From a7a5fd2c893103789710eb8c8886ed3c9de84c6e Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:19:32 -0400 Subject: [PATCH 01/27] Carry consumed schema semantics across the destination accessor The runtime model path needs every destination property that can change a constructed model or a planned write. The accessor's snapshot carried only attribute kind and relationship peer/cardinality, so optional/default/unique, relationship kind/optional, and each kind's ordered human-friendly ID and uniqueness-constraint component paths never crossed the adapter boundary. Add the closed normalized schema domain those consumers read, and widen the bundled Infrahub accessor to deliver it. Normalization is total: a value outside the domain refuses rather than being coerced, and members are ordered by name so snapshot delivery order cannot change anything derived from a snapshot. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/configuration/capabilities.py | 69 +++++-- infrahub_sync/runtime_schema/__init__.py | 33 ++++ infrahub_sync/runtime_schema/domain.py | 185 ++++++++++++++++++ infrahub_sync/runtime_schema/errors.py | 31 +++ tests/runtime_schema/__init__.py | 0 .../runtime_schema/test_accessor_snapshot.py | 64 ++++++ tests/runtime_schema/test_domain.py | 93 +++++++++ 7 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 infrahub_sync/runtime_schema/__init__.py create mode 100644 infrahub_sync/runtime_schema/domain.py create mode 100644 infrahub_sync/runtime_schema/errors.py create mode 100644 tests/runtime_schema/__init__.py create mode 100644 tests/runtime_schema/test_accessor_snapshot.py create mode 100644 tests/runtime_schema/test_domain.py diff --git a/infrahub_sync/configuration/capabilities.py b/infrahub_sync/configuration/capabilities.py index cfe2f749..0f28639b 100644 --- a/infrahub_sync/configuration/capabilities.py +++ b/infrahub_sync/configuration/capabilities.py @@ -5,6 +5,7 @@ import re from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass +from enum import Enum from types import MappingProxyType from typing import Any, Literal from urllib.parse import urlsplit @@ -15,9 +16,11 @@ WriteOperation = Literal["create", "update", "delete"] ConfigurationValidator = Callable[[ConfigurationPackage, AdapterRole], Sequence[ValidationFinding]] # The destination-schema accessor contract: (package, branch) -> one JSON-native schema -# snapshot, mapping each kind name to its attributes (name -> attribute kind) and -# relationships (name -> {"peer", "cardinality"}). Raises DestinationSchemaReadError and -# nothing else for a read that fails; performs I/O only when called, never at import. +# snapshot, mapping each kind name to its ordered "human_friendly_id" and +# "uniqueness_constraints" component paths, its attributes +# (name -> {"kind", "optional", "default_value", "unique"}), and its relationships +# (name -> {"peer", "cardinality", "optional", "kind"}). Raises DestinationSchemaReadError +# and nothing else for a read that fails; performs I/O only when called, never at import. DestinationSchemaAccessor = Callable[[ConfigurationPackage, str], Mapping[str, Any]] _SCHEMA_READ_REASON = re.compile(r"^[a-z]{1,32}$") _ADAPTER_NAME = re.compile(r"^[a-z][a-z0-9_-]*$") @@ -292,7 +295,12 @@ def _normalized_schema_snapshot(schema: object) -> Mapping[str, Any]: def _build_schema_snapshot(schema: object) -> dict[str, Any]: - """Build the snapshot from a third-party response, inside the boundary above.""" + """Build the snapshot from a third-party response, inside the boundary above. + + Each kind carries its ordered ``human_friendly_id`` and ``uniqueness_constraints`` + component paths and, per member, every property that can change a constructed + runtime model or a planned write. Nothing else from the response crosses. + """ if not isinstance(schema, Mapping): raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") snapshot: dict[str, Any] = {} @@ -300,9 +308,27 @@ def _build_schema_snapshot(schema: object) -> dict[str, Any]: if not isinstance(kind, str): raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") snapshot[kind] = { - "attributes": {attribute.name: attribute.kind for attribute in getattr(node, "attributes", ()) or ()}, + "human_friendly_id": [str(path) for path in getattr(node, "human_friendly_id", None) or ()], + "uniqueness_constraints": [ + [str(path) for path in constraint] + for constraint in getattr(node, "uniqueness_constraints", None) or () + ], + "attributes": { + attribute.name: { + "kind": _member_text(attribute.kind), + "optional": bool(attribute.optional), + "default_value": _json_native_default(attribute.default_value), + "unique": bool(attribute.unique), + } + for attribute in getattr(node, "attributes", ()) or () + }, "relationships": { - relationship.name: {"peer": relationship.peer, "cardinality": relationship.cardinality} + relationship.name: { + "peer": relationship.peer, + "cardinality": _member_text(relationship.cardinality), + "optional": bool(relationship.optional), + "kind": _member_text(relationship.kind), + } for relationship in getattr(node, "relationships", ()) or () }, } @@ -310,22 +336,43 @@ def _build_schema_snapshot(schema: object) -> dict[str, Any]: return snapshot +def _member_text(value: object) -> object: + """Return the value of an SDK string enum, leaving anything else to the shape check.""" + return value.value if isinstance(value, Enum) else value + + +def _json_native_default(value: object) -> Any: + """Keep a JSON-native declared default; refuse anything a model cannot reproduce.""" + if value is None or isinstance(value, (str, bool, int, float)): + return value + if isinstance(value, Enum): + return _json_native_default(value.value) + if isinstance(value, (list, tuple)): + return [_json_native_default(item) for item in value] + if isinstance(value, Mapping): + return {str(key): _json_native_default(item) for key, item in value.items()} + raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") + + def _require_usable_snapshot(snapshot: Mapping[str, Any]) -> None: - """Refuse a built snapshot that is not the string shape the schema checks consume. + """Refuse a built snapshot that is not the string shape its consumers expect. The last step inside the normalization boundary: the members were read without raising and every kind is already a string, but the snapshot is usable only when - every attribute name and kind, relationship name, peer, and cardinality is a string - too — the shape the SDK contract promises and ``compute_schema_subhash`` and the - content checks rely on. + every member name and every declared text property is a string too — the shape the + SDK contract promises and the content checks and the normalized runtime domain rely + on. """ for entry in snapshot.values(): attributes: dict[str, Any] = entry["attributes"] relationships: dict[str, Any] = entry["relationships"] - usable = all(isinstance(name, str) and isinstance(value, str) for name, value in attributes.items()) and all( + usable = all( + isinstance(name, str) and isinstance(attribute["kind"], str) for name, attribute in attributes.items() + ) and all( isinstance(name, str) and isinstance(relationship["peer"], str) and isinstance(relationship["cardinality"], str) + and isinstance(relationship["kind"], str) for name, relationship in relationships.items() ) if not usable: diff --git a/infrahub_sync/runtime_schema/__init__.py b/infrahub_sync/runtime_schema/__init__.py new file mode 100644 index 00000000..06453716 --- /dev/null +++ b/infrahub_sync/runtime_schema/__init__.py @@ -0,0 +1,33 @@ +"""Runtime destination-schema discovery and in-memory DiffSync model construction.""" + +from __future__ import annotations + +from .domain import ( + CARDINALITIES, + DestinationSchemaSnapshot, + NormalizedAttribute, + NormalizedKind, + NormalizedRelationship, + normalize_destination_schema, +) +from .errors import ( + DestinationSchemaUnavailableError, + MissingMappedKindError, + RuntimeSchemaError, + UnsupportedDestinationProfileError, + UnsupportedSchemaSemanticsError, +) + +__all__ = [ + "CARDINALITIES", + "DestinationSchemaSnapshot", + "DestinationSchemaUnavailableError", + "MissingMappedKindError", + "NormalizedAttribute", + "NormalizedKind", + "NormalizedRelationship", + "RuntimeSchemaError", + "UnsupportedDestinationProfileError", + "UnsupportedSchemaSemanticsError", + "normalize_destination_schema", +] diff --git a/infrahub_sync/runtime_schema/domain.py b/infrahub_sync/runtime_schema/domain.py new file mode 100644 index 00000000..1a3de72f --- /dev/null +++ b/infrahub_sync/runtime_schema/domain.py @@ -0,0 +1,185 @@ +"""The closed normalized destination-schema domain the runtime model path consumes. + +One immutable value per run. It carries only the facts Sync consumes — kind name, +ordered ``human_friendly_id`` and ``uniqueness_constraints`` component paths, and every +attribute and relationship property that can change a constructed model or a planned +write — so no SDK object, response text, or credential reaches the builder or the +fingerprint. + +Normalization is total over the JSON-native snapshot the destination accessor returns: +a value outside the domain raises :class:`UnsupportedSchemaSemanticsError` rather than +being coerced. Members are ordered by name, so snapshot delivery order cannot change a +normalized snapshot or anything derived from one. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from .errors import UnsupportedSchemaSemanticsError + +CARDINALITIES = frozenset({"one", "many"}) + + +@dataclass(frozen=True, slots=True) +class NormalizedAttribute: + """One destination attribute, with every property a model or write depends on.""" + + name: str + kind: str + optional: bool + default_value: Any + unique: bool + + +@dataclass(frozen=True, slots=True) +class NormalizedRelationship: + """One destination relationship, with its peer, shape, and relationship kind.""" + + name: str + peer: str + cardinality: str + optional: bool + kind: str + + +@dataclass(frozen=True, slots=True) +class NormalizedKind: + """One destination kind and its identity paths, members ordered by name.""" + + name: str + human_friendly_id: tuple[str, ...] + uniqueness_constraints: tuple[tuple[str, ...], ...] + attributes: tuple[NormalizedAttribute, ...] + relationships: tuple[NormalizedRelationship, ...] + + +@dataclass(frozen=True, slots=True) +class DestinationSchemaSnapshot: + """One immutable destination schema, keyed by kind name.""" + + kinds: Mapping[str, NormalizedKind] + + def __post_init__(self) -> None: + object.__setattr__(self, "kinds", dict(self.kinds)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, DestinationSchemaSnapshot): + return NotImplemented + return dict(self.kinds) == dict(other.kinds) + + def __hash__(self) -> int: + return hash(tuple(sorted(self.kinds))) + + +def _refuse(detail: str) -> UnsupportedSchemaSemanticsError: + return UnsupportedSchemaSemanticsError(f"destination schema snapshot is outside the supported domain: {detail}") + + +def _require_mapping(value: object, *, detail: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise _refuse(detail) + for key in value: + if not isinstance(key, str): + raise _refuse(detail) + return value + + +def _require_bool(value: object, *, detail: str) -> bool: + if not isinstance(value, bool): + raise _refuse(detail) + return value + + +def _require_str(value: object, *, detail: str) -> str: + if not isinstance(value, str): + raise _refuse(detail) + return value + + +def _require_json_default(value: object, *, detail: str) -> Any: + """Accept only a JSON-native default, so a model default is reproducible.""" + if value is None or isinstance(value, (str, bool, int, float)): + return value + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [_require_json_default(item, detail=detail) for item in value] + if isinstance(value, Mapping): + return { + _require_str(key, detail=detail): _require_json_default(item, detail=detail) + for key, item in value.items() + } + raise _refuse(detail) + + +def _component_paths(value: object, *, kind: str) -> tuple[str, ...]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + raise _refuse(f"kind {kind!r} declares a non-list component path") + return tuple(_require_str(item, detail=f"kind {kind!r} declares a non-string path component") for item in value) + + +def _normalized_attribute(name: str, entry: object, *, kind: str) -> NormalizedAttribute: + detail = f"kind {kind!r} attribute {name!r}" + member = _require_mapping(entry, detail=detail) + missing = {"kind", "optional", "default_value", "unique"} - set(member) + if missing: + raise _refuse(f"{detail} is missing {sorted(missing)!r}") + return NormalizedAttribute( + name=name, + kind=_require_str(member["kind"], detail=detail), + optional=_require_bool(member["optional"], detail=detail), + default_value=_require_json_default(member["default_value"], detail=detail), + unique=_require_bool(member["unique"], detail=detail), + ) + + +def _normalized_relationship(name: str, entry: object, *, kind: str) -> NormalizedRelationship: + detail = f"kind {kind!r} relationship {name!r}" + member = _require_mapping(entry, detail=detail) + missing = {"peer", "cardinality", "optional", "kind"} - set(member) + if missing: + raise _refuse(f"{detail} is missing {sorted(missing)!r}") + cardinality = _require_str(member["cardinality"], detail=detail) + if cardinality not in CARDINALITIES: + raise _refuse(f"{detail} declares cardinality {cardinality!r}") + return NormalizedRelationship( + name=name, + peer=_require_str(member["peer"], detail=detail), + cardinality=cardinality, + optional=_require_bool(member["optional"], detail=detail), + kind=_require_str(member["kind"], detail=detail), + ) + + +def normalize_destination_schema(snapshot: Mapping[str, Any]) -> DestinationSchemaSnapshot: + """Normalize one JSON-native destination snapshot into the closed domain. + + Raises: + UnsupportedSchemaSemanticsError: a member, property, or value the domain does + not declare. + """ + _require_mapping(snapshot, detail="snapshot root is not a mapping of kind names") + kinds: dict[str, NormalizedKind] = {} + for kind, entry in snapshot.items(): + member = _require_mapping(entry, detail=f"kind {kind!r} is not a mapping") + missing = {"human_friendly_id", "uniqueness_constraints", "attributes", "relationships"} - set(member) + if missing: + raise _refuse(f"kind {kind!r} is missing {sorted(missing)!r}") + raw_constraints = member["uniqueness_constraints"] + if not isinstance(raw_constraints, Sequence) or isinstance(raw_constraints, (str, bytes)): + raise _refuse(f"kind {kind!r} declares non-list uniqueness constraints") + attributes = _require_mapping(member["attributes"], detail=f"kind {kind!r} attributes") + relationships = _require_mapping(member["relationships"], detail=f"kind {kind!r} relationships") + kinds[kind] = NormalizedKind( + name=kind, + human_friendly_id=_component_paths(member["human_friendly_id"] or (), kind=kind), + uniqueness_constraints=tuple(_component_paths(item, kind=kind) for item in raw_constraints), + attributes=tuple( + _normalized_attribute(name, attributes[name], kind=kind) for name in sorted(attributes) + ), + relationships=tuple( + _normalized_relationship(name, relationships[name], kind=kind) for name in sorted(relationships) + ), + ) + return DestinationSchemaSnapshot(kinds=kinds) diff --git a/infrahub_sync/runtime_schema/errors.py b/infrahub_sync/runtime_schema/errors.py new file mode 100644 index 00000000..6feff882 --- /dev/null +++ b/infrahub_sync/runtime_schema/errors.py @@ -0,0 +1,31 @@ +"""Typed refusals of the runtime model path, raised before adapter extraction.""" + +from __future__ import annotations + + +class RuntimeSchemaError(Exception): + """A registered run cannot build its runtime models from the destination schema.""" + + +class UnsupportedDestinationProfileError(RuntimeSchemaError): + """The package's destination is outside the admitted runtime-model profile.""" + + +class DestinationSchemaUnavailableError(RuntimeSchemaError): + """The declared accessor could not deliver a destination schema snapshot. + + ``reason`` is the accessor's own short failure class ("timeout", "unauthorized", + ...). Nothing else from the failed read crosses this boundary. + """ + + def __init__(self, message: str, *, reason: str) -> None: + super().__init__(message) + self.reason = reason + + +class UnsupportedSchemaSemanticsError(RuntimeSchemaError): + """The snapshot carries a value outside the closed normalized schema domain.""" + + +class MissingMappedKindError(RuntimeSchemaError): + """The destination schema does not declare a kind the configuration maps.""" diff --git a/tests/runtime_schema/__init__.py b/tests/runtime_schema/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/runtime_schema/test_accessor_snapshot.py b/tests/runtime_schema/test_accessor_snapshot.py new file mode 100644 index 00000000..ce9e591f --- /dev/null +++ b/tests/runtime_schema/test_accessor_snapshot.py @@ -0,0 +1,64 @@ +"""The bundled Infrahub accessor delivers the properties the runtime path consumes.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from infrahub_sdk.schema.main import AttributeKind, NodeSchemaAPI + +from infrahub_sync.configuration import capabilities as capabilities_module +from infrahub_sync.runtime_schema import normalize_destination_schema + +_NODE: dict[str, Any] = { + "name": "Device", + "namespace": "Infra", + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": [ + {"name": "name", "kind": AttributeKind.TEXT, "optional": False, "unique": True}, + {"name": "role", "kind": AttributeKind.DROPDOWN, "optional": True, "default_value": "leaf"}, + ], + "relationships": [ + {"name": "site", "peer": "LocationSite", "cardinality": "one", "optional": False, "kind": "Attribute"}, + { + "name": "interfaces", + "peer": "InfraInterface", + "cardinality": "many", + "optional": True, + "kind": "Component", + }, + ], +} + + +@pytest.fixture(name="snapshot") +def _snapshot() -> dict[str, Any]: + node = NodeSchemaAPI.model_validate(_NODE) + return dict(capabilities_module._build_schema_snapshot({node.kind: node})) + + +def test_the_snapshot_carries_the_kind_identity_paths(snapshot: dict[str, Any]) -> None: + assert snapshot["InfraDevice"]["human_friendly_id"] == ["name__value"] + assert snapshot["InfraDevice"]["uniqueness_constraints"] == [["name__value"]] + + +def test_the_snapshot_carries_every_attribute_and_relationship_property(snapshot: dict[str, Any]) -> None: + assert snapshot["InfraDevice"]["attributes"]["role"] == { + "kind": "Dropdown", + "optional": True, + "default_value": "leaf", + "unique": False, + } + assert snapshot["InfraDevice"]["relationships"]["interfaces"] == { + "peer": "InfraInterface", + "cardinality": "many", + "optional": True, + "kind": "Component", + } + + +def test_the_delivered_snapshot_normalizes_into_the_closed_domain(snapshot: dict[str, Any]) -> None: + normalized = normalize_destination_schema(snapshot) + + assert normalized.kinds["InfraDevice"].human_friendly_id == ("name__value",) diff --git a/tests/runtime_schema/test_domain.py b/tests/runtime_schema/test_domain.py new file mode 100644 index 00000000..d695b657 --- /dev/null +++ b/tests/runtime_schema/test_domain.py @@ -0,0 +1,93 @@ +"""The closed normalized destination-schema domain the runtime model path consumes.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from infrahub_sync.runtime_schema import ( + UnsupportedSchemaSemanticsError, + normalize_destination_schema, +) + +_SNAPSHOT: dict[str, Any] = { + "InfraDevice": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"], ["site__name__value", "name__value"]], + "attributes": { + "name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "role": {"kind": "Dropdown", "optional": True, "default_value": "leaf", "unique": False}, + }, + "relationships": { + "site": {"peer": "LocationSite", "cardinality": "one", "optional": False, "kind": "Attribute"}, + "interfaces": {"peer": "InfraInterface", "cardinality": "many", "optional": True, "kind": "Component"}, + }, + }, +} + + +def test_normalized_kind_carries_every_consumed_property() -> None: + snapshot = normalize_destination_schema(_SNAPSHOT) + + kind = snapshot.kinds["InfraDevice"] + assert kind.name == "InfraDevice" + assert kind.human_friendly_id == ("name__value",) + assert kind.uniqueness_constraints == (("name__value",), ("site__name__value", "name__value")) + + name, role = kind.attributes + assert (name.name, name.kind, name.optional, name.default_value, name.unique) == ( + "name", + "Text", + False, + None, + True, + ) + assert (role.name, role.kind, role.optional, role.default_value, role.unique) == ( + "role", + "Dropdown", + True, + "leaf", + False, + ) + + interfaces, site = kind.relationships + assert (site.name, site.peer, site.cardinality, site.optional, site.kind) == ( + "site", + "LocationSite", + "one", + False, + "Attribute", + ) + assert interfaces.kind == "Component" + + +def test_normalization_orders_members_by_name_so_delivery_order_is_irrelevant() -> None: + reordered = { + "InfraDevice": { + **_SNAPSHOT["InfraDevice"], + "attributes": dict(reversed(list(_SNAPSHOT["InfraDevice"]["attributes"].items()))), + "relationships": dict(reversed(list(_SNAPSHOT["InfraDevice"]["relationships"].items()))), + } + } + + assert normalize_destination_schema(reordered) == normalize_destination_schema(_SNAPSHOT) + + +@pytest.mark.parametrize( + "mutation", + [ + pytest.param({"attributes": {"name": {"kind": "Text"}}}, id="attribute-missing-property"), + pytest.param( + {"relationships": {"site": {"peer": "LocationSite", "cardinality": "several", "optional": False, "kind": "Attribute"}}}, + id="unknown-cardinality", + ), + pytest.param({"human_friendly_id": ["name__value", 7]}, id="non-string-hfid-component"), + pytest.param({"uniqueness_constraints": ["name__value"]}, id="uniqueness-constraint-not-a-path-list"), + ], +) +def test_unusable_snapshot_members_refuse_with_a_typed_error(mutation: dict[str, Any]) -> None: + entry = {**_SNAPSHOT["InfraDevice"], **mutation} + + with pytest.raises(UnsupportedSchemaSemanticsError): + normalize_destination_schema({"InfraDevice": entry}) From 20d2fd79f72d61ec62e47368fb8f49830f832d79 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:24:35 -0400 Subject: [PATCH 02/27] Build DiffSync model classes in memory from one schema snapshot A registered worker has no generated Python to import, so it needs the model classes the generator would have written, constructed from the destination schema it discovers at run time. Build them with type(...) over a generated-equivalent intermediate base, reusing the generator's own identity and field-selection helpers so the two mechanisms cannot drift. The attribute-kind table is explicit rather than the generator's unknown-kind-to-str fallback: it names the four string-like kinds the maintained NetBox schema really declares, and a kind outside it refuses before extraction. Every call returns fresh classes, so two configurations sharing a kind name cannot reach each other's models. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/configuration/capabilities.py | 3 +- infrahub_sync/runtime_schema/__init__.py | 4 + infrahub_sync/runtime_schema/domain.py | 46 ++-- infrahub_sync/runtime_schema/models.py | 157 ++++++++++++ tests/runtime_schema/test_domain.py | 8 +- tests/runtime_schema/test_model_builder.py | 268 ++++++++++++++++++++ 6 files changed, 460 insertions(+), 26 deletions(-) create mode 100644 infrahub_sync/runtime_schema/models.py create mode 100644 tests/runtime_schema/test_model_builder.py diff --git a/infrahub_sync/configuration/capabilities.py b/infrahub_sync/configuration/capabilities.py index 0f28639b..8ba08608 100644 --- a/infrahub_sync/configuration/capabilities.py +++ b/infrahub_sync/configuration/capabilities.py @@ -310,8 +310,7 @@ def _build_schema_snapshot(schema: object) -> dict[str, Any]: snapshot[kind] = { "human_friendly_id": [str(path) for path in getattr(node, "human_friendly_id", None) or ()], "uniqueness_constraints": [ - [str(path) for path in constraint] - for constraint in getattr(node, "uniqueness_constraints", None) or () + [str(path) for path in constraint] for constraint in getattr(node, "uniqueness_constraints", None) or () ], "attributes": { attribute.name: { diff --git a/infrahub_sync/runtime_schema/__init__.py b/infrahub_sync/runtime_schema/__init__.py index 06453716..e9742237 100644 --- a/infrahub_sync/runtime_schema/__init__.py +++ b/infrahub_sync/runtime_schema/__init__.py @@ -17,8 +17,10 @@ UnsupportedDestinationProfileError, UnsupportedSchemaSemanticsError, ) +from .models import ATTRIBUTE_TYPE_DOMAIN, build_runtime_models, mapped_attribute_kinds __all__ = [ + "ATTRIBUTE_TYPE_DOMAIN", "CARDINALITIES", "DestinationSchemaSnapshot", "DestinationSchemaUnavailableError", @@ -29,5 +31,7 @@ "RuntimeSchemaError", "UnsupportedDestinationProfileError", "UnsupportedSchemaSemanticsError", + "build_runtime_models", + "mapped_attribute_kinds", "normalize_destination_schema", ] diff --git a/infrahub_sync/runtime_schema/domain.py b/infrahub_sync/runtime_schema/domain.py index 1a3de72f..105fc3a6 100644 --- a/infrahub_sync/runtime_schema/domain.py +++ b/infrahub_sync/runtime_schema/domain.py @@ -16,7 +16,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any +from typing import Any, NoReturn from .errors import UnsupportedSchemaSemanticsError @@ -47,9 +47,13 @@ class NormalizedRelationship: @dataclass(frozen=True, slots=True) class NormalizedKind: - """One destination kind and its identity paths, members ordered by name.""" + """One destination kind and its identity paths, members ordered by name. - name: str + Spelled ``kind`` rather than ``name`` because the shipping generator helpers the + model builder reuses read a node's kind under that name. + """ + + kind: str human_friendly_id: tuple[str, ...] uniqueness_constraints: tuple[tuple[str, ...], ...] attributes: tuple[NormalizedAttribute, ...] @@ -74,28 +78,29 @@ def __hash__(self) -> int: return hash(tuple(sorted(self.kinds))) -def _refuse(detail: str) -> UnsupportedSchemaSemanticsError: - return UnsupportedSchemaSemanticsError(f"destination schema snapshot is outside the supported domain: {detail}") +def _refuse(detail: str) -> NoReturn: + msg = f"destination schema snapshot is outside the supported domain: {detail}" + raise UnsupportedSchemaSemanticsError(msg) def _require_mapping(value: object, *, detail: str) -> Mapping[str, Any]: if not isinstance(value, Mapping): - raise _refuse(detail) + _refuse(detail) for key in value: if not isinstance(key, str): - raise _refuse(detail) + _refuse(detail) return value def _require_bool(value: object, *, detail: str) -> bool: if not isinstance(value, bool): - raise _refuse(detail) + _refuse(detail) return value def _require_str(value: object, *, detail: str) -> str: if not isinstance(value, str): - raise _refuse(detail) + _refuse(detail) return value @@ -107,15 +112,14 @@ def _require_json_default(value: object, *, detail: str) -> Any: return [_require_json_default(item, detail=detail) for item in value] if isinstance(value, Mapping): return { - _require_str(key, detail=detail): _require_json_default(item, detail=detail) - for key, item in value.items() + _require_str(key, detail=detail): _require_json_default(item, detail=detail) for key, item in value.items() } - raise _refuse(detail) + _refuse(detail) def _component_paths(value: object, *, kind: str) -> tuple[str, ...]: if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): - raise _refuse(f"kind {kind!r} declares a non-list component path") + _refuse(f"kind {kind!r} declares a non-list component path") return tuple(_require_str(item, detail=f"kind {kind!r} declares a non-string path component") for item in value) @@ -124,7 +128,7 @@ def _normalized_attribute(name: str, entry: object, *, kind: str) -> NormalizedA member = _require_mapping(entry, detail=detail) missing = {"kind", "optional", "default_value", "unique"} - set(member) if missing: - raise _refuse(f"{detail} is missing {sorted(missing)!r}") + _refuse(f"{detail} is missing {sorted(missing)!r}") return NormalizedAttribute( name=name, kind=_require_str(member["kind"], detail=detail), @@ -139,10 +143,10 @@ def _normalized_relationship(name: str, entry: object, *, kind: str) -> Normaliz member = _require_mapping(entry, detail=detail) missing = {"peer", "cardinality", "optional", "kind"} - set(member) if missing: - raise _refuse(f"{detail} is missing {sorted(missing)!r}") + _refuse(f"{detail} is missing {sorted(missing)!r}") cardinality = _require_str(member["cardinality"], detail=detail) if cardinality not in CARDINALITIES: - raise _refuse(f"{detail} declares cardinality {cardinality!r}") + _refuse(f"{detail} declares cardinality {cardinality!r}") return NormalizedRelationship( name=name, peer=_require_str(member["peer"], detail=detail), @@ -165,19 +169,17 @@ def normalize_destination_schema(snapshot: Mapping[str, Any]) -> DestinationSche member = _require_mapping(entry, detail=f"kind {kind!r} is not a mapping") missing = {"human_friendly_id", "uniqueness_constraints", "attributes", "relationships"} - set(member) if missing: - raise _refuse(f"kind {kind!r} is missing {sorted(missing)!r}") + _refuse(f"kind {kind!r} is missing {sorted(missing)!r}") raw_constraints = member["uniqueness_constraints"] if not isinstance(raw_constraints, Sequence) or isinstance(raw_constraints, (str, bytes)): - raise _refuse(f"kind {kind!r} declares non-list uniqueness constraints") + _refuse(f"kind {kind!r} declares non-list uniqueness constraints") attributes = _require_mapping(member["attributes"], detail=f"kind {kind!r} attributes") relationships = _require_mapping(member["relationships"], detail=f"kind {kind!r} relationships") kinds[kind] = NormalizedKind( - name=kind, + kind=kind, human_friendly_id=_component_paths(member["human_friendly_id"] or (), kind=kind), uniqueness_constraints=tuple(_component_paths(item, kind=kind) for item in raw_constraints), - attributes=tuple( - _normalized_attribute(name, attributes[name], kind=kind) for name in sorted(attributes) - ), + attributes=tuple(_normalized_attribute(name, attributes[name], kind=kind) for name in sorted(attributes)), relationships=tuple( _normalized_relationship(name, relationships[name], kind=kind) for name in sorted(relationships) ), diff --git a/infrahub_sync/runtime_schema/models.py b/infrahub_sync/runtime_schema/models.py new file mode 100644 index 00000000..b39b21d6 --- /dev/null +++ b/infrahub_sync/runtime_schema/models.py @@ -0,0 +1,157 @@ +"""Per-side, per-run DiffSync model classes built in memory from one snapshot. + +Construction reproduces the shipping generator exactly inside the closed attribute-kind +domain below: the same identifiers, the same ``_attributes``, the same annotations and +defaults, and the same generated-equivalent intermediate base that owns ``local_id`` and +``local_data`` only when the resolved model base does not already carry them. Field +inclusion and identity come from the generator's own helpers, so the two mechanisms +cannot drift apart; only the materialization differs — ``type(...)`` instead of rendered +text. + +No ``_children`` mapping is emitted, matching the generator: saved-plan derivation +refuses nested child diffs, so runtime construction must not activate them. +""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, cast + +from diffsync import DiffSyncModel + +from infrahub_sync.generator import get_attributes, get_identifiers, has_field, has_node + +from .domain import NormalizedAttribute, NormalizedKind, NormalizedRelationship +from .errors import UnsupportedSchemaSemanticsError + +if TYPE_CHECKING: + from collections.abc import Mapping + + from infrahub_sdk.schema import NodeSchema + + from infrahub_sync import SyncConfig + + from .domain import DestinationSchemaSnapshot + +# The closed attribute-kind domain. It is the generator's ``ATTRIBUTE_KIND_MAP` plus the +# four string-like kinds the maintained NetBox schema really declares — ``Dropdown``, +# ``MacAddress``, ``IPHost`` and ``IPNetwork`` — which the generator reaches only through +# its unknown-kind-to-``str`` fallback. Naming them keeps those qualified kinds without +# keeping the fallback: a kind outside this table refuses rather than becoming a string. +ATTRIBUTE_TYPE_DOMAIN: Mapping[str, Any] = MappingProxyType( + { + "Text": str, + "String": str, + "TextArea": str, + "DateTime": str, + "HashedPassword": str, + "Dropdown": str, + "MacAddress": str, + "IPHost": str, + "IPNetwork": str, + "Number": int, + "Integer": int, + "Boolean": bool, + "Checkbox": bool, + "List": list[Any], + } +) + +_REQUIRED = object() + + +def _attribute_field(attribute: NormalizedAttribute, *, kind: str) -> tuple[Any, Any]: + """Return the annotation and default the generator would render for an attribute.""" + try: + python_type = ATTRIBUTE_TYPE_DOMAIN[attribute.kind] + except KeyError: + msg = ( + f"destination kind {kind!r} maps attribute {attribute.name!r} of kind " + f"{attribute.kind!r}, which is outside the supported attribute kinds" + ) + raise UnsupportedSchemaSemanticsError(msg) from None + if not attribute.optional: + return python_type, _REQUIRED + return python_type | None, attribute.default_value + + +def _relationship_field(relationship: NormalizedRelationship) -> tuple[Any, Any]: + """Return the annotation and default the generator would render for a relationship.""" + if relationship.cardinality == "one": + return (str | None, None) if relationship.optional else (str, _REQUIRED) + return (list[str] | None, []) if relationship.optional else (list[str], []) + + +def _intermediate_base(model_base: type[DiffSyncModel]) -> type[DiffSyncModel]: + """Build the generated file's ``_GeneratedModelBase`` over a resolved model base.""" + declared = getattr(model_base, "model_fields", {}) + annotations: dict[str, Any] = {} + namespace: dict[str, Any] = {} + if "local_id" not in declared: + annotations["local_id"] = str | None + namespace["local_id"] = None + if "local_data" not in declared: + annotations["local_data"] = Any | None + namespace["local_data"] = None + namespace["__annotations__"] = annotations + return cast("type[DiffSyncModel]", type("_GeneratedModelBase", (model_base,), namespace)) + + +def build_runtime_models( + *, + snapshot: DestinationSchemaSnapshot, + configuration: SyncConfig, + model_base: type[DiffSyncModel], +) -> dict[str, type[DiffSyncModel]]: + """Build one side's fresh ``{kind: model class}`` mapping for one run. + + Every call returns new class objects over a new intermediate base, so two + configurations sharing a kind name — or a rebuild after a schema change — cannot + reach each other's classes. Nothing is cached, registered, or written. + + Raises: + UnsupportedSchemaSemanticsError: a mapped attribute declares a kind outside + :data:`ATTRIBUTE_TYPE_DOMAIN`. + """ + intermediate = _intermediate_base(model_base) + models: dict[str, type[DiffSyncModel]] = {} + for kind, node in sorted(snapshot.kinds.items()): + # The generator helpers read a node's kind, attributes and relationships, which + # the normalized kind presents under the same names. + view = cast("NodeSchema", node) + identifiers = get_identifiers(node=view, config=configuration) + if not identifiers or not has_node(config=configuration, name=kind): + continue + annotations: dict[str, Any] = {} + namespace: dict[str, Any] = { + "_modelname": kind, + "_identifiers": tuple(identifiers), + "_attributes": tuple(sorted(get_attributes(node=view, config=configuration) or ())), + } + for member in (*node.attributes, *node.relationships): + if not has_field(config=configuration, name=kind, field=member.name): + continue + annotation, default = ( + _attribute_field(member, kind=kind) + if isinstance(member, NormalizedAttribute) + else _relationship_field(member) + ) + annotations[member.name] = annotation + if default is not _REQUIRED: + namespace[member.name] = default + namespace["__annotations__"] = annotations + models[kind] = cast("type[DiffSyncModel]", type(kind, (intermediate,), namespace)) + return models + + +def mapped_attribute_kinds(snapshot: DestinationSchemaSnapshot, configuration: SyncConfig) -> set[str]: + """Return every attribute kind the configuration maps on a declared kind.""" + return { + attribute.kind + for node in snapshot.kinds.values() + for attribute in node.attributes + if has_field(config=configuration, name=node.kind, field=attribute.name) + } + + +__all__ = ["ATTRIBUTE_TYPE_DOMAIN", "NormalizedKind", "build_runtime_models", "mapped_attribute_kinds"] diff --git a/tests/runtime_schema/test_domain.py b/tests/runtime_schema/test_domain.py index d695b657..1d1ab2f6 100644 --- a/tests/runtime_schema/test_domain.py +++ b/tests/runtime_schema/test_domain.py @@ -31,7 +31,7 @@ def test_normalized_kind_carries_every_consumed_property() -> None: snapshot = normalize_destination_schema(_SNAPSHOT) kind = snapshot.kinds["InfraDevice"] - assert kind.name == "InfraDevice" + assert kind.kind == "InfraDevice" assert kind.human_friendly_id == ("name__value",) assert kind.uniqueness_constraints == (("name__value",), ("site__name__value", "name__value")) @@ -79,7 +79,11 @@ def test_normalization_orders_members_by_name_so_delivery_order_is_irrelevant() [ pytest.param({"attributes": {"name": {"kind": "Text"}}}, id="attribute-missing-property"), pytest.param( - {"relationships": {"site": {"peer": "LocationSite", "cardinality": "several", "optional": False, "kind": "Attribute"}}}, + { + "relationships": { + "site": {"peer": "LocationSite", "cardinality": "several", "optional": False, "kind": "Attribute"} + } + }, id="unknown-cardinality", ), pytest.param({"human_friendly_id": ["name__value", 7]}, id="non-string-hfid-component"), diff --git a/tests/runtime_schema/test_model_builder.py b/tests/runtime_schema/test_model_builder.py new file mode 100644 index 00000000..66c27618 --- /dev/null +++ b/tests/runtime_schema/test_model_builder.py @@ -0,0 +1,268 @@ +"""AR2: runtime model construction matches the generator over the supported domain.""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +import sys +import warnings +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import pytest +from diffsync import DiffSyncModel +from infrahub_sdk.schema import ( + AttributeSchema, + NodeSchema, + RelationshipKind, + RelationshipSchema, +) +from infrahub_sdk.schema.main import AttributeKind + +from infrahub_sync import ( + DiffSyncModelMixin, + SchemaMappingField, + SchemaMappingModel, + SyncAdapter, + SyncConfig, + SyncInstance, +) +from infrahub_sync.adapters.infrahub import InfrahubModel +from infrahub_sync.configuration import capabilities as capabilities_module +from infrahub_sync.runtime_schema import ( + ATTRIBUTE_TYPE_DOMAIN, + UnsupportedSchemaSemanticsError, + build_runtime_models, + normalize_destination_schema, +) +from infrahub_sync.utils import get_instance, render_adapter + +if TYPE_CHECKING: + from infrahub_sdk.schema import GenericSchema + +REPO_ROOT = Path(__file__).resolve().parents[2] +SNAPSHOT_DIR = REPO_ROOT / "tests" / "data" / "generator_schema_snapshots" +SchemaMapping = dict[str, "NodeSchema | GenericSchema"] + + +def _load_sdk_schema(snapshot_name: str) -> SchemaMapping: + entries = json.loads((SNAPSHOT_DIR / snapshot_name).read_text(encoding="utf-8")) + return { + kind: getattr(importlib.import_module(entry["class_module"]), entry["class_name"]).model_validate(entry["data"]) + for kind, entry in entries.items() + } + + +def _describe(model: type[DiffSyncModel]) -> dict[str, Any]: + """The comparable surface of one model class.""" + return { + "modelname": model._modelname, + "identifiers": list(model._identifiers), + "attributes": list(model._attributes), + "children": dict(model._children), + "base": model.__mro__[1].__mro__[1].__name__, + "fields": { + name: {"annotation": str(info.annotation).replace("typing.", ""), "default": repr(info.default)} + for name, info in model.model_fields.items() + }, + } + + +def _generated_models( + instance: SyncInstance, schema: SchemaMapping, out_dir: Path, tag: str +) -> dict[str, type[DiffSyncModel]]: + """Render the example with the shipping generator, then import what it wrote.""" + instance.directory = str(out_dir) + render_adapter(sync_instance=instance, schema=schema) + path = out_dir / instance.destination.name / "sync_models.py" + spec = importlib.util.spec_from_file_location(f"runtime_parity_{tag}", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return { + cast("type[DiffSyncModel]", obj)._modelname: cast("type[DiffSyncModel]", obj) + for name, obj in vars(module).items() + if isinstance(obj, type) and issubclass(obj, InfrahubModel) and not name.startswith("_") + } + + +def _runtime_models( + configuration: SyncConfig, schema: SchemaMapping, base: type[DiffSyncModel] +) -> dict[str, type[DiffSyncModel]]: + snapshot = normalize_destination_schema(capabilities_module._build_schema_snapshot(schema)) + return build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=base) + + +def test_runtime_classes_match_the_generator_over_the_netbox_example(tmp_path: Path) -> None: + instance = get_instance(name="from-netbox", directory=str(REPO_ROOT / "examples")) + assert instance is not None + schema = _load_sdk_schema("netbox_example_schema.json") + + runtime = _runtime_models(instance, schema, InfrahubModel) + generated = _generated_models(instance, schema, tmp_path, "netbox") + + assert set(runtime) == set(generated) + assert len(runtime) == 20 + assert {kind: _describe(model) for kind, model in runtime.items()} == { + kind: _describe(model) for kind, model in generated.items() + } + + +def test_a_mapped_component_relationship_stays_out_of_the_attributes(tmp_path: Path) -> None: + node = NodeSchema( + name="Device", + namespace="Infra", + attributes=[AttributeSchema(name="name", kind=AttributeKind.TEXT, unique=True)], + relationships=[ + RelationshipSchema(name="interfaces", peer="InfraInterface", cardinality="many"), + RelationshipSchema(name="site", peer="LocationSite", cardinality="one"), + ], + ) + node.relationships[0].kind = RelationshipKind.COMPONENT + configuration = SyncConfig( + name="component-example", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="InfraDevice", + fields=[ + SchemaMappingField(name="name"), + SchemaMappingField(name="interfaces"), + SchemaMappingField(name="site"), + ], + ) + ], + ) + schema: SchemaMapping = {node.kind: node} + + runtime = _runtime_models(configuration, schema, InfrahubModel) + generated = _generated_models( + SyncInstance(**configuration.model_dump(), directory=str(tmp_path)), schema, tmp_path, "component" + ) + + assert "interfaces" in runtime["InfraDevice"].model_fields + assert "interfaces" not in runtime["InfraDevice"]._attributes + assert runtime["InfraDevice"]._children == {} + assert _describe(runtime["InfraDevice"]) == _describe(generated["InfraDevice"]) + + +class _SourceModelBase(DiffSyncModelMixin, DiffSyncModel): + """Stands in for a source adapter's own model base, which needs no optional driver.""" + + +def test_each_side_derives_from_its_own_resolved_model_base() -> None: + instance = get_instance(name="from-netbox", directory=str(REPO_ROOT / "examples")) + assert instance is not None + schema = _load_sdk_schema("netbox_example_schema.json") + + source = _runtime_models(instance, schema, _SourceModelBase) + destination = _runtime_models(instance, schema, InfrahubModel) + + assert issubclass(source["BuiltinTag"], _SourceModelBase) + assert issubclass(destination["BuiltinTag"], InfrahubModel) + assert source["BuiltinTag"] is not destination["BuiltinTag"] + + +def test_the_intermediate_base_only_declares_locals_the_resolved_base_lacks() -> None: + instance = get_instance(name="from-netbox", directory=str(REPO_ROOT / "examples")) + assert instance is not None + schema = _load_sdk_schema("netbox_example_schema.json") + + with warnings.catch_warnings(): + # Declaring the locals on the base is the point of this case; the shadow warning + # is what the generated-equivalent intermediate base exists to avoid repeating. + warnings.simplefilter("ignore", UserWarning) + + class _CarriesLocals(InfrahubModel): + local_id: str | None = None + local_data: Any | None = None + + built = build_runtime_models( + snapshot=normalize_destination_schema(capabilities_module._build_schema_snapshot(schema)), + configuration=instance, + model_base=_CarriesLocals, + ) + + intermediate = built["BuiltinTag"].__mro__[1] + assert set(intermediate.__annotations__) == set() + + +def test_two_configurations_sharing_a_kind_get_distinct_classes() -> None: + schema = _load_sdk_schema("netbox_example_schema.json") + snapshot = normalize_destination_schema(capabilities_module._build_schema_snapshot(schema)) + + def _configuration(fields: list[str]) -> SyncConfig: + return SyncConfig( + name="shared-kind", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="BuiltinTag", + identifiers=["name"], + fields=[SchemaMappingField(name=field) for field in fields], + ) + ], + ) + + first = build_runtime_models(snapshot=snapshot, configuration=_configuration(["name"]), model_base=InfrahubModel) + second = build_runtime_models( + snapshot=snapshot, configuration=_configuration(["name", "description"]), model_base=InfrahubModel + ) + + assert first["BuiltinTag"] is not second["BuiltinTag"] + assert set(first["BuiltinTag"].model_fields) < set(second["BuiltinTag"].model_fields) + + +def test_an_attribute_kind_outside_the_closed_table_refuses_before_extraction() -> None: + snapshot = normalize_destination_schema( + { + "InfraDevice": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": { + "name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "bandwidth": {"kind": "Bandwidth", "optional": True, "default_value": None, "unique": False}, + }, + "relationships": {}, + } + } + ) + configuration = SyncConfig( + name="unknown-kind", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="InfraDevice", + fields=[SchemaMappingField(name="name"), SchemaMappingField(name="bandwidth")], + ) + ], + ) + + with pytest.raises(UnsupportedSchemaSemanticsError): + build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=InfrahubModel) + + +@pytest.mark.parametrize( + ("snapshot_name", "example_name"), + [("netbox_example_schema.json", "from-netbox"), ("custom_example_schema.json", "custom-example")], +) +def test_every_captured_mapped_attribute_kind_is_inside_the_closed_table(snapshot_name: str, example_name: str) -> None: + instance = get_instance(name=example_name, directory=str(REPO_ROOT / "examples")) + assert instance is not None + mapped = {(mapping.name, field.name) for mapping in instance.schema_mapping for field in mapping.fields or ()} + + captured = { + attribute.kind + for kind, node in _load_sdk_schema(snapshot_name).items() + for attribute in node.attributes + if (kind, attribute.name) in mapped + } + + assert captured + assert captured <= set(ATTRIBUTE_TYPE_DOMAIN) From 16ed7900c4a7a708c19b55372a379b9f1a8c34fa Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:26:17 -0400 Subject: [PATCH 03/27] Fingerprint the schema semantics a configuration consumes Comparing a saved plan against a live schema needs a digest that changes exactly when the destination changes something the plan depended on. The existing subhash covers the configuration mapping plus sorted kind names, so a mapped attribute can change from Number to Text and still hash the same. Project the consumed semantics instead: each configured kind, its effective DiffSync identifiers, its ordered human-friendly ID and uniqueness-constraint component paths, every mapped field's properties, and every mandatory-without-default field on those kinds, mapped or not. Digest that canonically with SHA-256. Unmapped growth and snapshot delivery order do not move it. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/runtime_schema/__init__.py | 6 + infrahub_sync/runtime_schema/models.py | 3 +- infrahub_sync/runtime_schema/projection.py | 100 ++++++++++++ tests/runtime_schema/test_projection.py | 175 +++++++++++++++++++++ 4 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 infrahub_sync/runtime_schema/projection.py create mode 100644 tests/runtime_schema/test_projection.py diff --git a/infrahub_sync/runtime_schema/__init__.py b/infrahub_sync/runtime_schema/__init__.py index e9742237..697cef20 100644 --- a/infrahub_sync/runtime_schema/__init__.py +++ b/infrahub_sync/runtime_schema/__init__.py @@ -18,6 +18,10 @@ UnsupportedSchemaSemanticsError, ) from .models import ATTRIBUTE_TYPE_DOMAIN, build_runtime_models, mapped_attribute_kinds +from .projection import ( + canonical_consumed_schema_projection, + compute_consumed_schema_fingerprint, +) __all__ = [ "ATTRIBUTE_TYPE_DOMAIN", @@ -32,6 +36,8 @@ "UnsupportedDestinationProfileError", "UnsupportedSchemaSemanticsError", "build_runtime_models", + "canonical_consumed_schema_projection", + "compute_consumed_schema_fingerprint", "mapped_attribute_kinds", "normalize_destination_schema", ] diff --git a/infrahub_sync/runtime_schema/models.py b/infrahub_sync/runtime_schema/models.py index b39b21d6..304b2cfc 100644 --- a/infrahub_sync/runtime_schema/models.py +++ b/infrahub_sync/runtime_schema/models.py @@ -17,8 +17,6 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast -from diffsync import DiffSyncModel - from infrahub_sync.generator import get_attributes, get_identifiers, has_field, has_node from .domain import NormalizedAttribute, NormalizedKind, NormalizedRelationship @@ -27,6 +25,7 @@ if TYPE_CHECKING: from collections.abc import Mapping + from diffsync import DiffSyncModel from infrahub_sdk.schema import NodeSchema from infrahub_sync import SyncConfig diff --git a/infrahub_sync/runtime_schema/projection.py b/infrahub_sync/runtime_schema/projection.py new file mode 100644 index 00000000..d8e4ae46 --- /dev/null +++ b/infrahub_sync/runtime_schema/projection.py @@ -0,0 +1,100 @@ +"""The one compatibility property: a canonical projection of consumed schema semantics. + +A plan's schema fingerprint is SHA-256 over this projection. It carries every fact a +registered configuration consumes — each configured kind, its effective DiffSync +identifiers, its ordered destination human-friendly ID and uniqueness-constraint +component paths, every mapped field's model- and write-affecting properties, and the +semantics of every mandatory-without-default field on those kinds, mapped or not, +because such a field can reject a retained create. + +Everything else is compatible growth: an unmapped kind, an optional or defaulted +unmapped field, and any difference in snapshot delivery order leave the projection — +and so the fingerprint — unchanged. +""" + +from __future__ import annotations + +import hashlib +from typing import TYPE_CHECKING, Any, cast + +from infrahub_sync.generator import get_identifiers, has_field +from infrahub_sync.plan.canonical import canonical_json_bytes + +from .domain import NormalizedAttribute + +if TYPE_CHECKING: + from infrahub_sdk.schema import NodeSchema + + from infrahub_sync import SyncConfig + + from .domain import DestinationSchemaSnapshot, NormalizedKind, NormalizedRelationship + + +def _member_semantics(member: NormalizedAttribute | NormalizedRelationship) -> dict[str, Any]: + """Project one member's model- and write-affecting properties.""" + if isinstance(member, NormalizedAttribute): + return { + "name": member.name, + "role": "attribute", + "kind": member.kind, + "optional": member.optional, + "default_value": member.default_value, + "unique": member.unique, + } + return { + "name": member.name, + "role": "relationship", + "peer": member.peer, + "cardinality": member.cardinality, + "optional": member.optional, + "kind": member.kind, + } + + +def _is_mandatory_without_default(member: NormalizedAttribute | NormalizedRelationship) -> bool: + """Whether this member can reject a create the plan retained.""" + if member.optional: + return False + return member.default_value is None if isinstance(member, NormalizedAttribute) else True + + +def _kind_projection(node: NormalizedKind, configuration: SyncConfig) -> dict[str, Any]: + """Project one consumed kind's identity, mapped fields, and mandatory fields.""" + members = (*node.attributes, *node.relationships) + mapped = { + member.name: member for member in members if has_field(config=configuration, name=node.kind, field=member.name) + } + identifiers = get_identifiers(node=cast("NodeSchema", node), config=configuration) + return { + "kind": node.kind, + "present": True, + "identifiers": list(identifiers) if identifiers else None, + "human_friendly_id": list(node.human_friendly_id), + # The outer list is sorted because the destination does not order its constraints + # against each other; the components inside one constraint stay in declared order. + "uniqueness_constraints": sorted(list(constraint) for constraint in node.uniqueness_constraints), + "fields": [_member_semantics(mapped[name]) for name in sorted(mapped)], + "mandatory_without_default": [ + _member_semantics(member) + for member in sorted(members, key=lambda item: item.name) + if member.name not in mapped and _is_mandatory_without_default(member) + ], + } + + +def canonical_consumed_schema_projection( + *, configuration: SyncConfig, snapshot: DestinationSchemaSnapshot +) -> list[dict[str, Any]]: + """Project the schema semantics one configuration consumes, in a canonical order.""" + return [ + {"kind": mapping.name, "present": False} + if mapping.name not in snapshot.kinds + else _kind_projection(snapshot.kinds[mapping.name], configuration) + for mapping in sorted(configuration.schema_mapping, key=lambda mapping: mapping.name) + ] + + +def compute_consumed_schema_fingerprint(*, configuration: SyncConfig, snapshot: DestinationSchemaSnapshot) -> str: + """Return the full SHA-256 digest of the canonical consumed-semantics projection.""" + projection = canonical_consumed_schema_projection(configuration=configuration, snapshot=snapshot) + return hashlib.sha256(canonical_json_bytes(projection)).hexdigest() diff --git a/tests/runtime_schema/test_projection.py b/tests/runtime_schema/test_projection.py new file mode 100644 index 00000000..691716b4 --- /dev/null +++ b/tests/runtime_schema/test_projection.py @@ -0,0 +1,175 @@ +"""AR6: compatibility closes by consumed semantics, derived from the snapshot schema.""" + +from __future__ import annotations + +import copy +from typing import Any + +import pytest + +from infrahub_sync import SchemaMappingField, SchemaMappingModel, SyncAdapter, SyncConfig +from infrahub_sync.runtime_schema import compute_consumed_schema_fingerprint, normalize_destination_schema + +_SNAPSHOT: dict[str, Any] = { + "InfraDevice": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": { + "name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "role": {"kind": "Dropdown", "optional": True, "default_value": "leaf", "unique": False}, + "asn": {"kind": "Number", "optional": False, "default_value": None, "unique": False}, + }, + "relationships": { + "site": {"peer": "LocationSite", "cardinality": "one", "optional": True, "kind": "Attribute"}, + "tags": {"peer": "BuiltinTag", "cardinality": "many", "optional": True, "kind": "Generic"}, + }, + }, + "LocationSite": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}}, + "relationships": {}, + }, +} + +_CONFIGURATION = SyncConfig( + name="fingerprint-example", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="InfraDevice", + fields=[ + SchemaMappingField(name="name"), + SchemaMappingField(name="role"), + SchemaMappingField(name="site"), + ], + ) + ], +) + + +def _fingerprint(snapshot: dict[str, Any]) -> str: + return compute_consumed_schema_fingerprint( + configuration=_CONFIGURATION, snapshot=normalize_destination_schema(snapshot) + ) + + +def _mutated(**changes: object) -> dict[str, Any]: + snapshot = copy.deepcopy(_SNAPSHOT) + entry = snapshot["InfraDevice"] + for path, value in changes.items(): + target: Any = entry + *parents, leaf = path.split(".") + for step in parents: + target = target[step] + target[leaf] = value + return snapshot + + +def test_the_fingerprint_is_a_full_sha256_digest() -> None: + fingerprint = _fingerprint(_SNAPSHOT) + + assert len(fingerprint) == 64 + assert set(fingerprint) <= set("0123456789abcdef") + + +def test_the_fingerprint_is_stable_across_repeated_projections() -> None: + assert _fingerprint(_SNAPSHOT) == _fingerprint(copy.deepcopy(_SNAPSHOT)) + + +@pytest.mark.parametrize( + "snapshot", + [ + pytest.param( + {kind: _SNAPSHOT[kind] for kind in reversed(list(_SNAPSHOT))}, + id="kind-delivery-order", + ), + pytest.param( + _mutated(attributes=dict(reversed(list(_SNAPSHOT["InfraDevice"]["attributes"].items())))), + id="attribute-delivery-order", + ), + pytest.param( + _mutated(relationships=dict(reversed(list(_SNAPSHOT["InfraDevice"]["relationships"].items())))), + id="relationship-delivery-order", + ), + pytest.param( + { + **_SNAPSHOT, + "InfraInterface": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [], + "attributes": {"name": {"kind": "Text", "optional": True, "default_value": None, "unique": False}}, + "relationships": {}, + }, + }, + id="unmapped-kind-added", + ), + pytest.param( + _mutated( + **{ + "attributes.description": { + "kind": "TextArea", + "optional": True, + "default_value": None, + "unique": False, + } + } + ), + id="optional-unmapped-attribute-added", + ), + ], +) +def test_compatible_change_retains_the_fingerprint(snapshot: dict[str, Any]) -> None: + assert _fingerprint(snapshot) == _fingerprint(_SNAPSHOT) + + +@pytest.mark.parametrize( + "snapshot", + [ + pytest.param({"LocationSite": _SNAPSHOT["LocationSite"]}, id="consumed-kind-removed"), + pytest.param(_mutated(human_friendly_id=["name__value", "site__name__value"]), id="human-friendly-id"), + pytest.param( + _mutated(uniqueness_constraints=[["name__value", "site__name__value"]]), id="uniqueness-constraint" + ), + pytest.param(_mutated(**{"attributes.name.unique": False}), id="identifier-uniqueness"), + pytest.param(_mutated(**{"attributes.role.kind": "Number"}), id="mapped-attribute-kind"), + pytest.param(_mutated(**{"attributes.role.optional": False}), id="mapped-attribute-required"), + pytest.param(_mutated(**{"attributes.role.default_value": "spine"}), id="mapped-attribute-default"), + pytest.param(_mutated(**{"attributes.role.unique": True}), id="mapped-attribute-uniqueness"), + pytest.param(_mutated(**{"relationships.site.peer": "LocationRegion"}), id="mapped-relationship-peer"), + pytest.param(_mutated(**{"relationships.site.cardinality": "many"}), id="mapped-relationship-cardinality"), + pytest.param(_mutated(**{"relationships.site.optional": False}), id="mapped-relationship-optional"), + pytest.param(_mutated(**{"relationships.site.kind": "Component"}), id="mapped-relationship-kind"), + pytest.param( + _mutated( + **{ + "attributes.serial": { + "kind": "Text", + "optional": False, + "default_value": None, + "unique": False, + } + } + ), + id="mandatory-unmapped-attribute-added", + ), + pytest.param( + _mutated( + **{ + "relationships.owner": { + "peer": "CoreAccount", + "cardinality": "one", + "optional": False, + "kind": "Attribute", + } + } + ), + id="mandatory-unmapped-relationship-added", + ), + pytest.param(_mutated(**{"attributes.asn.optional": True}), id="mandatory-unmapped-attribute-relaxed"), + pytest.param(_mutated(**{"attributes.asn.kind": "Text"}), id="mandatory-unmapped-attribute-kind"), + ], +) +def test_incompatible_change_changes_the_fingerprint(snapshot: dict[str, Any]) -> None: + assert _fingerprint(snapshot) != _fingerprint(_SNAPSHOT) From 1276dde64d1a4cdd1d771ef4ef259af9abc26674 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:27:19 -0400 Subject: [PATCH 04/27] Resolve the destination branch through one shared rule Schema discovery, destination adapter construction, and the plan's destination binding each resolved a branch, and the interim validation helper read only the declared setting. A run could then discover one branch's schema while writing another's. Give them one resolver: declared destination branch, else the run request's branch, else main. Validation has no run request and passes none, so its behavior is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/configuration/runtime.py | 19 +++++++++++- .../configuration/schema_validation.py | 17 ++++------ infrahub_sync/utils.py | 5 +-- tests/runtime_schema/test_effective_branch.py | 31 +++++++++++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 tests/runtime_schema/test_effective_branch.py diff --git a/infrahub_sync/configuration/runtime.py b/infrahub_sync/configuration/runtime.py index 9832cbdc..2cf8b09e 100644 --- a/infrahub_sync/configuration/runtime.py +++ b/infrahub_sync/configuration/runtime.py @@ -2,16 +2,33 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from infrahub_sync import SyncInstance from .credentials import _REGISTERED_CONTEXT, resolve_reference if TYPE_CHECKING: + from collections.abc import Mapping + from .models import ConfigurationPackage +def effective_destination_branch(settings: Mapping[str, Any] | None, run_branch: str | None) -> str: + """Resolve the one destination branch a run works against. + + Declared ``destination.settings.branch`` first, then the run request's branch, then + ``"main"`` — the SDK's own default. Schema discovery, destination adapter + construction, and the plan's destination binding all resolve through this, so a run + cannot read one branch's schema and write another's. Explicit configuration + validation has no run request and passes ``None``. + """ + declared = (settings or {}).get("branch") + if isinstance(declared, str) and declared: + return declared + return run_branch or "main" + + def resolve_runtime_instance(package: ConfigurationPackage, *, directory: str) -> SyncInstance: """Resolve declared credential references without adapter ambient lookup.""" diff --git a/infrahub_sync/configuration/schema_validation.py b/infrahub_sync/configuration/schema_validation.py index d08180f8..9f982e4e 100644 --- a/infrahub_sync/configuration/schema_validation.py +++ b/infrahub_sync/configuration/schema_validation.py @@ -41,6 +41,7 @@ from .capabilities import BUILTIN_ADAPTER_CAPABILITIES, DestinationSchemaReadError from .models import ValidationFinding, sort_findings +from .runtime import effective_destination_branch from .validation import _finding_message if TYPE_CHECKING: @@ -79,19 +80,13 @@ class DestinationSchemaValidation: def resolve_declared_destination_branch(package: ConfigurationPackage) -> str: - """Resolve the destination branch from the declared setting only. + """Resolve the destination branch validation reads, through the shared rule. - The interim SYNC-79 helper: reads ``destination.settings.branch`` and nothing else — - never the ambient environment — defaulting to ``"main"``, the SDK's own default - branch. Its consumers are the schema read and, through the snapshot that read - returns, the fingerprint subhash. CF-005's shared resolver replaces this at its one - call site. + Validation judges declared content and has no run request, so it passes no run + branch: the result is the declared branch or ``"main"``. Execution calls the same + resolver with the run's branch. """ - settings = package.configuration.destination.settings or {} - branch = settings.get("branch") - if isinstance(branch, str) and branch: - return branch - return "main" + return effective_destination_branch(package.configuration.destination.settings, None) def _finding(*, code: str, location: str, message: str) -> ValidationFinding: diff --git a/infrahub_sync/utils.py b/infrahub_sync/utils.py index 2e813464..f11c94d4 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -13,6 +13,7 @@ from infrahub_sync import SyncAdapter, SyncConfig, SyncInstance from infrahub_sync.cache.paths import run_dir as stored_run_dir +from infrahub_sync.configuration.runtime import effective_destination_branch from infrahub_sync.generator import render_template from infrahub_sync.plan.errors import PlanVerificationError from infrahub_sync.plan.reader import read_plan_artifact_bytes @@ -234,7 +235,7 @@ def get_potenda_from_instance( "internal_storage_engine": destination_store, } if "infrahub" in sync_instance.destination.name.lower(): - dest_kwargs["branch"] = (sync_instance.destination.settings or {}).get("branch") or branch or "main" + dest_kwargs["branch"] = effective_destination_branch(sync_instance.destination.settings, branch) try: dst = destination(**dest_kwargs) @@ -365,7 +366,7 @@ def open_existing( "internal_storage_engine": _destination_store(sync_instance), } if "infrahub" in sync_instance.destination.name.lower(): - dest_kwargs["branch"] = (sync_instance.destination.settings or {}).get("branch") or branch or "main" + dest_kwargs["branch"] = effective_destination_branch(sync_instance.destination.settings, branch) try: destination = destination_class(**dest_kwargs) except (ValueError, TypeError) as exc: diff --git a/tests/runtime_schema/test_effective_branch.py b/tests/runtime_schema/test_effective_branch.py new file mode 100644 index 00000000..8f5f172a --- /dev/null +++ b/tests/runtime_schema/test_effective_branch.py @@ -0,0 +1,31 @@ +"""AR5: one branch rule feeds discovery, adapter construction, and destination binding.""" + +from __future__ import annotations + +import pytest + +from infrahub_sync.configuration.runtime import effective_destination_branch + + +@pytest.mark.parametrize( + ("declared", "run_branch", "expected"), + [ + ("staging", "review", "staging"), + ("staging", None, "staging"), + (None, "review", "review"), + (None, None, "main"), + ("", "review", "review"), + (None, "", "main"), + ], +) +def test_the_declared_branch_wins_then_the_run_request_then_main( + declared: str | None, run_branch: str | None, expected: str +) -> None: + settings = {} if declared is None else {"branch": declared} + + assert effective_destination_branch(settings, run_branch) == expected + + +def test_absent_settings_resolve_the_same_way() -> None: + assert effective_destination_branch(None, "review") == "review" + assert effective_destination_branch(None, None) == "main" From cd211b770a7c508423cbf2d818b6ba1c3201d8a8 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:28:09 -0400 Subject: [PATCH 05/27] Separate installed adapter resolution from generated-wrapper precedence A registered worker has no generated Python in its configuration directory, but the only resolution path also probed for one. Split the installed half out: dotted path, entry point, or built-in module, plus the model base under the same spec the generated models file uses. import_adapter keeps its generated-first behavior for the legacy local path and now delegates its fallback here, so both paths resolve installed code the same way and the registered path never reads the configuration directory. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/plugin_loader.py | 31 +++++++ infrahub_sync/utils.py | 27 ++----- .../test_installed_resolution.py | 81 +++++++++++++++++++ 3 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 tests/runtime_schema/test_installed_resolution.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index 008de140..936188df 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -29,6 +29,8 @@ if TYPE_CHECKING: from collections.abc import Iterable + from infrahub_sync import SyncAdapter, SyncConfig + class PluginLoadError(Exception): """Exception raised when a plugin cannot be loaded.""" @@ -479,3 +481,32 @@ def _find_class_by_name_candidates( return cls return None + + +def resolve_installed_adapter_class(configuration: SyncConfig, adapter: SyncAdapter) -> type[Any]: + """Resolve one side's adapter class from installed code only. + + The registered worker's resolution seam: dotted path, entry point, or built-in + module, through the same loader the generated wrapper would have used. It never + reads the configuration directory, so generated Python cannot reach a registered run. + + Raises: + PluginLoadError: no installed class answers the declared adapter. + """ + loader = PluginLoader.from_env_and_args(adapter_paths=configuration.adapters_path or []) + return loader.resolve(adapter.adapter or adapter.name) + + +def resolve_installed_model_base(configuration: SyncConfig, adapter: SyncAdapter) -> type[Any]: + """Resolve one side's DiffSync model base from installed code only. + + Uses the spec the generated models file uses — the module half of an explicit + adapter spec, otherwise the adapter name — so a runtime-built class derives from the + same base a generated one would have. + + Raises: + PluginLoadError: no installed class answers the declared adapter. + """ + loader = PluginLoader.from_env_and_args(adapter_paths=configuration.adapters_path or []) + spec = adapter.adapter.split(":")[0] if adapter.adapter else adapter.name + return loader.resolve(spec, default_class_candidates=("Model",)) diff --git a/infrahub_sync/utils.py b/infrahub_sync/utils.py index f11c94d4..f5d92ca6 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -18,7 +18,7 @@ from infrahub_sync.plan.errors import PlanVerificationError from infrahub_sync.plan.reader import read_plan_artifact_bytes from infrahub_sync.plan.verify import destination_binding_failure -from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError +from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError, resolve_installed_adapter_class from infrahub_sync.potenda import Potenda logger = logging.getLogger(__name__) @@ -105,28 +105,15 @@ def import_adapter(sync_instance: SyncInstance, adapter: SyncAdapter): except (ImportError, AttributeError, SyntaxError, TypeError, ValueError, OSError) as exc: logger.warning("Could not load generated adapter from %s: %s", adapter_file_path, exc) - # Fall back to the plugin loader + # Fall back to installed resolution. # The "sync" classes could be declared into a separate module - adapter_paths = sync_instance.adapters_path or [] - loader = PluginLoader.from_env_and_args(adapter_paths=adapter_paths) - - # If explicit adapter spec is provided, use it - if adapter.adapter: - try: - # Try loading the explicitly specified adapter - adapter_class = loader.resolve(adapter.adapter) - logger.debug("Using directly specified adapter class: %s", adapter_class.__name__) - except PluginLoadError as exc: + try: + return resolve_installed_adapter_class(sync_instance, adapter) + except PluginLoadError as exc: + if adapter.adapter: msg = f"Failed to load adapter '{adapter.adapter}': {exc}" raise ImportError(msg) from exc - else: - return adapter_class - - else: - try: - return loader.resolve(adapter.name) - except PluginLoadError: - return None + return None def get_all_sync(directory: str | None = None) -> list[SyncInstance]: diff --git a/tests/runtime_schema/test_installed_resolution.py b/tests/runtime_schema/test_installed_resolution.py new file mode 100644 index 00000000..9eb773cc --- /dev/null +++ b/tests/runtime_schema/test_installed_resolution.py @@ -0,0 +1,81 @@ +"""AR9: installed resolution never reaches generated Python in the configuration directory.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from infrahub_sync import SyncAdapter, SyncInstance +from infrahub_sync.adapters.infrahub import InfrahubAdapter, InfrahubModel +from infrahub_sync.plugin_loader import ( + PluginLoadError, + resolve_installed_adapter_class, + resolve_installed_model_base, +) +from infrahub_sync.utils import import_adapter + + +def _instance(directory: Path) -> SyncInstance: + return SyncInstance( + name="installed-resolution", + source=SyncAdapter(name="infrahub"), + destination=SyncAdapter(name="infrahub"), + directory=str(directory), + ) + + +def _write_generated_wrapper(directory: Path) -> None: + package = directory / "infrahub" + package.mkdir(parents=True) + (package / "__init__.py").touch() + (package / "sync_adapter.py").write_text( + "class InfrahubSync:\n generated = True\n", + encoding="utf-8", + ) + + +def test_installed_resolution_ignores_a_generated_wrapper(tmp_path: Path) -> None: + _write_generated_wrapper(tmp_path) + instance = _instance(tmp_path) + + assert resolve_installed_adapter_class(instance, instance.destination) is InfrahubAdapter + + +def test_the_generated_wrapper_still_takes_precedence_for_the_legacy_path(tmp_path: Path) -> None: + _write_generated_wrapper(tmp_path) + instance = _instance(tmp_path) + + resolved = import_adapter(sync_instance=instance, adapter=instance.destination) + + assert resolved is not InfrahubAdapter + assert resolved.generated is True + + +def test_the_installed_model_base_matches_the_generated_wrapper_spec(tmp_path: Path) -> None: + instance = _instance(tmp_path) + + assert resolve_installed_model_base(instance, instance.destination) is InfrahubModel + + +def test_an_explicit_adapter_spec_resolves_its_module_for_the_model_base(tmp_path: Path) -> None: + instance = SyncInstance( + name="explicit-spec", + source=SyncAdapter(name="infrahub", adapter="infrahub_sync.adapters.infrahub:InfrahubAdapter"), + destination=SyncAdapter(name="infrahub"), + directory=str(tmp_path), + ) + + assert resolve_installed_model_base(instance, instance.source) is InfrahubModel + + +def test_an_unresolvable_installed_model_base_refuses(tmp_path: Path) -> None: + instance = SyncInstance( + name="unknown-base", + source=SyncAdapter(name="nowhere"), + destination=SyncAdapter(name="infrahub"), + directory=str(tmp_path), + ) + + with pytest.raises(PluginLoadError): + resolve_installed_model_base(instance, instance.source) From c4be105fd2c10aba6d7bda4445985a354b86c2d9 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:36:52 -0400 Subject: [PATCH 06/27] Bind registered execution to runtime models and migrate the validation fingerprint Registered composition now builds one run's model plan: it refuses a destination outside the Infrahub profile before any schema I/O, reads the destination schema once through the declared capability, and derives both sides' adapter classes, both sides' model classes, and the plan's schema fingerprint from that single snapshot. Engine assembly and the apply seam bind those classes onto the run's adapter instances, so a registered run never reads generated Python and two runs cannot share a class object. The legacy path is untouched. Configuration validation reports the same consumed-semantics fingerprint instead of the kind-name subhash, so a fingerprint a client sees is the fingerprint a run of that package against that schema records. The incremental cache keeps its own subhash. Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/durable-product-records.mdx | 8 + infrahub_sync/__init__.py | 6 + .../configuration/schema_validation.py | 23 +- infrahub_sync/managed/flow.py | 16 +- infrahub_sync/product_store/configs.py | 9 +- infrahub_sync/runtime_schema/__init__.py | 10 + infrahub_sync/runtime_schema/worker.py | 157 +++++++ infrahub_sync/utils.py | 25 +- tests/configuration/test_schema_validation.py | 36 +- .../test_validate_destination_schema.py | 14 +- .../test_installed_resolution.py | 25 +- tests/runtime_schema/test_model_builder.py | 2 +- tests/runtime_schema/test_worker_path.py | 384 ++++++++++++++++++ 13 files changed, 682 insertions(+), 33 deletions(-) create mode 100644 infrahub_sync/runtime_schema/worker.py create mode 100644 tests/runtime_schema/test_worker_path.py diff --git a/docs/docs/reference/durable-product-records.mdx b/docs/docs/reference/durable-product-records.mdx index 6918ea36..4b049b6c 100644 --- a/docs/docs/reference/durable-product-records.mdx +++ b/docs/docs/reference/durable-product-records.mdx @@ -95,6 +95,14 @@ only, performs no schema read and no network I/O, and never emits them. All four | `destination-schema-validation-unsupported` | Schema validation was explicitly requested against a destination adapter that does not declare it. A missing capability needed to determine safety is an error, not a warning. | `/configuration/destination` | | `unsupported-destination-write` | The configuration requests destination write operations the destination adapter does not declare support for. | `/configuration/destination` | +A successful opt-in read also returns `destination_schema_fingerprint`: the full SHA-256 +digest of the schema semantics this configuration consumes — each mapped kind, its +DiffSync identifiers, its ordered human-friendly ID and uniqueness-constraint component +paths, every mapped field's type and required/default/unique properties, and every +mandatory-without-default field on those kinds. Unmapped destination growth and +differences in schema delivery order leave it unchanged. It is `null` whenever no +snapshot was read — the default path, a non-declaring destination, or a failed read. + #### Warning-channel codes The warning channel is closed: warnings are limited to intentional omissions and diff --git a/infrahub_sync/__init__.py b/infrahub_sync/__init__.py index c8e59601..4415115c 100644 --- a/infrahub_sync/__init__.py +++ b/infrahub_sync/__init__.py @@ -18,6 +18,8 @@ from collections.abc import Callable from diffsync.store import BaseStore + + from infrahub_sync.runtime_schema import RuntimeModelPlan from diffsync.enum import DiffSyncFlags from jinja2 import StrictUndefined from jinja2.nativetypes import NativeEnvironment @@ -149,6 +151,10 @@ class SyncInstance(SyncConfig): directory: str # Worker-only state, deliberately absent from serialized configuration data. _configuration_binding: tuple[str, int, str] | None = pydantic.PrivateAttr(default=None) + # The registered run's runtime model plan, when one was built. Its presence is what + # tells engine assembly to use installed resolution and bind in-memory classes rather + # than the legacy generated-wrapper path. + _runtime_models: RuntimeModelPlan | None = pydantic.PrivateAttr(default=None) def resolve_effective_diffsync_flags( diff --git a/infrahub_sync/configuration/schema_validation.py b/infrahub_sync/configuration/schema_validation.py index 9f982e4e..5b362862 100644 --- a/infrahub_sync/configuration/schema_validation.py +++ b/infrahub_sync/configuration/schema_validation.py @@ -37,7 +37,11 @@ from typing import TYPE_CHECKING, Any from infrahub_sync import requested_destination_write_operations -from infrahub_sync.cache import compute_schema_subhash +from infrahub_sync.runtime_schema import ( + UnsupportedSchemaSemanticsError, + compute_consumed_schema_fingerprint, + normalize_destination_schema, +) from .capabilities import BUILTIN_ADAPTER_CAPABILITIES, DestinationSchemaReadError from .models import ValidationFinding, sort_findings @@ -70,9 +74,11 @@ class DestinationSchemaOptions: class DestinationSchemaValidation: """Every schema-path finding for one package, with the judged snapshot's identity. - ``schema_fingerprint`` is the ``compute_schema_subhash`` identity of the snapshot the - content checks actually judged — ``None`` whenever no snapshot was read: a - non-declaring destination, an unknown adapter, or a failed read. + ``schema_fingerprint`` is the consumed-semantics identity of the snapshot the content + checks actually judged — ``None`` whenever no snapshot was read: a non-declaring + destination, an unknown adapter, or a failed read. Validation, worker construction, + and apply share this one projection, so a fingerprint reported here is the fingerprint + a plan of the same package against the same schema records. """ findings: tuple[ValidationFinding, ...] @@ -221,6 +227,13 @@ def collect_destination_schema_findings(package: ConfigurationPackage) -> Destin ) ) else: - fingerprint = compute_schema_subhash(package.configuration, dict(snapshot)) + try: + fingerprint = compute_consumed_schema_fingerprint( + configuration=package.configuration, snapshot=normalize_destination_schema(snapshot) + ) + except UnsupportedSchemaSemanticsError: + # A snapshot the accessor delivered but the closed domain refuses has no + # identity to report; the content checks below still judge what they can. + fingerprint = None findings.extend(_schema_content_findings(package, snapshot)) return DestinationSchemaValidation(findings=sort_findings(findings), schema_fingerprint=fingerprint) diff --git a/infrahub_sync/managed/flow.py b/infrahub_sync/managed/flow.py index 25d7774c..15a38f95 100644 --- a/infrahub_sync/managed/flow.py +++ b/infrahub_sync/managed/flow.py @@ -47,6 +47,7 @@ ExecutionWriteback, ProductProjection, ) +from infrahub_sync.runtime_schema import build_runtime_model_plan from .liveness import LivenessPolicy from .models import PlanResource @@ -245,8 +246,14 @@ def _worker_execution_context( *, config_directory: str, projection: ProductProjection, + run_branch: str | None, ) -> tuple[ProductProjection, Any, str]: - """Load the durable run and resolve its registered or legacy runtime.""" + """Load the durable run and resolve its registered or legacy runtime. + + A registered run also builds its runtime model plan here, from one destination + schema read, before any adapter is constructed or any source is extracted. The + legacy path keeps its generated-code resolution and builds no plan. + """ stored = projection.lookup_run(run_id) if stored.value is None: msg = f"API-created Sync run {run_id!r} is unavailable" @@ -275,6 +282,7 @@ def _worker_execution_context( raise ValueError(_REGISTERED_CHECKSUM_MISMATCH) instance = resolve_runtime_instance(package, directory=config_directory) instance._configuration_binding = binding + instance._runtime_models = build_runtime_model_plan(package=package, instance=instance, run_branch=run_branch) return projection, instance, package.configuration.name @@ -295,7 +303,11 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar """Resolve and execute one managed stage within the sanitized worker boundary.""" parameter_binding = _worker_binding(config_id, registry_version, package_checksum) projection, instance, sync_name = _worker_execution_context( - run_id, parameter_binding, config_directory=config_directory, projection=projection + run_id, + parameter_binding, + config_directory=config_directory, + projection=projection, + run_branch=branch, ) if stage in ("apply", "sync") and not confirm_writes: msg = f"confirm_writes=true is required for managed stage={stage}" diff --git a/infrahub_sync/product_store/configs.py b/infrahub_sync/product_store/configs.py index 1093ea3c..8f0aaa83 100644 --- a/infrahub_sync/product_store/configs.py +++ b/infrahub_sync/product_store/configs.py @@ -515,10 +515,11 @@ class RegisteredVersion: class ValidationReport: """Every declared defect in one registered version, already in contract order. - ``destination_schema_fingerprint`` is the identity of the destination schema snapshot - the schema checks judged — ``None`` whenever no snapshot was read: the default path, - a non-declaring destination, or a failed read. It is what makes "same package, same - schema snapshot, same report" auditable rather than asserted. + ``destination_schema_fingerprint`` is the consumed-semantics identity of the + destination schema snapshot the schema checks judged — ``None`` whenever no snapshot + was read: the default path, a non-declaring destination, or a failed read. It is what + makes "same package, same schema snapshot, same report" auditable rather than + asserted, and it is the same projection a run records on its plan. """ config_id: str diff --git a/infrahub_sync/runtime_schema/__init__.py b/infrahub_sync/runtime_schema/__init__.py index 697cef20..af8642b8 100644 --- a/infrahub_sync/runtime_schema/__init__.py +++ b/infrahub_sync/runtime_schema/__init__.py @@ -22,6 +22,12 @@ canonical_consumed_schema_projection, compute_consumed_schema_fingerprint, ) +from .worker import ( + RuntimeModelPlan, + bind_runtime_models, + build_runtime_model_plan, + read_destination_schema_snapshot, +) __all__ = [ "ATTRIBUTE_TYPE_DOMAIN", @@ -32,12 +38,16 @@ "NormalizedAttribute", "NormalizedKind", "NormalizedRelationship", + "RuntimeModelPlan", "RuntimeSchemaError", "UnsupportedDestinationProfileError", "UnsupportedSchemaSemanticsError", + "bind_runtime_models", + "build_runtime_model_plan", "build_runtime_models", "canonical_consumed_schema_projection", "compute_consumed_schema_fingerprint", "mapped_attribute_kinds", "normalize_destination_schema", + "read_destination_schema_snapshot", ] diff --git a/infrahub_sync/runtime_schema/worker.py b/infrahub_sync/runtime_schema/worker.py new file mode 100644 index 00000000..a32f8b19 --- /dev/null +++ b/infrahub_sync/runtime_schema/worker.py @@ -0,0 +1,157 @@ +"""Registered composition's runtime model plan: one schema read, one set of classes. + +A registered run reads the destination schema once, through the destination adapter's +own declared capability, and derives everything it needs from that one immutable value: +both sides' model classes and the plan's schema fingerprint. There is no second read to +disagree with the first, and no generated Python on the path. + +The admitted profile is an Infrahub destination — it owns both V3 seams, schema +discovery and saved-plan writes. A package outside it refuses here, before any schema +I/O. An installed non-bundled source paired with an Infrahub destination may execute; it +is admitted, not qualified. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from infrahub_sync.configuration.capabilities import ( + DestinationSchemaReadError, + get_adapter_capabilities, +) +from infrahub_sync.configuration.capabilities import ( + UnknownAdapterCapabilitiesError as _UnknownAdapterCapabilitiesError, +) +from infrahub_sync.configuration.runtime import effective_destination_branch +from infrahub_sync.plugin_loader import resolve_installed_adapter_class, resolve_installed_model_base + +from .domain import normalize_destination_schema +from .errors import ( + DestinationSchemaUnavailableError, + MissingMappedKindError, + UnsupportedDestinationProfileError, +) +from .models import build_runtime_models +from .projection import compute_consumed_schema_fingerprint + +if TYPE_CHECKING: + from collections.abc import Mapping + + from diffsync import DiffSyncModel + + from infrahub_sync import SyncConfig, SyncInstance + from infrahub_sync.configuration.models import ConfigurationPackage + + +@dataclass(frozen=True, slots=True) +class RuntimeModelPlan: + """One run's resolved adapter classes, model classes, and schema identity.""" + + branch: str + schema_fingerprint: str + source_adapter_class: type[Any] + source_models: Mapping[str, type[DiffSyncModel]] + destination_adapter_class: type[Any] + destination_models: Mapping[str, type[DiffSyncModel]] + + +def read_destination_schema_snapshot(package: ConfigurationPackage, branch: str) -> Mapping[str, Any]: + """Read one destination schema snapshot through the declared capability accessor. + + The worker's single schema read, and the only place a registered run performs schema + I/O. Tests replace this module attribute to inject a snapshot. + + Raises: + UnsupportedDestinationProfileError: the destination declares no schema accessor. + DestinationSchemaReadError: the accessor's own typed read failure. + """ + accessor = _require_admitted_destination(package.configuration).destination_schema_accessor + if accessor is None: # pragma: no cover - the admission check already refused this + msg = "destination declares no schema accessor" + raise UnsupportedDestinationProfileError(msg) + return accessor(package, branch) + + +def _require_admitted_destination(configuration: SyncConfig) -> Any: + """Return the destination's declaration, refusing a destination outside the profile.""" + name = configuration.destination.name + try: + capabilities = get_adapter_capabilities(name) + except _UnknownAdapterCapabilitiesError: + msg = ( + f"destination adapter {name!r} is outside the supported runtime-model profile: " + "it has no configuration capability declaration" + ) + raise UnsupportedDestinationProfileError(msg) from None + if not capabilities.destination_schema_validation: + msg = ( + f"destination adapter {name!r} is outside the supported runtime-model profile: " + "it does not declare destination schema discovery" + ) + raise UnsupportedDestinationProfileError(msg) + return capabilities + + +def _require_mapped_kinds(configuration: SyncConfig, snapshot_kinds: Mapping[str, Any]) -> None: + """Refuse a configuration that maps a kind the destination schema does not declare.""" + missing = sorted({mapping.name for mapping in configuration.schema_mapping} - set(snapshot_kinds)) + if missing: + msg = f"destination schema declares none of the mapped kinds {missing!r}" + raise MissingMappedKindError(msg) + + +def build_runtime_model_plan( + *, + package: ConfigurationPackage, + instance: SyncInstance, + run_branch: str | None, +) -> RuntimeModelPlan: + """Build one registered run's adapter classes, model classes, and schema fingerprint. + + Raises: + UnsupportedDestinationProfileError: the destination is outside the admitted + profile; raised before any schema I/O. + DestinationSchemaUnavailableError: the declared accessor could not deliver a + snapshot. Carries the accessor's short reason and nothing else from the read. + UnsupportedSchemaSemanticsError: the snapshot, or a mapped attribute's kind, is + outside the closed schema domain. + MissingMappedKindError: the schema declares no kind for a configured mapping. + PluginLoadError: an installed adapter class or model base does not resolve. + """ + _require_admitted_destination(instance) + branch = effective_destination_branch(instance.destination.settings, run_branch) + try: + raw = read_destination_schema_snapshot(package, branch) + except DestinationSchemaReadError as exc: + msg = f"destination schema for branch {branch!r} could not be read: {exc.reason}" + raise DestinationSchemaUnavailableError(msg, reason=exc.reason) from None + snapshot = normalize_destination_schema(raw) + _require_mapped_kinds(instance, snapshot.kinds) + return RuntimeModelPlan( + branch=branch, + schema_fingerprint=compute_consumed_schema_fingerprint(configuration=instance, snapshot=snapshot), + source_adapter_class=resolve_installed_adapter_class(instance, instance.source), + source_models=build_runtime_models( + snapshot=snapshot, + configuration=instance, + model_base=resolve_installed_model_base(instance, instance.source), + ), + destination_adapter_class=resolve_installed_adapter_class(instance, instance.destination), + destination_models=build_runtime_models( + snapshot=snapshot, + configuration=instance, + model_base=resolve_installed_model_base(instance, instance.destination), + ), + ) + + +def bind_runtime_models(adapter: object, models: Mapping[str, type[DiffSyncModel]]) -> None: + """Bind one side's model classes onto that run's adapter instance. + + Instance attributes, exactly where the generated ``Sync`` class declared them, + so no module, file, process-global registry, or adapter base class is touched and a + second run cannot reach these classes. + """ + for kind, model in models.items(): + setattr(adapter, kind, model) diff --git a/infrahub_sync/utils.py b/infrahub_sync/utils.py index f5d92ca6..9cbad813 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -20,6 +20,7 @@ from infrahub_sync.plan.verify import destination_binding_failure from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError, resolve_installed_adapter_class from infrahub_sync.potenda import Potenda +from infrahub_sync.runtime_schema import bind_runtime_models logger = logging.getLogger(__name__) @@ -176,8 +177,15 @@ def get_potenda_from_instance( When ``run_id`` is None, a fresh sortable identifier is allocated via ``generate_run_id()`` so each invocation gets its own cache directory. """ - source = import_adapter(sync_instance=sync_instance, adapter=sync_instance.source) - destination = import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) + runtime_models = sync_instance._runtime_models + if runtime_models is None: + source = import_adapter(sync_instance=sync_instance, adapter=sync_instance.source) + destination = import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) + else: + # A registered run resolved both classes from installed code already, so nothing + # here reads the configuration directory. + source = runtime_models.source_adapter_class + destination = runtime_models.destination_adapter_class if not source or not destination: missing = [] @@ -214,6 +222,8 @@ def get_potenda_from_instance( except (ValueError, TypeError) as exc: msg = f"Error initializing {sync_instance.source.name.title()}Adapter: {exc}" raise ValueError(msg) from exc + if runtime_models is not None: + bind_runtime_models(src, runtime_models.source_models) dest_kwargs = { "config": sync_instance, @@ -229,6 +239,8 @@ def get_potenda_from_instance( except (ValueError, TypeError) as exc: msg = f"Error initializing {sync_instance.destination.name.title()}Adapter: {exc}" raise ValueError(msg) from exc + if runtime_models is not None: + bind_runtime_models(dst, runtime_models.destination_models) # Single topological pass yields both the flat order and the tier layout # (tiers is None when an explicit `order` is configured). @@ -341,7 +353,12 @@ def open_existing( ImportError: the destination adapter could not be loaded. ValueError: the destination adapter could not be initialized. """ - destination_class = import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) + runtime_models = sync_instance._runtime_models + destination_class = ( + import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) + if runtime_models is None + else runtime_models.destination_adapter_class + ) if not destination_class: msg = f"Could not load the destination adapter '{sync_instance.destination.name}'" raise ImportError(msg) @@ -359,6 +376,8 @@ def open_existing( except (ValueError, TypeError) as exc: msg = f"Error initializing {sync_instance.destination.name.title()}Adapter: {exc}" raise ValueError(msg) from exc + if runtime_models is not None: + bind_runtime_models(destination, runtime_models.destination_models) top_level, tiers = sync_instance.compute_order_and_tiers() diff --git a/tests/configuration/test_schema_validation.py b/tests/configuration/test_schema_validation.py index 1a83e846..6bef29aa 100644 --- a/tests/configuration/test_schema_validation.py +++ b/tests/configuration/test_schema_validation.py @@ -22,7 +22,6 @@ from infrahub_sdk import exceptions as sdk_exceptions import infrahub_sync -from infrahub_sync.cache import compute_schema_subhash from infrahub_sync.configuration import capabilities as capabilities_module from infrahub_sync.configuration import schema_validation from infrahub_sync.configuration import validation as validation_module @@ -37,6 +36,7 @@ resolve_declared_destination_branch, ) from infrahub_sync.configuration.validation import collect_findings, validate_package_credentials +from infrahub_sync.runtime_schema import compute_consumed_schema_fingerprint, normalize_destination_schema from tests.configuration.validation_packages import package, package_data if TYPE_CHECKING: @@ -45,17 +45,35 @@ from infrahub_sync.configuration import ConfigurationPackage -# A real destination schema snapshot shape: kind -> attributes (name -> kind) and -# relationships (name -> peer + cardinality), exactly what the accessor contract returns. +# A real destination schema snapshot shape, exactly what the accessor contract returns: +# each kind's ordered identity paths, its attributes (name -> kind, optional, default, +# unique) and its relationships (name -> peer, cardinality, optional, kind). +def _attribute( + kind: str = "Text", *, optional: bool = True, default: object = None, unique: bool = False +) -> dict[str, Any]: + return {"kind": kind, "optional": optional, "default_value": default, "unique": unique} + + +def _relationship(peer: str, cardinality: str, *, optional: bool = True, kind: str = "Attribute") -> dict[str, Any]: + return {"peer": peer, "cardinality": cardinality, "optional": optional, "kind": kind} + + _SNAPSHOT: dict[str, Any] = { "InfraDevice": { - "attributes": {"name": "Text", "description": "Text"}, + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": _attribute(optional=False, unique=True), "description": _attribute()}, "relationships": { - "site": {"peer": "LocationSite", "cardinality": "one"}, - "tags": {"peer": "BuiltinTag", "cardinality": "many"}, + "site": _relationship("LocationSite", "one"), + "tags": _relationship("BuiltinTag", "many", kind="Generic"), }, }, - "LocationSite": {"attributes": {"name": "Text"}, "relationships": {}}, + "LocationSite": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": _attribute(optional=False, unique=True)}, + "relationships": {}, + }, } @@ -261,7 +279,9 @@ def test_a_conforming_mapping_yields_no_findings_and_a_fingerprint(monkeypatch: result = collect_destination_schema_findings(parsed) assert result.findings == () - assert result.schema_fingerprint == compute_schema_subhash(parsed.configuration, _SNAPSHOT) + assert result.schema_fingerprint == compute_consumed_schema_fingerprint( + configuration=parsed.configuration, snapshot=normalize_destination_schema(_SNAPSHOT) + ) # --- AR2: capability gating ----------------------------------------------------------- diff --git a/tests/product_store/test_validate_destination_schema.py b/tests/product_store/test_validate_destination_schema.py index 86cfec8b..528463b4 100644 --- a/tests/product_store/test_validate_destination_schema.py +++ b/tests/product_store/test_validate_destination_schema.py @@ -18,13 +18,13 @@ import pytest -from infrahub_sync.cache import compute_schema_subhash from infrahub_sync.configuration import schema_validation from infrahub_sync.configuration import validation as validation_module from infrahub_sync.configuration.capabilities import BUILTIN_ADAPTER_CAPABILITIES from infrahub_sync.configuration.schema_validation import DestinationSchemaOptions from infrahub_sync.configuration.validation import collect_findings from infrahub_sync.product_store import configs as configs_service +from infrahub_sync.runtime_schema import compute_consumed_schema_fingerprint, normalize_destination_schema from tests.configuration.validation_packages import package, package_data if TYPE_CHECKING: @@ -34,8 +34,12 @@ _SNAPSHOT: dict[str, Any] = { "InfraDevice": { - "attributes": {"name": "Text"}, - "relationships": {"site": {"peer": "LocationSite", "cardinality": "one"}}, + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}}, + "relationships": { + "site": {"peer": "LocationSite", "cardinality": "one", "optional": True, "kind": "Attribute"} + }, }, } @@ -169,8 +173,8 @@ def test_the_opt_in_records_the_snapshot_fingerprint(tmp_path: Path, monkeypatch report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) - assert report.destination_schema_fingerprint == compute_schema_subhash( - package(package_data()).configuration, _SNAPSHOT + assert report.destination_schema_fingerprint == compute_consumed_schema_fingerprint( + configuration=package(package_data()).configuration, snapshot=normalize_destination_schema(_SNAPSHOT) ) diff --git a/tests/runtime_schema/test_installed_resolution.py b/tests/runtime_schema/test_installed_resolution.py index 9eb773cc..d80d2007 100644 --- a/tests/runtime_schema/test_installed_resolution.py +++ b/tests/runtime_schema/test_installed_resolution.py @@ -7,7 +7,6 @@ import pytest from infrahub_sync import SyncAdapter, SyncInstance -from infrahub_sync.adapters.infrahub import InfrahubAdapter, InfrahubModel from infrahub_sync.plugin_loader import ( PluginLoadError, resolve_installed_adapter_class, @@ -16,6 +15,16 @@ from infrahub_sync.utils import import_adapter +def _qualified(cls: type) -> str: + """Name a resolved class without comparing identity. + + Other suites re-import the bundled adapter modules dynamically, so two live class + objects for one adapter can coexist in a session; the qualified name is what this + test is about anyway. + """ + return f"{cls.__module__}.{cls.__name__}" + + def _instance(directory: Path) -> SyncInstance: return SyncInstance( name="installed-resolution", @@ -39,7 +48,9 @@ def test_installed_resolution_ignores_a_generated_wrapper(tmp_path: Path) -> Non _write_generated_wrapper(tmp_path) instance = _instance(tmp_path) - assert resolve_installed_adapter_class(instance, instance.destination) is InfrahubAdapter + resolved = resolve_installed_adapter_class(instance, instance.destination) + + assert _qualified(resolved) == "infrahub_sync.adapters.infrahub.InfrahubAdapter" def test_the_generated_wrapper_still_takes_precedence_for_the_legacy_path(tmp_path: Path) -> None: @@ -48,14 +59,16 @@ def test_the_generated_wrapper_still_takes_precedence_for_the_legacy_path(tmp_pa resolved = import_adapter(sync_instance=instance, adapter=instance.destination) - assert resolved is not InfrahubAdapter + assert _qualified(resolved) != "infrahub_sync.adapters.infrahub.InfrahubAdapter" assert resolved.generated is True def test_the_installed_model_base_matches_the_generated_wrapper_spec(tmp_path: Path) -> None: instance = _instance(tmp_path) - assert resolve_installed_model_base(instance, instance.destination) is InfrahubModel + assert _qualified(resolve_installed_model_base(instance, instance.destination)) == ( + "infrahub_sync.adapters.infrahub.InfrahubModel" + ) def test_an_explicit_adapter_spec_resolves_its_module_for_the_model_base(tmp_path: Path) -> None: @@ -66,7 +79,9 @@ def test_an_explicit_adapter_spec_resolves_its_module_for_the_model_base(tmp_pat directory=str(tmp_path), ) - assert resolve_installed_model_base(instance, instance.source) is InfrahubModel + assert _qualified(resolve_installed_model_base(instance, instance.source)) == ( + "infrahub_sync.adapters.infrahub.InfrahubModel" + ) def test_an_unresolvable_installed_model_base_refuses(tmp_path: Path) -> None: diff --git a/tests/runtime_schema/test_model_builder.py b/tests/runtime_schema/test_model_builder.py index 66c27618..d9ca20ba 100644 --- a/tests/runtime_schema/test_model_builder.py +++ b/tests/runtime_schema/test_model_builder.py @@ -85,7 +85,7 @@ def _generated_models( return { cast("type[DiffSyncModel]", obj)._modelname: cast("type[DiffSyncModel]", obj) for name, obj in vars(module).items() - if isinstance(obj, type) and issubclass(obj, InfrahubModel) and not name.startswith("_") + if isinstance(obj, type) and issubclass(obj, DiffSyncModel) and not name.startswith("_") } diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py new file mode 100644 index 00000000..6a5a9b95 --- /dev/null +++ b/tests/runtime_schema/test_worker_path.py @@ -0,0 +1,384 @@ +"""AR1/AR3/AR4/AR5/AR9: registered composition builds and binds runtime models.""" + +from __future__ import annotations + +import copy +import sys +import types +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from infrahub_sync.configuration import ConfigurationPackage, parse_configuration_package +from infrahub_sync.configuration.capabilities import DestinationSchemaReadError +from infrahub_sync.plan.config_version import resolve_config_version +from infrahub_sync.product_store.models import ConfigurationVersion, LookupResult, ProductRun +from infrahub_sync.runtime_schema import ( + DestinationSchemaUnavailableError, + MissingMappedKindError, + RuntimeModelPlan, + UnsupportedDestinationProfileError, + build_runtime_model_plan, +) +from infrahub_sync.runtime_schema import worker as worker_module +from tests.configuration.validation_packages import package_data + +if TYPE_CHECKING: + from collections.abc import Iterator + + from infrahub_sync.product_store import ProductProjection + +pytest.importorskip("prefect") +pytest.importorskip("opsmill_prefect_extras") + +from infrahub_sync.managed import flow as managed_flow + +_SNAPSHOT: dict[str, Any] = { + "BuiltinTag": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": { + "name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "description": {"kind": "Text", "optional": True, "default_value": None, "unique": False}, + }, + "relationships": {}, + }, + "LocationSite": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}}, + "relationships": {"tags": {"peer": "BuiltinTag", "cardinality": "many", "optional": True, "kind": "Generic"}}, + }, +} + +_MAPPING = [ + { + "name": "BuiltinTag", + "mapping": "extras.tags", + "fields": [{"name": "name", "mapping": "name"}, {"name": "description", "mapping": "description"}], + }, + { + "name": "LocationSite", + "mapping": "dcim.sites", + "fields": [{"name": "name", "mapping": "name"}, {"name": "tags", "mapping": "tags", "reference": "BuiltinTag"}], + }, +] + + +def _package_content(**overrides: object) -> dict[str, Any]: + content = package_data() + content["configuration"]["schema_mapping"] = copy.deepcopy(_MAPPING) + content["configuration"].update(overrides) + return content + + +def _package(**overrides: object) -> ConfigurationPackage: + return parse_configuration_package(_package_content(**overrides)) + + +class _SnapshotSpy: + """Records every destination schema read and returns a fixed snapshot.""" + + def __init__(self, snapshot: dict[str, Any] | None = None) -> None: + self.snapshot = _SNAPSHOT if snapshot is None else snapshot + self.branches: list[str] = [] + + def __call__(self, package: ConfigurationPackage, branch: str) -> dict[str, Any]: + del package + self.branches.append(branch) + return self.snapshot + + +@pytest.fixture(name="spy") +def _spy(monkeypatch: pytest.MonkeyPatch) -> _SnapshotSpy: + spy = _SnapshotSpy() + monkeypatch.setattr(worker_module, "read_destination_schema_snapshot", spy) + return spy + + +@pytest.fixture(name="credentials", autouse=True) +def _credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NETBOX_TOKEN", "netbox-worker-canary") + monkeypatch.setenv("INFRAHUB_API_TOKEN", "infrahub-worker-canary") + + +@pytest.fixture(name="netbox_driver", autouse=True) +def _netbox_driver(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Make the NetBox adapter importable without its optional driver installed.""" + driver = cast("Any", types.ModuleType("pynetbox")) + driver.api = lambda *_args, **_kwargs: types.SimpleNamespace() + monkeypatch.setitem(sys.modules, "pynetbox", driver) + yield + sys.modules.pop("infrahub_sync.adapters.netbox", None) + + +def _plan(package: ConfigurationPackage, tmp_path: Path, *, run_branch: str | None = None) -> RuntimeModelPlan: + from infrahub_sync.configuration.runtime import resolve_runtime_instance + + instance = resolve_runtime_instance(package, directory=str(tmp_path)) + return build_runtime_model_plan(package=package, instance=instance, run_branch=run_branch) + + +# --- AR1: the registered worker has a real runtime-model consumer ----------------------- + + +def test_the_plan_carries_fresh_model_classes_for_both_sides(spy: _SnapshotSpy, tmp_path: Path) -> None: + plan = _plan(_package(), tmp_path) + + assert set(plan.source_models) == {"BuiltinTag", "LocationSite"} + assert set(plan.destination_models) == {"BuiltinTag", "LocationSite"} + assert plan.source_models["BuiltinTag"] is not plan.destination_models["BuiltinTag"] + assert plan.destination_models["LocationSite"]._attributes == ("tags",) + assert spy.branches == ["main"] + + +def test_registered_composition_attaches_the_plan_to_the_runtime_instance(spy: _SnapshotSpy, tmp_path: Path) -> None: + package = _package() + binding = ("cfg-runtime-models", 1, package.checksum()) + projection = _StubProjection(package, binding) + + _, instance, name = managed_flow._worker_execution_context( + "run-runtime-models", + binding, + config_directory=str(tmp_path), + projection=cast("ProductProjection", projection), + run_branch=None, + ) + + assert name == package.configuration.name + assert instance._runtime_models is not None + assert set(instance._runtime_models.destination_models) == {"BuiltinTag", "LocationSite"} + assert spy.branches == ["main"] + + +def test_a_legacy_unregistered_run_builds_no_runtime_models(spy: _SnapshotSpy, tmp_path: Path) -> None: + package = _package() + (tmp_path / "from-netbox").mkdir() + (tmp_path / "from-netbox" / "config.yml").write_text( + "name: from-netbox\nsource:\n name: netbox\ndestination:\n name: infrahub\n", encoding="utf-8" + ) + from infrahub_sync.execution import resolve_sync_instance + + reference = resolve_config_version(resolve_sync_instance("from-netbox", directory=str(tmp_path))) + projection = _StubProjection(package, None, sync_name="from-netbox", configuration_reference=reference) + + _, instance, _ = managed_flow._worker_execution_context( + "legacy-run", + None, + config_directory=str(tmp_path), + projection=cast("ProductProjection", projection), + run_branch=None, + ) + + assert instance._runtime_models is None + assert spy.branches == [] + + +# --- AR5: one snapshot decides one plan ------------------------------------------------- + + +def test_one_read_feeds_both_sides_and_the_fingerprint(spy: _SnapshotSpy, tmp_path: Path) -> None: + plan = _plan(_package(), tmp_path) + + assert len(spy.branches) == 1 + assert len(plan.schema_fingerprint) == 64 + + +@pytest.mark.parametrize( + ("declared", "run_branch", "expected"), + [("staging", "review", "staging"), (None, "review", "review"), (None, None, "main")], +) +def test_discovery_and_the_destination_binding_use_the_same_branch( + spy: _SnapshotSpy, tmp_path: Path, declared: str | None, run_branch: str | None, expected: str +) -> None: + content = _package_content() + if declared is not None: + content["configuration"]["destination"]["settings"]["branch"] = declared + plan = _plan(parse_configuration_package(content), tmp_path, run_branch=run_branch) + + assert spy.branches == [expected] + assert plan.branch == expected + + +# --- AR4: schema acquisition is declared, bounded, and secret-safe ----------------------- + + +def test_a_non_infrahub_destination_refuses_before_any_schema_read(spy: _SnapshotSpy, tmp_path: Path) -> None: + content = _package_content() + content["configuration"]["destination"] = { + "name": "peeringmanager", + "settings": {"url": "https://peering.example.net", "token": {"$credential": "infrahub-token"}}, + } + + with pytest.raises(UnsupportedDestinationProfileError): + _plan(parse_configuration_package(content), tmp_path) + + assert spy.branches == [] + + +def test_a_non_bundled_installed_source_with_an_infrahub_destination_may_execute( + spy: _SnapshotSpy, tmp_path: Path +) -> None: + content = _package_content() + content["configuration"]["source"] = { + "name": "infrahub", + "settings": {"url": "http://source:8000", "token": {"$credential": "infrahub-token"}}, + } + + plan = _plan(parse_configuration_package(content), tmp_path) + + assert set(plan.source_models) == {"BuiltinTag", "LocationSite"} + assert spy.branches == ["main"] + + +def test_a_failed_schema_read_becomes_a_typed_failure_carrying_only_its_reason( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + def _refuse(package: ConfigurationPackage, branch: str) -> dict[str, Any]: + del package, branch + msg = "third-party text with infrahub-worker-canary inside" + raise DestinationSchemaReadError(msg, reason="timeout") + + monkeypatch.setattr(worker_module, "read_destination_schema_snapshot", _refuse) + + with pytest.raises(DestinationSchemaUnavailableError) as caught: + _plan(_package(), tmp_path) + + assert caught.value.reason == "timeout" + assert "third-party text" not in str(caught.value) + assert "canary" not in str(caught.value) + + +def test_a_mapped_kind_the_schema_does_not_declare_refuses(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr( + worker_module, + "read_destination_schema_snapshot", + _SnapshotSpy({"BuiltinTag": _SNAPSHOT["BuiltinTag"]}), + ) + + with pytest.raises(MissingMappedKindError) as caught: + _plan(_package(), tmp_path) + + assert "LocationSite" in str(caught.value) + + +# --- AR3: isolation is structural ------------------------------------------------------- + + +def test_two_configurations_sharing_kinds_get_distinct_bound_classes(spy: _SnapshotSpy, tmp_path: Path) -> None: + first = _plan(_package(), tmp_path) + assert spy.branches == ["main"] + second = _plan(_package(name="second-configuration"), tmp_path) + + assert spy.branches == ["main", "main"] + assert first.destination_models["BuiltinTag"] is not second.destination_models["BuiltinTag"] + assert first.schema_fingerprint == second.schema_fingerprint + + +def test_a_rebuild_after_a_schema_change_leaves_the_earlier_classes_untouched( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + spy = _SnapshotSpy() + monkeypatch.setattr(worker_module, "read_destination_schema_snapshot", spy) + before = _plan(_package(), tmp_path) + + grown = copy.deepcopy(_SNAPSHOT) + grown["BuiltinTag"]["attributes"]["colour"] = { + "kind": "Text", + "optional": True, + "default_value": None, + "unique": False, + } + spy.snapshot = grown + after = _plan(_package(), tmp_path) + + assert "colour" not in before.destination_models["BuiltinTag"].model_fields + assert after.destination_models["BuiltinTag"] is not before.destination_models["BuiltinTag"] + assert after.schema_fingerprint == before.schema_fingerprint + + +# --- AR9: generated Python is absent from the registered path --------------------------- + + +def test_the_plan_binds_onto_adapters_without_reading_generated_python( + spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert spy.branches == [] + from infrahub_sync import utils as utils_module + + generated = tmp_path / "infrahub" + generated.mkdir() + (generated / "__init__.py").touch() + (generated / "sync_adapter.py").write_text("raise AssertionError('generated adapter imported')\n") + + def _forbidden(*args: object, **kwargs: object) -> None: + del args, kwargs + msg = "registered execution rendered generated code" + raise AssertionError(msg) + + monkeypatch.setattr(utils_module, "render_adapter", _forbidden) + monkeypatch.setattr(utils_module, "import_adapter", _forbidden) + + plan = _plan(_package(), tmp_path) + adapter = _RecordingAdapter() + worker_module.bind_runtime_models(adapter, plan.destination_models) + + assert adapter.BuiltinTag is plan.destination_models["BuiltinTag"] + assert adapter.LocationSite is plan.destination_models["LocationSite"] + + +class _RecordingAdapter: + """Stands in for a constructed adapter instance the plan binds onto.""" + + BuiltinTag: type + LocationSite: type + + +class _StubProjection: + """The two durable reads registered composition performs, without a store.""" + + def __init__( + self, + package: ConfigurationPackage, + binding: tuple[str, int, str] | None, + *, + sync_name: str | None = None, + configuration_reference: str = "legacy@1", + ) -> None: + self._package = package + self._binding = binding + self._sync_name = sync_name + self._configuration_reference = configuration_reference + + def lookup_run(self, run_id: str) -> LookupResult[ProductRun]: + summary = {"sync_name": self._sync_name} if self._sync_name else {} + return LookupResult( + value=ProductRun( + run_id=run_id, + operation="plan", + configuration_reference=( + self._configuration_reference if self._binding is None else f"{self._binding[0]}@{self._binding[1]}" + ), + config_id=None if self._binding is None else self._binding[0], + registry_version=None if self._binding is None else self._binding[1], + package_checksum=None if self._binding is None else self._binding[2], + actor="owner", + started_at=datetime.now(timezone.utc), + phase="reserved", + summary=summary, + ) + ) + + def lookup_configuration_version(self, config_id: str, registry_version: int) -> LookupResult[ConfigurationVersion]: + assert self._binding is not None + return LookupResult( + value=ConfigurationVersion( + config_id=config_id, + registry_version=registry_version, + package_checksum=self._binding[2], + declared_content=self._package.model_dump(mode="json"), + created_at=datetime.now(timezone.utc), + ) + ) From 3c1997251e52f798eb526cbbc8d7afd0dacd616b Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:41:20 -0400 Subject: [PATCH 07/27] Prove structural isolation and the single read of a composed sync Add the absence scan the isolation property needs: no module in the runtime schema path holds a mutable global, reaches the rendering or import machinery, or shares a writable table, so nothing one run builds can outlive it. Add the registered composed-sync case: plan, verify and apply legs reach execution through one instance built from one destination schema read. Making the managed stage's tail arguments keyword-only is what lets that case call it without a positional boolean. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/managed/flow.py | 11 ++-- infrahub_sync/runtime_schema/domain.py | 4 +- tests/runtime_schema/test_absence_scan.py | 75 +++++++++++++++++++++ tests/runtime_schema/test_model_builder.py | 10 +-- tests/runtime_schema/test_worker_path.py | 76 ++++++++++++++++++++++ 5 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 tests/runtime_schema/test_absence_scan.py diff --git a/infrahub_sync/managed/flow.py b/infrahub_sync/managed/flow.py index 15a38f95..2792818c 100644 --- a/infrahub_sync/managed/flow.py +++ b/infrahub_sync/managed/flow.py @@ -294,6 +294,7 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar package_checksum: str | None, branch: str | None, expected_checksum: str | None, + *, confirm_writes: bool, run_logger: RunLogger, secrets: list[str], @@ -506,11 +507,11 @@ def managed_sync_run( # pylint: disable=too-many-positional-arguments package_checksum, branch, expected_checksum, - confirm_writes, - run_logger, - secrets, - config_directory, - projection, + confirm_writes=confirm_writes, + run_logger=run_logger, + secrets=secrets, + config_directory=config_directory, + projection=projection, ) except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught # Rebuilt after the original exception context exits. diff --git a/infrahub_sync/runtime_schema/domain.py b/infrahub_sync/runtime_schema/domain.py index 105fc3a6..442b20b0 100644 --- a/infrahub_sync/runtime_schema/domain.py +++ b/infrahub_sync/runtime_schema/domain.py @@ -16,7 +16,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any, NoReturn +from typing import Any, NoReturn, cast from .errors import UnsupportedSchemaSemanticsError @@ -89,7 +89,7 @@ def _require_mapping(value: object, *, detail: str) -> Mapping[str, Any]: for key in value: if not isinstance(key, str): _refuse(detail) - return value + return cast("Mapping[str, Any]", value) def _require_bool(value: object, *, detail: str) -> bool: diff --git a/tests/runtime_schema/test_absence_scan.py b/tests/runtime_schema/test_absence_scan.py new file mode 100644 index 00000000..d52d9e8f --- /dev/null +++ b/tests/runtime_schema/test_absence_scan.py @@ -0,0 +1,75 @@ +"""AR3/AR9: the runtime model path holds no global state and reaches no generated code.""" + +from __future__ import annotations + +import ast +from pathlib import Path +from types import MappingProxyType +from typing import Any, cast + +import pytest + +from infrahub_sync import runtime_schema + +PACKAGE = Path(runtime_schema.__file__).parent +MODULES = sorted(PACKAGE.glob("*.py")) + +# Names that would make one run's construction reachable from another, or would put +# generated Python back on the registered path. +FORBIDDEN_NAMES = frozenset( + { + "render_adapter", + "render_template", + "import_adapter", + "modules", + "path", + "setattr", + } +) + + +def _module_source(module: Path) -> ast.Module: + return ast.parse(module.read_text(encoding="utf-8"), filename=str(module)) + + +def _assigned_name(node: ast.Assign | ast.AnnAssign) -> str: + target = node.targets[0] if isinstance(node, ast.Assign) else node.target + return target.id if isinstance(target, ast.Name) else ast.dump(target) + + +@pytest.mark.parametrize("module", MODULES, ids=lambda module: module.name) +def test_no_module_holds_a_mutable_global(module: Path) -> None: + """A module-level mutable container would outlive one run's classes.""" + tree = _module_source(module) + assignments = [node for node in tree.body if isinstance(node, (ast.Assign, ast.AnnAssign))] + mutable = [ + _assigned_name(node) + for node in assignments + if isinstance(node.value, (ast.Dict, ast.List, ast.Set, ast.DictComp, ast.ListComp, ast.SetComp)) + ] + + # ``__all__`` names exports; it holds no run state. + assert [name for name in mutable if name != "__all__"] == [] + + +@pytest.mark.parametrize("module", MODULES, ids=lambda module: module.name) +def test_no_module_reaches_generated_code_or_the_import_system(module: Path) -> None: + """Rendering, importing generated files, and editing sys.path/sys.modules are absent.""" + tree = _module_source(module) + used = { + node.id if isinstance(node, ast.Name) else node.attr + for node in ast.walk(tree) + if isinstance(node, (ast.Name, ast.Attribute)) + } + # ``setattr`` is how one run's classes are bound onto that run's adapter instance. + allowed = {"setattr"} if module.name == "worker.py" else set() + + assert (used & FORBIDDEN_NAMES) <= allowed + + +def test_the_only_shared_table_is_immutable() -> None: + """The attribute-kind domain is shared across runs, so it must not be writable.""" + assert isinstance(runtime_schema.ATTRIBUTE_TYPE_DOMAIN, MappingProxyType) + + with pytest.raises(TypeError): + cast("dict[str, Any]", runtime_schema.ATTRIBUTE_TYPE_DOMAIN)["Bandwidth"] = str diff --git a/tests/runtime_schema/test_model_builder.py b/tests/runtime_schema/test_model_builder.py index d9ca20ba..07108c01 100644 --- a/tests/runtime_schema/test_model_builder.py +++ b/tests/runtime_schema/test_model_builder.py @@ -34,6 +34,7 @@ ATTRIBUTE_TYPE_DOMAIN, UnsupportedSchemaSemanticsError, build_runtime_models, + mapped_attribute_kinds, normalize_destination_schema, ) from infrahub_sync.utils import get_instance, render_adapter @@ -255,14 +256,9 @@ def test_an_attribute_kind_outside_the_closed_table_refuses_before_extraction() def test_every_captured_mapped_attribute_kind_is_inside_the_closed_table(snapshot_name: str, example_name: str) -> None: instance = get_instance(name=example_name, directory=str(REPO_ROOT / "examples")) assert instance is not None - mapped = {(mapping.name, field.name) for mapping in instance.schema_mapping for field in mapping.fields or ()} + snapshot = normalize_destination_schema(capabilities_module._build_schema_snapshot(_load_sdk_schema(snapshot_name))) - captured = { - attribute.kind - for kind, node in _load_sdk_schema(snapshot_name).items() - for attribute in node.attributes - if (kind, attribute.name) in mapped - } + captured = mapped_attribute_kinds(snapshot, instance) assert captured assert captured <= set(ATTRIBUTE_TYPE_DOMAIN) diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index 6a5a9b95..f9c3d171 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -13,7 +13,10 @@ from infrahub_sync.configuration import ConfigurationPackage, parse_configuration_package from infrahub_sync.configuration.capabilities import DestinationSchemaReadError +from infrahub_sync.execution import RunResult from infrahub_sync.plan.config_version import resolve_config_version +from infrahub_sync.plan.models import PlanManifest +from infrahub_sync.plan.review import SavedPlan from infrahub_sync.product_store.models import ConfigurationVersion, LookupResult, ProductRun from infrahub_sync.runtime_schema import ( DestinationSchemaUnavailableError, @@ -28,6 +31,7 @@ if TYPE_CHECKING: from collections.abc import Iterator + from infrahub_sync import SyncInstance from infrahub_sync.product_store import ProductProjection pytest.importorskip("prefect") @@ -176,6 +180,47 @@ def test_a_legacy_unregistered_run_builds_no_runtime_models(spy: _SnapshotSpy, t assert spy.branches == [] +def test_a_registered_stage_reaches_execution_with_its_models_bound( + spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + package = _package() + binding = ("cfg-runtime-models", 1, package.checksum()) + projection = _StubProjection(package, binding) + seen: list[SyncInstance] = [] + + def _record(instance: SyncInstance, **kwargs: object) -> SavedPlan | RunResult: + seen.append(instance) + if kwargs.get("operation") == "apply": + return _run_result(instance.name, tmp_path / "run-composed-sync") + return _saved_plan() + + def _skip(*_args: object, **_kwargs: object) -> None: + return None + + monkeypatch.setattr(managed_flow, "execute_run", _record) + monkeypatch.setattr(managed_flow, "_publish_plan", _skip) + monkeypatch.setattr(managed_flow, "_verify_registered_apply", _skip) + + result, _ = managed_flow._execute_stage( + "run-composed-sync", + "sync", + *binding, + None, + None, + confirm_writes=True, + run_logger=managed_flow.logger, + secrets=[], + config_directory=str(tmp_path), + projection=cast("ProductProjection", projection), + ) + + assert result["operation"] == "sync" + # Plan, verify and apply legs share the one instance the single schema read built. + assert len({id(instance) for instance in seen}) == 1 + assert seen[0]._runtime_models is not None + assert spy.branches == ["main"] + + # --- AR5: one snapshot decides one plan ------------------------------------------------- @@ -329,6 +374,37 @@ def _forbidden(*args: object, **kwargs: object) -> None: assert adapter.LocationSite is plan.destination_models["LocationSite"] +def _saved_plan() -> SavedPlan: + """The smallest real saved plan the managed stage's assertions accept.""" + return SavedPlan( + manifest=PlanManifest( + format_version=1, + run_id="run-composed-sync", + created_at="2026-08-30T00:00:00+00:00", + config_version="sha256:" + "0" * 64, + source_snapshot=[], + operations_count=0, + delete_operations_computed=True, + plan_checksum="a" * 64, + ), + operations=[], + checksum_ok=True, + verification_notes=[], + ) + + +def _run_result(sync_name: str, artifact_path: Path) -> RunResult: + return RunResult( + sync_name=sync_name, + operation="apply", + run_id="run-composed-sync", + status="no-change", + changed=False, + summary={"create": 0, "update": 0, "delete": 0}, + artifact_path=str(artifact_path), + ) + + class _RecordingAdapter: """Stands in for a constructed adapter instance the plan binds onto.""" From 45e4af2bcbb9504b444682e80f737ac6c8e64ef7 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:44:30 -0400 Subject: [PATCH 08/27] Refuse non-string identity paths instead of stringifying them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the snapshot called str() on each human-friendly-ID and uniqueness component and on default mapping keys. A third-party object's own __str__ output would then enter the snapshot, and everything derived from it, rather than being refused — the one thing this boundary exists to prevent. Pass the values through and let the snapshot's shape gate answer, the way it already answers for member names, kinds, peers, and cardinalities. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/configuration/capabilities.py | 30 +++++++++++-------- .../runtime_schema/test_accessor_snapshot.py | 17 +++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/infrahub_sync/configuration/capabilities.py b/infrahub_sync/configuration/capabilities.py index 8ba08608..d6b5b944 100644 --- a/infrahub_sync/configuration/capabilities.py +++ b/infrahub_sync/configuration/capabilities.py @@ -308,9 +308,9 @@ def _build_schema_snapshot(schema: object) -> dict[str, Any]: if not isinstance(kind, str): raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") snapshot[kind] = { - "human_friendly_id": [str(path) for path in getattr(node, "human_friendly_id", None) or ()], + "human_friendly_id": list(getattr(node, "human_friendly_id", None) or ()), "uniqueness_constraints": [ - [str(path) for path in constraint] for constraint in getattr(node, "uniqueness_constraints", None) or () + list(constraint) for constraint in getattr(node, "uniqueness_constraints", None) or () ], "attributes": { attribute.name: { @@ -348,8 +348,8 @@ def _json_native_default(value: object) -> Any: return _json_native_default(value.value) if isinstance(value, (list, tuple)): return [_json_native_default(item) for item in value] - if isinstance(value, Mapping): - return {str(key): _json_native_default(item) for key, item in value.items()} + if isinstance(value, Mapping) and all(isinstance(key, str) for key in value): + return {key: _json_native_default(item) for key, item in value.items()} raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") @@ -365,14 +365,20 @@ def _require_usable_snapshot(snapshot: Mapping[str, Any]) -> None: for entry in snapshot.values(): attributes: dict[str, Any] = entry["attributes"] relationships: dict[str, Any] = entry["relationships"] - usable = all( - isinstance(name, str) and isinstance(attribute["kind"], str) for name, attribute in attributes.items() - ) and all( - isinstance(name, str) - and isinstance(relationship["peer"], str) - and isinstance(relationship["cardinality"], str) - and isinstance(relationship["kind"], str) - for name, relationship in relationships.items() + paths: list[Any] = [ + *entry["human_friendly_id"], + *(component for constraint in entry["uniqueness_constraints"] for component in constraint), + ] + usable = ( + all(isinstance(name, str) and isinstance(attribute["kind"], str) for name, attribute in attributes.items()) + and all( + isinstance(name, str) + and isinstance(relationship["peer"], str) + and isinstance(relationship["cardinality"], str) + and isinstance(relationship["kind"], str) + for name, relationship in relationships.items() + ) + and all(isinstance(component, str) for component in paths) ) if not usable: msg = "destination returned an unusable schema member shape" diff --git a/tests/runtime_schema/test_accessor_snapshot.py b/tests/runtime_schema/test_accessor_snapshot.py index ce9e591f..1dac220c 100644 --- a/tests/runtime_schema/test_accessor_snapshot.py +++ b/tests/runtime_schema/test_accessor_snapshot.py @@ -8,6 +8,7 @@ from infrahub_sdk.schema.main import AttributeKind, NodeSchemaAPI from infrahub_sync.configuration import capabilities as capabilities_module +from infrahub_sync.configuration.capabilities import DestinationSchemaReadError from infrahub_sync.runtime_schema import normalize_destination_schema _NODE: dict[str, Any] = { @@ -62,3 +63,19 @@ def test_the_delivered_snapshot_normalizes_into_the_closed_domain(snapshot: dict normalized = normalize_destination_schema(snapshot) assert normalized.kinds["InfraDevice"].human_friendly_id == ("name__value",) + + +class _NonStringPathNode: + """A node whose identity paths are not the strings the SDK contract promises.""" + + human_friendly_id = ("name__value", 7) + uniqueness_constraints = () + attributes: tuple[object, ...] = () + relationships: tuple[object, ...] = () + + +def test_a_non_string_identity_path_is_refused_at_the_adapter_boundary() -> None: + # Refused rather than coerced: `str()` on a third-party object would let its own text + # into the snapshot, and everything derived from one. + with pytest.raises(DestinationSchemaReadError): + capabilities_module._build_schema_snapshot({"InfraDevice": _NonStringPathNode()}) From abea2ff9bb0b6ec9fa6459a3d256b34973c4b9c2 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 22:48:12 -0400 Subject: [PATCH 09/27] Keep the pylint baseline at engine assembly Selecting both adapter classes inline pushed get_potenda_from_instance past the branch ceiling, and placing the plan's type import in the second TYPE_CHECKING block split the package's import grouping. Extract the selection into one helper that answers "which adapter classes does this run use" and group the import with its neighbours. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/__init__.py | 3 +-- infrahub_sync/utils.py | 46 ++++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/infrahub_sync/__init__.py b/infrahub_sync/__init__.py index 4415115c..fbf472b7 100644 --- a/infrahub_sync/__init__.py +++ b/infrahub_sync/__init__.py @@ -11,6 +11,7 @@ from collections.abc import Iterable from infrahub_sync.cache.cursors import CursorState + from infrahub_sync.runtime_schema import RuntimeModelPlan import pydantic @@ -18,8 +19,6 @@ from collections.abc import Callable from diffsync.store import BaseStore - - from infrahub_sync.runtime_schema import RuntimeModelPlan from diffsync.enum import DiffSyncFlags from jinja2 import StrictUndefined from jinja2.nativetypes import NativeEnvironment diff --git a/infrahub_sync/utils.py b/infrahub_sync/utils.py index 9cbad813..a309d374 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -32,6 +32,7 @@ from infrahub_sync.plan.models import ApplyRecord from infrahub_sync.plan.reader import RawPlanArtifact + from infrahub_sync.runtime_schema import RuntimeModelPlan def find_missing_schema_model( @@ -163,6 +164,33 @@ def get_instance( return None +def _adapter_classes( + sync_instance: SyncInstance, runtime_models: RuntimeModelPlan | None +) -> tuple[type[Any], type[Any]]: + """Resolve both sides' adapter classes for one run. + + A registered run resolved them from installed code when it built its model plan, so + nothing here reads the configuration directory. Every other run keeps the legacy + generated-wrapper-first resolution. + + Raises: + ImportError: either side's adapter class could not be loaded. + """ + if runtime_models is not None: + return runtime_models.source_adapter_class, runtime_models.destination_adapter_class + source = import_adapter(sync_instance=sync_instance, adapter=sync_instance.source) + destination = import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) + if source and destination: + return source, destination + missing = [] + if not source: + missing.append(f"source adapter '{sync_instance.source.name}'") + if not destination: + missing.append(f"destination adapter '{sync_instance.destination.name}'") + msg = f"Could not load the following adapter(s): {', '.join(missing)}" + raise ImportError(msg) + + def get_potenda_from_instance( sync_instance: SyncInstance, branch: str | None = None, @@ -178,23 +206,7 @@ def get_potenda_from_instance( ``generate_run_id()`` so each invocation gets its own cache directory. """ runtime_models = sync_instance._runtime_models - if runtime_models is None: - source = import_adapter(sync_instance=sync_instance, adapter=sync_instance.source) - destination = import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) - else: - # A registered run resolved both classes from installed code already, so nothing - # here reads the configuration directory. - source = runtime_models.source_adapter_class - destination = runtime_models.destination_adapter_class - - if not source or not destination: - missing = [] - if not source: - missing.append(f"source adapter '{sync_instance.source.name}'") - if not destination: - missing.append(f"destination adapter '{sync_instance.destination.name}'") - msg = f"Could not load the following adapter(s): {', '.join(missing)}" - raise ImportError(msg) + source, destination = _adapter_classes(sync_instance, runtime_models) source_store = LocalStore() destination_store = LocalStore() From faad5beffbe3fdcff3b4d0fdb3b8733fef3514b4 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:13:16 -0400 Subject: [PATCH 10/27] Make registered resolution structurally unable to load filesystem plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installed-only resolvers built the general loader, which reads INFRAHUB_SYNC_ADAPTER_PATHS, searches configured adapter paths, and searches the working directory. A registered run could therefore load an arbitrary adapter module from disk — the shipped custom-adapter example resolves that way. Give the loader an installed_only constructor that disables filesystem resolution outright, and resolve registered adapters and model bases through it. Dotted imports, entry points, and bundled modules remain; nothing on disk does. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/plugin_loader.py | 61 ++++++--- infrahub_sync/runtime_schema/worker.py | 8 +- infrahub_sync/utils.py | 2 +- .../test_installed_resolution.py | 8 +- .../test_registered_resolution.py | 120 ++++++++++++++++++ 5 files changed, 169 insertions(+), 30 deletions(-) create mode 100644 tests/runtime_schema/test_registered_resolution.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index 936188df..e1ba7b79 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from infrahub_sync import SyncAdapter, SyncConfig + from infrahub_sync import SyncAdapter class PluginLoadError(Exception): @@ -56,16 +56,31 @@ class PluginLoader: - Python entry points: group infrahub_sync.adapters """ - def __init__(self, adapter_paths: Iterable[str] | None = None) -> None: + def __init__(self, adapter_paths: Iterable[str] | None = None, *, allow_filesystem: bool = True) -> None: """ Initialize a new PluginLoader. Args: adapter_paths: Optional list of paths to search for adapters. + allow_filesystem: Whether filesystem resolution may run at all. False makes + the loader structurally incapable of loading a module from a path, an + adapter-path directory, or the working directory. """ self.adapter_paths = list(adapter_paths) if adapter_paths else [] + self.allow_filesystem = allow_filesystem self._cache: dict[str, tuple[type[Any], Plugintype]] = {} + @classmethod + def installed_only(cls) -> PluginLoader: + """Return a loader that resolves installed code and nothing else. + + Dotted imports, entry points, and bundled adapter modules only: no configured + adapter paths, no ``INFRAHUB_SYNC_ADAPTER_PATHS``, and no working directory. This + is the loader registered execution resolves through, so an adapter that is not + installed in the worker's environment cannot enter a registered run. + """ + return cls(adapter_paths=None, allow_filesystem=False) + @classmethod def from_env_and_args(cls, adapter_paths: Iterable[str] | None = None) -> PluginLoader: """ @@ -167,12 +182,13 @@ def resolve(self, spec: str, default_class_candidates: tuple[str, ...] = ("Adapt return cls # 2. Filesystem path (search adapter_paths and CWD) - cls = self._resolve_from_filesystem( - path=spec_path, class_name=class_name, default_class_candidates=default_class_candidates - ) - if cls: - self._cache[spec] = (cls, Plugintype.FILESYSTEM) - return cls + if self.allow_filesystem: + cls = self._resolve_from_filesystem( + path=spec_path, class_name=class_name, default_class_candidates=default_class_candidates + ) + if cls: + self._cache[spec] = (cls, Plugintype.FILESYSTEM) + return cls # 3. Try as an entry point if cls is None: @@ -189,10 +205,12 @@ def resolve(self, spec: str, default_class_candidates: tuple[str, ...] = ("Adapt return cls # If we get here, we couldn't resolve the class - msg = ( - f"Could not resolve adapter class for spec '{spec}'. " - f"Tried dotted path, filesystem, entry point, and built-in resolution." + tried = ( + "dotted path, filesystem, entry point, and built-in" + if self.allow_filesystem + else ("dotted path, entry point, and built-in") ) + msg = f"Could not resolve adapter class for spec '{spec}'. Tried {tried} resolution." raise PluginLoadError(msg) def _resolve_from_dotted_path( @@ -483,30 +501,31 @@ def _find_class_by_name_candidates( return None -def resolve_installed_adapter_class(configuration: SyncConfig, adapter: SyncAdapter) -> type[Any]: +def resolve_installed_adapter_class(adapter: SyncAdapter) -> type[Any]: """Resolve one side's adapter class from installed code only. - The registered worker's resolution seam: dotted path, entry point, or built-in - module, through the same loader the generated wrapper would have used. It never - reads the configuration directory, so generated Python cannot reach a registered run. + The registered worker's resolution seam. It resolves through + :meth:`PluginLoader.installed_only`, so neither generated Python in the configuration + directory nor any filesystem plugin — a configured adapter path, the + ``INFRAHUB_SYNC_ADAPTER_PATHS`` environment, or the working directory — can enter a + registered run. Raises: PluginLoadError: no installed class answers the declared adapter. """ - loader = PluginLoader.from_env_and_args(adapter_paths=configuration.adapters_path or []) - return loader.resolve(adapter.adapter or adapter.name) + return PluginLoader.installed_only().resolve(adapter.adapter or adapter.name) -def resolve_installed_model_base(configuration: SyncConfig, adapter: SyncAdapter) -> type[Any]: +def resolve_installed_model_base(adapter: SyncAdapter) -> type[Any]: """Resolve one side's DiffSync model base from installed code only. Uses the spec the generated models file uses — the module half of an explicit adapter spec, otherwise the adapter name — so a runtime-built class derives from the - same base a generated one would have. + same base a generated one would have, resolved through the same installed-only loader + as the adapter class. Raises: PluginLoadError: no installed class answers the declared adapter. """ - loader = PluginLoader.from_env_and_args(adapter_paths=configuration.adapters_path or []) spec = adapter.adapter.split(":")[0] if adapter.adapter else adapter.name - return loader.resolve(spec, default_class_candidates=("Model",)) + return PluginLoader.installed_only().resolve(spec, default_class_candidates=("Model",)) diff --git a/infrahub_sync/runtime_schema/worker.py b/infrahub_sync/runtime_schema/worker.py index a32f8b19..61068bf4 100644 --- a/infrahub_sync/runtime_schema/worker.py +++ b/infrahub_sync/runtime_schema/worker.py @@ -131,17 +131,17 @@ def build_runtime_model_plan( return RuntimeModelPlan( branch=branch, schema_fingerprint=compute_consumed_schema_fingerprint(configuration=instance, snapshot=snapshot), - source_adapter_class=resolve_installed_adapter_class(instance, instance.source), + source_adapter_class=resolve_installed_adapter_class(instance.source), source_models=build_runtime_models( snapshot=snapshot, configuration=instance, - model_base=resolve_installed_model_base(instance, instance.source), + model_base=resolve_installed_model_base(instance.source), ), - destination_adapter_class=resolve_installed_adapter_class(instance, instance.destination), + destination_adapter_class=resolve_installed_adapter_class(instance.destination), destination_models=build_runtime_models( snapshot=snapshot, configuration=instance, - model_base=resolve_installed_model_base(instance, instance.destination), + model_base=resolve_installed_model_base(instance.destination), ), ) diff --git a/infrahub_sync/utils.py b/infrahub_sync/utils.py index a309d374..ce950d46 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -110,7 +110,7 @@ def import_adapter(sync_instance: SyncInstance, adapter: SyncAdapter): # Fall back to installed resolution. # The "sync" classes could be declared into a separate module try: - return resolve_installed_adapter_class(sync_instance, adapter) + return resolve_installed_adapter_class(adapter) except PluginLoadError as exc: if adapter.adapter: msg = f"Failed to load adapter '{adapter.adapter}': {exc}" diff --git a/tests/runtime_schema/test_installed_resolution.py b/tests/runtime_schema/test_installed_resolution.py index d80d2007..f877b089 100644 --- a/tests/runtime_schema/test_installed_resolution.py +++ b/tests/runtime_schema/test_installed_resolution.py @@ -48,7 +48,7 @@ def test_installed_resolution_ignores_a_generated_wrapper(tmp_path: Path) -> Non _write_generated_wrapper(tmp_path) instance = _instance(tmp_path) - resolved = resolve_installed_adapter_class(instance, instance.destination) + resolved = resolve_installed_adapter_class(instance.destination) assert _qualified(resolved) == "infrahub_sync.adapters.infrahub.InfrahubAdapter" @@ -66,7 +66,7 @@ def test_the_generated_wrapper_still_takes_precedence_for_the_legacy_path(tmp_pa def test_the_installed_model_base_matches_the_generated_wrapper_spec(tmp_path: Path) -> None: instance = _instance(tmp_path) - assert _qualified(resolve_installed_model_base(instance, instance.destination)) == ( + assert _qualified(resolve_installed_model_base(instance.destination)) == ( "infrahub_sync.adapters.infrahub.InfrahubModel" ) @@ -79,7 +79,7 @@ def test_an_explicit_adapter_spec_resolves_its_module_for_the_model_base(tmp_pat directory=str(tmp_path), ) - assert _qualified(resolve_installed_model_base(instance, instance.source)) == ( + assert _qualified(resolve_installed_model_base(instance.source)) == ( "infrahub_sync.adapters.infrahub.InfrahubModel" ) @@ -93,4 +93,4 @@ def test_an_unresolvable_installed_model_base_refuses(tmp_path: Path) -> None: ) with pytest.raises(PluginLoadError): - resolve_installed_model_base(instance, instance.source) + resolve_installed_model_base(instance.source) diff --git a/tests/runtime_schema/test_registered_resolution.py b/tests/runtime_schema/test_registered_resolution.py new file mode 100644 index 00000000..1c838197 --- /dev/null +++ b/tests/runtime_schema/test_registered_resolution.py @@ -0,0 +1,120 @@ +"""F2: registered resolution cannot reach filesystem plugins, by construction.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from infrahub_sync import SyncAdapter, SyncInstance +from infrahub_sync.plugin_loader import ( + PluginLoadError, + resolve_installed_adapter_class, + resolve_installed_model_base, +) + +_ADAPTER_SOURCE = """ +from diffsync import Adapter, DiffSyncModel + + +class SideloadedModel(DiffSyncModel): + _modelname = "SideloadedModel" + _identifiers = ("name",) + name: str + + +class SideloadedAdapter(Adapter): + type = "Sideloaded" +""" + + +@pytest.fixture(name="sideloaded") +def _sideloaded(tmp_path: Path) -> Path: + """A working adapter module on disk, reachable only through filesystem resolution.""" + package = tmp_path / "sideloaded" + package.mkdir() + (package / "__init__.py").write_text(_ADAPTER_SOURCE, encoding="utf-8") + return tmp_path + + +def _instance(name: str, *, adapters_path: list[str] | None = None) -> SyncInstance: + return SyncInstance( + name="registered-resolution", + source=SyncAdapter(name=name), + destination=SyncAdapter(name="infrahub"), + adapters_path=adapters_path, + directory="/nonexistent", + ) + + +def test_a_configured_adapter_path_cannot_reach_a_filesystem_plugin(sideloaded: Path) -> None: + instance = _instance("sideloaded", adapters_path=[str(sideloaded)]) + + with pytest.raises(PluginLoadError): + resolve_installed_adapter_class(instance.source) + + +def test_the_adapter_paths_environment_variable_cannot_reach_a_filesystem_plugin( + sideloaded: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("INFRAHUB_SYNC_ADAPTER_PATHS", str(sideloaded)) + instance = _instance("sideloaded") + + with pytest.raises(PluginLoadError): + resolve_installed_adapter_class(instance.source) + + +def test_the_working_directory_cannot_reach_a_filesystem_plugin( + sideloaded: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(sideloaded) + instance = _instance("sideloaded") + + with pytest.raises(PluginLoadError): + resolve_installed_adapter_class(instance.source) + + +def test_a_filesystem_model_base_is_unreachable_too(sideloaded: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFRAHUB_SYNC_ADAPTER_PATHS", str(sideloaded)) + instance = _instance("sideloaded", adapters_path=[str(sideloaded)]) + + with pytest.raises(PluginLoadError): + resolve_installed_model_base(instance.source) + + +def test_the_shipped_filesystem_example_adapter_is_unreachable() -> None: + # The reviewer's reproduction: the custom-adapter example declares a `./...py:Class` + # spec, which registered admission must not be able to load. + instance = SyncInstance( + name="registered-resolution", + source=SyncAdapter( + name="mockdb", + adapter="./examples/custom_adapter/custom_adapter_src/custom_adapter.py:MockdbAdapter", + ), + destination=SyncAdapter(name="infrahub"), + directory="/nonexistent", + ) + + with pytest.raises(PluginLoadError): + resolve_installed_adapter_class(instance.source) + + +def test_a_bundled_adapter_still_resolves() -> None: + instance = _instance("infrahub") + + resolved = resolve_installed_adapter_class(instance.source) + + assert resolved.__module__ == "infrahub_sync.adapters.infrahub" + + +def test_a_dotted_installed_adapter_still_resolves() -> None: + instance = SyncInstance( + name="registered-resolution", + source=SyncAdapter(name="custom", adapter="infrahub_sync.adapters.infrahub:InfrahubAdapter"), + destination=SyncAdapter(name="infrahub"), + directory="/nonexistent", + ) + + resolved = resolve_installed_adapter_class(instance.source) + + assert resolved.__name__ == "InfrahubAdapter" From 61c2841f15e7095e4aa3a377d88ebd82459fb490 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:19:39 -0400 Subject: [PATCH 11/27] Admit an installed source adapter declaration into registered packages The accepted profile lets a registered package pair an installed dotted-path or entry-point source with an Infrahub destination, but registered parsing refused source.adapter outright, so that profile could not be declared and the test that claimed to cover it actually used the bundled adapter. Accept a source adapter that names a dotted import target or an entry point, and refuse every filesystem form by property rather than by loader behaviour. The declaration is serialized when present, so it enters the package checksum; a package that omits it keeps its exact declared content and checksum. The destination adapter and adapters_path stay refused. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/configuration/models.py | 72 ++++++++- tests/configuration/test_contracts.py | 19 ++- .../installed_source_adapter.py | 23 +++ .../test_registered_source_declaration.py | 142 ++++++++++++++++++ tests/runtime_schema/test_worker_path.py | 49 +++++- 5 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 tests/runtime_schema/installed_source_adapter.py create mode 100644 tests/runtime_schema/test_registered_source_declaration.py diff --git a/infrahub_sync/configuration/models.py b/infrahub_sync/configuration/models.py index 4110c68a..31245ebc 100644 --- a/infrahub_sync/configuration/models.py +++ b/infrahub_sync/configuration/models.py @@ -19,6 +19,7 @@ ValidationError, field_serializer, field_validator, + model_serializer, model_validator, ) from pydantic_core import PydanticCustomError @@ -61,6 +62,13 @@ _INVALID_UNICODE_SURROGATE_ERROR = "invalid_unicode_surrogate" _INVALID_JSON_VALUE_ERROR = "invalid_json_value" _INVALID_DIFFSYNC_FLAG_NAME_ERROR = "invalid_diffsync_flag_name" +_UNSUPPORTED_ADAPTER_SPEC_ERROR = "unsupported_adapter_spec" +# An installed source adapter is one Python import target: dot-separated identifiers, +# optionally naming a class after a colon. Every filesystem form a plugin loader would +# otherwise accept fails this by construction — a path separator, a leading "." or "~", +# an empty segment, a space — and a ".py" module tail is refused alongside it, because a +# loader reads that as a file rather than a module. +_INSTALLED_ADAPTER_SPEC = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*(:[A-Za-z_][A-Za-z0-9_]*)?$") _SAFE_PYDANTIC_FAILURE_REASONS = { "missing": "required field is missing", "literal_error": "unsupported value", @@ -152,6 +160,7 @@ def _require_known_fields(value: Any, model: type[BaseModel], *, location: str) _raise_unsupported_declared_fields(location=location, fields=unknown) +_SOURCE_LOCATION = "configuration.source" _STRICT_CONFIGURATION_CHILDREN: dict[type[BaseModel], dict[str, tuple[type[BaseModel], bool]]] = { SyncConfig: { "store": (SyncStore, False), @@ -168,6 +177,29 @@ def _require_known_fields(value: Any, model: type[BaseModel], *, location: str) } +def _raise_unsupported_adapter_spec(*, location: str) -> None: + """Raise one structured error for a source adapter outside installed resolution.""" + pointer = "/" + location.replace(".", "/") + raise PydanticCustomError( + _UNSUPPORTED_ADAPTER_SPEC_ERROR, + "{location} contains an unsupported adapter specification", # noqa: RUF027 + {"location": location, "pointer": pointer}, + ) + + +def _require_installed_adapter_spec(value: Any, *, location: str) -> None: + """Refuse a declared source adapter a registered worker could not safely resolve. + + Registered execution resolves through installed-only loading, so a declaration is + admitted when it names a dotted import target or an entry point and refused when it + names anything on a filesystem. + """ + if type(value) is not str or _INSTALLED_ADAPTER_SPEC.fullmatch(value) is None: # pylint: disable=unidiomatic-typecheck + _raise_unsupported_adapter_spec(location=location) + if value.partition(":")[0].endswith(".py"): + _raise_unsupported_adapter_spec(location=location) + + def _require_strict_model(value: Any, model: type[BaseModel], *, location: str) -> None: """Apply extra-forbid semantics to one registered legacy model node.""" if not isinstance(value, Mapping): @@ -176,7 +208,12 @@ def _require_strict_model(value: Any, model: type[BaseModel], *, location: str) if model is SyncConfig and value.get("adapters_path") is not None: _raise_unsupported_declared_fields(location=location, fields=("adapters_path",)) if model is SyncAdapter and value.get("adapter") is not None: - _raise_unsupported_declared_fields(location=location, fields=("adapter",)) + # The source may name an installed adapter; the destination may not, because the + # destination owns the schema-discovery and saved-plan write seams this release + # qualifies only for the bundled Infrahub adapter. + if location != _SOURCE_LOCATION: + _raise_unsupported_declared_fields(location=location, fields=("adapter",)) + _require_installed_adapter_spec(value["adapter"], location=f"{location}.adapter") for field_name, (child_model, many) in _STRICT_CONFIGURATION_CHILDREN.get(model, {}).items(): child = value.get(field_name) child_location = f"{location}.{field_name}" @@ -345,6 +382,26 @@ def _serialize_settings(self, value: Mapping[str, Any] | None) -> dict[str, Any] return cast("dict[str, Any] | None", _thaw_json(value)) +class _ImmutableSyncSourceAdapter(_ImmutableSyncAdapter): + """The source adapter, which may declare one installed resolution target. + + ``adapter`` is serialized — and so covered by the package checksum — whenever it is + declared, because two packages that resolve different source code are not the same + package. It is omitted when absent, so a package that declares no source adapter keeps + the exact declared content, and the exact checksum, it had before the field was + admitted. + """ + + adapter: str | None = None + + @model_serializer(mode="wrap") + def _omit_absent_adapter(self, handler: Callable[[Any], dict[str, Any]]) -> dict[str, Any]: + content = handler(self) + if content.get("adapter") is None: + content.pop("adapter", None) + return content + + class _ImmutableSyncStore(SyncStore): """Package-local immutable form of legacy store settings.""" @@ -374,7 +431,7 @@ class _ImmutableSyncConfig(SyncConfig): model_config = ConfigDict(frozen=True) store: _ImmutableSyncStore | None = None - source: _ImmutableSyncAdapter + source: _ImmutableSyncSourceAdapter destination: _ImmutableSyncAdapter # Refused when non-null by _require_strict_model, so the value is always null. Excluded # from the dump: carrying a constant into the checksum makes removing it a rehash later. @@ -768,12 +825,23 @@ def _decode_diffsync_failure(record: dict[object, object], location: str) -> tup return ((location, "invalid diffsync flag name"),) +def _decode_adapter_spec_failure(record: dict[object, object], location: str) -> tuple[tuple[str, str], ...]: + """Decode one closed unsupported-adapter-specification custom error context.""" + context = _closed_context(record) + if context is not None: + pointer = _safe_context_pointer(context.get("pointer")) + if pointer is not None: + return ((pointer, "unsupported adapter specification"),) + return ((location, "unsupported adapter specification"),) + + _CustomFailureDecoder = Callable[[dict[object, object], str], tuple[tuple[str, str], ...]] _CUSTOM_FAILURE_DECODERS: dict[str, _CustomFailureDecoder] = { _INVALID_JSON_VALUE_ERROR: _decode_json_failure, _INVALID_UNICODE_SURROGATE_ERROR: _decode_unicode_failure, _UNSUPPORTED_DECLARED_FIELDS_ERROR: _decode_unsupported_field_failures, _INVALID_DIFFSYNC_FLAG_NAME_ERROR: _decode_diffsync_failure, + _UNSUPPORTED_ADAPTER_SPEC_ERROR: _decode_adapter_spec_failure, } diff --git a/tests/configuration/test_contracts.py b/tests/configuration/test_contracts.py index 3ab58865..4efe218a 100644 --- a/tests/configuration/test_contracts.py +++ b/tests/configuration/test_contracts.py @@ -685,7 +685,9 @@ def test_package_rejects_machine_local_directory() -> None: def test_always_null_legacy_fields_stay_out_of_package_identity() -> None: - # Both are refused when non-null, so hashing a constant only makes removal a rehash later. + # Refused when non-null, or absent, so hashing a constant only makes removal a rehash + # later. A source adapter that IS declared is serialized and hashed; see + # tests/runtime_schema/test_registered_source_declaration.py. declared_content = _package().declared_content() assert "adapters_path" not in declared_content["configuration"] @@ -701,15 +703,24 @@ def test_package_rejects_machine_local_adapter_path() -> None: ConfigurationPackage.model_validate(data) -@pytest.mark.parametrize("role", ["source", "destination"]) -def test_package_rejects_custom_adapter_override(role: str) -> None: +def test_package_rejects_a_custom_destination_adapter_override() -> None: + # The destination owns the schema-discovery and saved-plan write seams, which this + # release qualifies only for the bundled Infrahub adapter. data = _package().model_dump(mode="json") - data["configuration"][role]["adapter"] = "evil.module:CustomSync" + data["configuration"]["destination"]["adapter"] = "evil.module:CustomSync" with pytest.raises(ValidationError, match="unsupported declared fields: adapter"): ConfigurationPackage.model_validate(data) +def test_package_rejects_a_filesystem_source_adapter_override() -> None: + data = _package().model_dump(mode="json") + data["configuration"]["source"]["adapter"] = "./evil/module.py:CustomSync" + + with pytest.raises(ValidationError, match="unsupported adapter specification"): + ConfigurationPackage.model_validate(data) + + def test_package_rejects_nested_fields_legacy_models_would_ignore() -> None: data = _package().model_dump(mode="json") data["configuration"]["source"]["ignored"] = "would-not-be-hashed" diff --git a/tests/runtime_schema/installed_source_adapter.py b/tests/runtime_schema/installed_source_adapter.py new file mode 100644 index 00000000..58a65f28 --- /dev/null +++ b/tests/runtime_schema/installed_source_adapter.py @@ -0,0 +1,23 @@ +"""One installed, non-bundled source adapter, importable by dotted path. + +Registered admission accepts a source that names an installed import target or an entry +point. This module stands in for such a distribution: it is real installed code with no +optional driver and no network, so the admitted-profile tests exercise resolution rather +than describing it. +""" + +from __future__ import annotations + +from diffsync import Adapter, DiffSyncModel + +from infrahub_sync import DiffSyncMixin, DiffSyncModelMixin + + +class InstalledSourceModel(DiffSyncModelMixin, DiffSyncModel): + """The model base a runtime-built source class derives from.""" + + +class InstalledSourceAdapter(DiffSyncMixin, Adapter): + """The adapter class registered resolution loads for this source.""" + + type = "InstalledSource" diff --git a/tests/runtime_schema/test_registered_source_declaration.py b/tests/runtime_schema/test_registered_source_declaration.py new file mode 100644 index 00000000..b19a4cfb --- /dev/null +++ b/tests/runtime_schema/test_registered_source_declaration.py @@ -0,0 +1,142 @@ +"""F3: registered packages may declare an installed source adapter, never a filesystem one.""" + +from __future__ import annotations + +import copy +from typing import Any + +import pytest + +from infrahub_sync.configuration import ( + ConfigurationPackage, + ConfigurationPackageParseError, + parse_configuration_package, +) +from infrahub_sync.plugin_loader import PluginLoader +from tests.configuration.validation_packages import package_data + +INSTALLED_SOURCE_SPECS = ( + pytest.param("infrahub_sync.adapters.infrahub", id="dotted-module"), + pytest.param("infrahub_sync.adapters.infrahub:InfrahubAdapter", id="dotted-module-and-class"), + pytest.param("registered_entry_point_adapter", id="entry-point-name"), +) + +FILESYSTEM_SOURCE_SPECS = ( + pytest.param("./examples/custom_adapter/custom_adapter_src/custom_adapter.py:MockdbAdapter", id="relative-file"), + pytest.param("/opt/adapters/custom_adapter.py:MockdbAdapter", id="absolute-file"), + pytest.param("adapters/custom.py", id="relative-directory"), + pytest.param("custom_adapter.py", id="bare-python-file"), + pytest.param("pkg.mod.custom_adapter.py", id="dotted-python-file"), + pytest.param("~/adapters/custom", id="home-relative"), + pytest.param("..\\adapters\\custom", id="windows-relative"), + pytest.param("", id="empty"), + pytest.param("pkg..mod", id="empty-segment"), + pytest.param("pkg mod", id="space"), +) + + +def _content(*, source_adapter: str | None = None, destination_adapter: str | None = None) -> dict[str, Any]: + content = copy.deepcopy(package_data()) + if source_adapter is not None: + content["configuration"]["source"]["adapter"] = source_adapter + if destination_adapter is not None: + content["configuration"]["destination"]["adapter"] = destination_adapter + return content + + +@pytest.mark.parametrize("spec", INSTALLED_SOURCE_SPECS) +def test_an_installed_source_adapter_is_admitted(spec: str) -> None: + package = parse_configuration_package(_content(source_adapter=spec)) + + assert package.configuration.source.adapter == spec + + +@pytest.mark.parametrize("spec", FILESYSTEM_SOURCE_SPECS) +def test_a_filesystem_source_adapter_is_refused(spec: str) -> None: + with pytest.raises( + ConfigurationPackageParseError, match=r"/configuration/source/adapter: unsupported adapter specification" + ): + parse_configuration_package(_content(source_adapter=spec)) + + +@pytest.mark.parametrize("spec", INSTALLED_SOURCE_SPECS) +def test_the_destination_adapter_is_never_customizable(spec: str) -> None: + with pytest.raises( + ConfigurationPackageParseError, match=r"/configuration/destination/adapter: unsupported declared field" + ): + parse_configuration_package(_content(destination_adapter=spec)) + + +def test_adapters_path_stays_refused() -> None: + content = _content(source_adapter="infrahub_sync.adapters.infrahub") + content["configuration"]["adapters_path"] = ["/opt/adapters"] + + with pytest.raises( + ConfigurationPackageParseError, match=r"/configuration/adapters_path: unsupported declared field" + ): + parse_configuration_package(content) + + +def test_a_declared_source_adapter_enters_package_identity() -> None: + plain = parse_configuration_package(_content()) + declared = parse_configuration_package(_content(source_adapter="infrahub_sync.adapters.infrahub")) + other = parse_configuration_package(_content(source_adapter="infrahub_sync.adapters.infrahub:InfrahubAdapter")) + + assert declared.checksum() != plain.checksum() + assert declared.checksum() != other.checksum() + assert declared.declared_content()["configuration"]["source"]["adapter"] == "infrahub_sync.adapters.infrahub" + + +def test_a_package_without_a_source_adapter_keeps_its_exact_declared_content() -> None: + # Admitting the field must not change the identity of every package that omits it. + content = parse_configuration_package(_content()).declared_content() + + assert "adapter" not in content["configuration"]["source"] + assert "adapter" not in content["configuration"]["destination"] + + +# --- resolution of an admitted declaration --------------------------------------------- + + +class _FakeEntryPoint: + def __init__(self, name: str, value: type) -> None: + self.name = name + self._value = value + + def load(self) -> type: + return self._value + + +class _FakeEntryPoints: + def __init__(self, entry_point: _FakeEntryPoint) -> None: + self._entry_point = entry_point + + def select(self, *, group: str, name: str) -> tuple[_FakeEntryPoint, ...]: + if group == "infrahub_sync.adapters" and name == self._entry_point.name: + return (self._entry_point,) + return () + + +@pytest.fixture(name="registered_entry_point") +def _registered_entry_point(monkeypatch: pytest.MonkeyPatch) -> type: + """Publish one adapter under the plugin entry-point group, as an install would.""" + from infrahub_sync.adapters.infrahub import InfrahubAdapter + + monkeypatch.setattr( + "infrahub_sync.plugin_loader.entry_points", + lambda: _FakeEntryPoints(_FakeEntryPoint("registered_entry_point_adapter", InfrahubAdapter)), + ) + return InfrahubAdapter + + +@pytest.mark.parametrize("spec", INSTALLED_SOURCE_SPECS) +def test_every_admitted_declaration_resolves_through_installed_only_loading( + spec: str, registered_entry_point: type +) -> None: + package: ConfigurationPackage = parse_configuration_package(_content(source_adapter=spec)) + declared = package.configuration.source.adapter + assert declared is not None + + resolved = PluginLoader.installed_only().resolve(declared) + + assert resolved is registered_entry_point diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index f9c3d171..611a660e 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -3,10 +3,12 @@ from __future__ import annotations import copy +import importlib import sys import types from datetime import datetime, timezone from pathlib import Path +from types import ModuleType, SimpleNamespace from typing import TYPE_CHECKING, Any, cast import pytest @@ -263,21 +265,58 @@ def test_a_non_infrahub_destination_refuses_before_any_schema_read(spy: _Snapsho assert spy.branches == [] +@pytest.mark.parametrize( + "source_adapter", + [ + pytest.param("tests.runtime_schema.installed_source_adapter", id="dotted-module"), + pytest.param( + "tests.runtime_schema.installed_source_adapter:InstalledSourceAdapter", id="dotted-module-and-class" + ), + pytest.param("installed_source_entry_point", id="entry-point-name"), + ], +) def test_a_non_bundled_installed_source_with_an_infrahub_destination_may_execute( - spy: _SnapshotSpy, tmp_path: Path + spy: _SnapshotSpy, tmp_path: Path, source_adapter: str, monkeypatch: pytest.MonkeyPatch ) -> None: + # Admitted, not qualified: an installed dotted or entry-point source runs, while a + # filesystem declaration never crosses registered admission at all. + from tests.runtime_schema.installed_source_adapter import InstalledSourceAdapter, InstalledSourceModel + + monkeypatch.setattr( + "infrahub_sync.plugin_loader.entry_points", + lambda: _EntryPoints( + "installed_source_entry_point", + importlib.import_module("tests.runtime_schema.installed_source_adapter"), + ), + ) content = _package_content() - content["configuration"]["source"] = { - "name": "infrahub", - "settings": {"url": "http://source:8000", "token": {"$credential": "infrahub-token"}}, - } + content["configuration"]["source"]["adapter"] = source_adapter plan = _plan(parse_configuration_package(content), tmp_path) + assert plan.source_adapter_class is InstalledSourceAdapter assert set(plan.source_models) == {"BuiltinTag", "LocationSite"} + assert issubclass(plan.source_models["BuiltinTag"], InstalledSourceModel) assert spy.branches == ["main"] +class _EntryPoints: + """The packaging metadata one installed plugin distribution would publish. + + A real distribution points its ``infrahub_sync.adapters`` entry at the module, which + is what lets one entry serve both the adapter class and the model base. + """ + + def __init__(self, name: str, target: ModuleType) -> None: + self._name = name + self._target = target + + def select(self, *, group: str, name: str) -> tuple[object, ...]: + if group != "infrahub_sync.adapters" or name != self._name: + return () + return (SimpleNamespace(name=self._name, load=lambda: self._target),) + + def test_a_failed_schema_read_becomes_a_typed_failure_carrying_only_its_reason( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From 5a5b1ff0288f2e8c2a92e74266d01d950b4d0565 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:24:05 -0400 Subject: [PATCH 12/27] Scope registered runtime preparation to what the stage constructs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every registered stage built a full two-sided plan before dispatch. Verify then performed live schema discovery although it returns before any adapter is built, and a saved-plan apply required the source plugin although it constructs the destination alone — reintroducing the source dependency a no-source apply exists to avoid. Prepare per stage: nothing for verify, the destination only for apply, both sides for plan and sync. Whatever a stage covers still comes from one schema read, and assembling a two-sided engine from an apply-scoped plan is refused where the classes are selected rather than by loading a source adapter. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/managed/flow.py | 14 ++- infrahub_sync/runtime_schema/__init__.py | 8 ++ infrahub_sync/runtime_schema/errors.py | 4 + infrahub_sync/runtime_schema/worker.py | 64 +++++++---- infrahub_sync/utils.py | 17 +-- tests/runtime_schema/test_worker_path.py | 133 ++++++++++++++++++++--- 6 files changed, 194 insertions(+), 46 deletions(-) diff --git a/infrahub_sync/managed/flow.py b/infrahub_sync/managed/flow.py index 2792818c..8b3df742 100644 --- a/infrahub_sync/managed/flow.py +++ b/infrahub_sync/managed/flow.py @@ -47,7 +47,7 @@ ExecutionWriteback, ProductProjection, ) -from infrahub_sync.runtime_schema import build_runtime_model_plan +from infrahub_sync.runtime_schema import STAGE_RUNTIME_MODEL_SCOPE, build_runtime_model_plan from .liveness import LivenessPolicy from .models import PlanResource @@ -247,11 +247,14 @@ def _worker_execution_context( config_directory: str, projection: ProductProjection, run_branch: str | None, + stage: str, ) -> tuple[ProductProjection, Any, str]: """Load the durable run and resolve its registered or legacy runtime. A registered run also builds its runtime model plan here, from one destination - schema read, before any adapter is constructed or any source is extracted. The + schema read, before any adapter is constructed or any source is extracted. What that + plan covers follows the stage: both sides for plan and sync, the destination only for + a saved-plan apply, and nothing at all for verify, which constructs no adapter. The legacy path keeps its generated-code resolution and builds no plan. """ stored = projection.lookup_run(run_id) @@ -282,7 +285,11 @@ def _worker_execution_context( raise ValueError(_REGISTERED_CHECKSUM_MISMATCH) instance = resolve_runtime_instance(package, directory=config_directory) instance._configuration_binding = binding - instance._runtime_models = build_runtime_model_plan(package=package, instance=instance, run_branch=run_branch) + scope = STAGE_RUNTIME_MODEL_SCOPE.get(stage) + if scope is not None: + instance._runtime_models = build_runtime_model_plan( + package=package, instance=instance, run_branch=run_branch, scope=scope + ) return projection, instance, package.configuration.name @@ -309,6 +316,7 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar config_directory=config_directory, projection=projection, run_branch=branch, + stage=stage, ) if stage in ("apply", "sync") and not confirm_writes: msg = f"confirm_writes=true is required for managed stage={stage}" diff --git a/infrahub_sync/runtime_schema/__init__.py b/infrahub_sync/runtime_schema/__init__.py index af8642b8..93daab8e 100644 --- a/infrahub_sync/runtime_schema/__init__.py +++ b/infrahub_sync/runtime_schema/__init__.py @@ -13,6 +13,7 @@ from .errors import ( DestinationSchemaUnavailableError, MissingMappedKindError, + RuntimeModelScopeError, RuntimeSchemaError, UnsupportedDestinationProfileError, UnsupportedSchemaSemanticsError, @@ -23,7 +24,10 @@ compute_consumed_schema_fingerprint, ) from .worker import ( + STAGE_RUNTIME_MODEL_SCOPE, RuntimeModelPlan, + RuntimeModelScope, + RuntimeSideModels, bind_runtime_models, build_runtime_model_plan, read_destination_schema_snapshot, @@ -32,6 +36,7 @@ __all__ = [ "ATTRIBUTE_TYPE_DOMAIN", "CARDINALITIES", + "STAGE_RUNTIME_MODEL_SCOPE", "DestinationSchemaSnapshot", "DestinationSchemaUnavailableError", "MissingMappedKindError", @@ -39,7 +44,10 @@ "NormalizedKind", "NormalizedRelationship", "RuntimeModelPlan", + "RuntimeModelScope", + "RuntimeModelScopeError", "RuntimeSchemaError", + "RuntimeSideModels", "UnsupportedDestinationProfileError", "UnsupportedSchemaSemanticsError", "bind_runtime_models", diff --git a/infrahub_sync/runtime_schema/errors.py b/infrahub_sync/runtime_schema/errors.py index 6feff882..48757fd4 100644 --- a/infrahub_sync/runtime_schema/errors.py +++ b/infrahub_sync/runtime_schema/errors.py @@ -29,3 +29,7 @@ class UnsupportedSchemaSemanticsError(RuntimeSchemaError): class MissingMappedKindError(RuntimeSchemaError): """The destination schema does not declare a kind the configuration maps.""" + + +class RuntimeModelScopeError(RuntimeSchemaError): + """A run asked a runtime model plan for a side that plan does not carry.""" diff --git a/infrahub_sync/runtime_schema/worker.py b/infrahub_sync/runtime_schema/worker.py index 61068bf4..72182f51 100644 --- a/infrahub_sync/runtime_schema/worker.py +++ b/infrahub_sync/runtime_schema/worker.py @@ -14,7 +14,8 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Literal from infrahub_sync.configuration.capabilities import ( DestinationSchemaReadError, @@ -40,20 +41,39 @@ from diffsync import DiffSyncModel - from infrahub_sync import SyncConfig, SyncInstance + from infrahub_sync import SyncAdapter, SyncConfig, SyncInstance from infrahub_sync.configuration.models import ConfigurationPackage +# What one stage's runtime preparation must produce. A saved-plan apply constructs the +# destination only, so building — and therefore resolving — the source would reintroduce +# the source dependency a no-source apply exists to avoid. +RuntimeModelScope = Literal["destination", "both"] +STAGE_RUNTIME_MODEL_SCOPE: Mapping[str, RuntimeModelScope | None] = MappingProxyType( + {"plan": "both", "sync": "both", "apply": "destination", "verify": None} +) + + +@dataclass(frozen=True, slots=True) +class RuntimeSideModels: + """One side's resolved adapter class and its fresh model classes.""" + + adapter_class: type[Any] + models: Mapping[str, type[DiffSyncModel]] + + @dataclass(frozen=True, slots=True) class RuntimeModelPlan: - """One run's resolved adapter classes, model classes, and schema identity.""" + """One run's resolved sides and the schema identity they were built from. + + ``source`` is ``None`` on a destination-only plan. Both sides, when present, come + from the one snapshot this plan's ``schema_fingerprint`` was computed over. + """ branch: str schema_fingerprint: str - source_adapter_class: type[Any] - source_models: Mapping[str, type[DiffSyncModel]] - destination_adapter_class: type[Any] - destination_models: Mapping[str, type[DiffSyncModel]] + destination: RuntimeSideModels + source: RuntimeSideModels | None def read_destination_schema_snapshot(package: ConfigurationPackage, branch: str) -> Mapping[str, Any]: @@ -106,9 +126,14 @@ def build_runtime_model_plan( package: ConfigurationPackage, instance: SyncInstance, run_branch: str | None, + scope: RuntimeModelScope, ) -> RuntimeModelPlan: """Build one registered run's adapter classes, model classes, and schema fingerprint. + ``scope`` is what the stage will construct: ``"both"`` for plan and sync, and + ``"destination"`` for a saved-plan apply, which builds and resolves nothing for the + source. Whatever the scope covers comes from one schema read. + Raises: UnsupportedDestinationProfileError: the destination is outside the admitted profile; raised before any schema I/O. @@ -128,21 +153,22 @@ def build_runtime_model_plan( raise DestinationSchemaUnavailableError(msg, reason=exc.reason) from None snapshot = normalize_destination_schema(raw) _require_mapped_kinds(instance, snapshot.kinds) + + def side(adapter: SyncAdapter) -> RuntimeSideModels: + return RuntimeSideModels( + adapter_class=resolve_installed_adapter_class(adapter), + models=build_runtime_models( + snapshot=snapshot, + configuration=instance, + model_base=resolve_installed_model_base(adapter), + ), + ) + return RuntimeModelPlan( branch=branch, schema_fingerprint=compute_consumed_schema_fingerprint(configuration=instance, snapshot=snapshot), - source_adapter_class=resolve_installed_adapter_class(instance.source), - source_models=build_runtime_models( - snapshot=snapshot, - configuration=instance, - model_base=resolve_installed_model_base(instance.source), - ), - destination_adapter_class=resolve_installed_adapter_class(instance.destination), - destination_models=build_runtime_models( - snapshot=snapshot, - configuration=instance, - model_base=resolve_installed_model_base(instance.destination), - ), + destination=side(instance.destination), + source=side(instance.source) if scope == "both" else None, ) diff --git a/infrahub_sync/utils.py b/infrahub_sync/utils.py index ce950d46..f9803650 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -20,7 +20,7 @@ from infrahub_sync.plan.verify import destination_binding_failure from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError, resolve_installed_adapter_class from infrahub_sync.potenda import Potenda -from infrahub_sync.runtime_schema import bind_runtime_models +from infrahub_sync.runtime_schema import RuntimeModelScopeError, bind_runtime_models logger = logging.getLogger(__name__) @@ -177,7 +177,10 @@ def _adapter_classes( ImportError: either side's adapter class could not be loaded. """ if runtime_models is not None: - return runtime_models.source_adapter_class, runtime_models.destination_adapter_class + if runtime_models.source is None: + msg = "engine assembly needs both adapters, but this run prepared a destination-only runtime model plan" + raise RuntimeModelScopeError(msg) + return runtime_models.source.adapter_class, runtime_models.destination.adapter_class source = import_adapter(sync_instance=sync_instance, adapter=sync_instance.source) destination = import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) if source and destination: @@ -234,8 +237,8 @@ def get_potenda_from_instance( except (ValueError, TypeError) as exc: msg = f"Error initializing {sync_instance.source.name.title()}Adapter: {exc}" raise ValueError(msg) from exc - if runtime_models is not None: - bind_runtime_models(src, runtime_models.source_models) + if runtime_models is not None and runtime_models.source is not None: + bind_runtime_models(src, runtime_models.source.models) dest_kwargs = { "config": sync_instance, @@ -252,7 +255,7 @@ def get_potenda_from_instance( msg = f"Error initializing {sync_instance.destination.name.title()}Adapter: {exc}" raise ValueError(msg) from exc if runtime_models is not None: - bind_runtime_models(dst, runtime_models.destination_models) + bind_runtime_models(dst, runtime_models.destination.models) # Single topological pass yields both the flat order and the tier layout # (tiers is None when an explicit `order` is configured). @@ -369,7 +372,7 @@ def open_existing( destination_class = ( import_adapter(sync_instance=sync_instance, adapter=sync_instance.destination) if runtime_models is None - else runtime_models.destination_adapter_class + else runtime_models.destination.adapter_class ) if not destination_class: msg = f"Could not load the destination adapter '{sync_instance.destination.name}'" @@ -389,7 +392,7 @@ def open_existing( msg = f"Error initializing {sync_instance.destination.name.title()}Adapter: {exc}" raise ValueError(msg) from exc if runtime_models is not None: - bind_runtime_models(destination, runtime_models.destination_models) + bind_runtime_models(destination, runtime_models.destination.models) top_level, tiers = sync_instance.compute_order_and_tiers() diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index 611a660e..e5233d5e 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -24,10 +24,13 @@ DestinationSchemaUnavailableError, MissingMappedKindError, RuntimeModelPlan, + RuntimeModelScope, + RuntimeModelScopeError, UnsupportedDestinationProfileError, build_runtime_model_plan, ) from infrahub_sync.runtime_schema import worker as worker_module +from infrahub_sync.utils import get_potenda_from_instance from tests.configuration.validation_packages import package_data if TYPE_CHECKING: @@ -120,11 +123,22 @@ def _netbox_driver(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: sys.modules.pop("infrahub_sync.adapters.netbox", None) -def _plan(package: ConfigurationPackage, tmp_path: Path, *, run_branch: str | None = None) -> RuntimeModelPlan: +def _instance(package: ConfigurationPackage, tmp_path: Path) -> SyncInstance: from infrahub_sync.configuration.runtime import resolve_runtime_instance - instance = resolve_runtime_instance(package, directory=str(tmp_path)) - return build_runtime_model_plan(package=package, instance=instance, run_branch=run_branch) + return resolve_runtime_instance(package, directory=str(tmp_path)) + + +def _plan( + package: ConfigurationPackage, + tmp_path: Path, + *, + run_branch: str | None = None, + scope: RuntimeModelScope = "both", +) -> RuntimeModelPlan: + return build_runtime_model_plan( + package=package, instance=_instance(package, tmp_path), run_branch=run_branch, scope=scope + ) # --- AR1: the registered worker has a real runtime-model consumer ----------------------- @@ -133,10 +147,11 @@ def _plan(package: ConfigurationPackage, tmp_path: Path, *, run_branch: str | No def test_the_plan_carries_fresh_model_classes_for_both_sides(spy: _SnapshotSpy, tmp_path: Path) -> None: plan = _plan(_package(), tmp_path) - assert set(plan.source_models) == {"BuiltinTag", "LocationSite"} - assert set(plan.destination_models) == {"BuiltinTag", "LocationSite"} - assert plan.source_models["BuiltinTag"] is not plan.destination_models["BuiltinTag"] - assert plan.destination_models["LocationSite"]._attributes == ("tags",) + assert plan.source is not None + assert set(plan.source.models) == {"BuiltinTag", "LocationSite"} + assert set(plan.destination.models) == {"BuiltinTag", "LocationSite"} + assert plan.source.models["BuiltinTag"] is not plan.destination.models["BuiltinTag"] + assert plan.destination.models["LocationSite"]._attributes == ("tags",) assert spy.branches == ["main"] @@ -151,11 +166,12 @@ def test_registered_composition_attaches_the_plan_to_the_runtime_instance(spy: _ config_directory=str(tmp_path), projection=cast("ProductProjection", projection), run_branch=None, + stage="plan", ) assert name == package.configuration.name assert instance._runtime_models is not None - assert set(instance._runtime_models.destination_models) == {"BuiltinTag", "LocationSite"} + assert set(instance._runtime_models.destination.models) == {"BuiltinTag", "LocationSite"} assert spy.branches == ["main"] @@ -176,6 +192,7 @@ def test_a_legacy_unregistered_run_builds_no_runtime_models(spy: _SnapshotSpy, t config_directory=str(tmp_path), projection=cast("ProductProjection", projection), run_branch=None, + stage="plan", ) assert instance._runtime_models is None @@ -223,6 +240,87 @@ def _skip(*_args: object, **_kwargs: object) -> None: assert spy.branches == ["main"] +# --- F1: runtime preparation is scoped to what the stage consumes ----------------------- + + +@pytest.mark.parametrize( + ("stage", "expected_reads"), + [("plan", 1), ("sync", 1), ("apply", 1), ("verify", 0)], +) +def test_only_stages_that_construct_adapters_read_the_schema( + spy: _SnapshotSpy, tmp_path: Path, stage: str, expected_reads: int +) -> None: + package = _package() + binding = ("cfg-runtime-models", 1, package.checksum()) + projection = _StubProjection(package, binding) + + _, instance, _ = managed_flow._worker_execution_context( + f"run-{stage}", + binding, + config_directory=str(tmp_path), + projection=cast("ProductProjection", projection), + run_branch=None, + stage=stage, + ) + + assert len(spy.branches) == expected_reads + assert (instance._runtime_models is None) == (expected_reads == 0) + + +def test_a_saved_plan_apply_builds_no_source_requirements(spy: _SnapshotSpy, tmp_path: Path) -> None: + # Apply constructs the destination only, so requiring the source plugin would + # reintroduce the source dependency a no-source apply exists to avoid. + package = _package() + binding = ("cfg-runtime-models", 1, package.checksum()) + projection = _StubProjection(package, binding) + + _, instance, _ = managed_flow._worker_execution_context( + "run-apply", + binding, + config_directory=str(tmp_path), + projection=cast("ProductProjection", projection), + run_branch=None, + stage="apply", + ) + + plan = instance._runtime_models + assert plan is not None + assert plan.source is None + assert set(plan.destination.models) == {"BuiltinTag", "LocationSite"} + assert spy.branches == ["main"] + + +def test_an_apply_scoped_plan_never_resolves_the_source_adapter( + spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def _forbidden(adapter: object) -> type: + del adapter + msg = "apply resolved a source adapter" + raise AssertionError(msg) + + monkeypatch.setattr(worker_module, "resolve_installed_adapter_class", _forbidden) + monkeypatch.setattr(worker_module, "resolve_installed_model_base", _forbidden) + + with pytest.raises(AssertionError, match="apply resolved a source adapter"): + # The destination is still resolved, so the spy must fire for it and only it. + _plan(_package(), tmp_path, scope="destination") + + assert spy.branches == ["main"] + + +def test_engine_assembly_refuses_a_destination_only_plan(spy: _SnapshotSpy, tmp_path: Path) -> None: + # Assembling a two-sided engine from an apply-scoped plan is a wrong-stage request, + # refused where the classes are selected rather than by loading a source adapter. + package = _package() + instance = _instance(package, tmp_path) + instance._runtime_models = _plan(package, tmp_path, scope="destination") + + with pytest.raises(RuntimeModelScopeError): + get_potenda_from_instance(sync_instance=instance) + + assert spy.branches == ["main"] + + # --- AR5: one snapshot decides one plan ------------------------------------------------- @@ -294,9 +392,10 @@ def test_a_non_bundled_installed_source_with_an_infrahub_destination_may_execute plan = _plan(parse_configuration_package(content), tmp_path) - assert plan.source_adapter_class is InstalledSourceAdapter - assert set(plan.source_models) == {"BuiltinTag", "LocationSite"} - assert issubclass(plan.source_models["BuiltinTag"], InstalledSourceModel) + assert plan.source is not None + assert plan.source.adapter_class is InstalledSourceAdapter + assert set(plan.source.models) == {"BuiltinTag", "LocationSite"} + assert issubclass(plan.source.models["BuiltinTag"], InstalledSourceModel) assert spy.branches == ["main"] @@ -357,7 +456,7 @@ def test_two_configurations_sharing_kinds_get_distinct_bound_classes(spy: _Snaps second = _plan(_package(name="second-configuration"), tmp_path) assert spy.branches == ["main", "main"] - assert first.destination_models["BuiltinTag"] is not second.destination_models["BuiltinTag"] + assert first.destination.models["BuiltinTag"] is not second.destination.models["BuiltinTag"] assert first.schema_fingerprint == second.schema_fingerprint @@ -378,8 +477,8 @@ def test_a_rebuild_after_a_schema_change_leaves_the_earlier_classes_untouched( spy.snapshot = grown after = _plan(_package(), tmp_path) - assert "colour" not in before.destination_models["BuiltinTag"].model_fields - assert after.destination_models["BuiltinTag"] is not before.destination_models["BuiltinTag"] + assert "colour" not in before.destination.models["BuiltinTag"].model_fields + assert after.destination.models["BuiltinTag"] is not before.destination.models["BuiltinTag"] assert after.schema_fingerprint == before.schema_fingerprint @@ -407,10 +506,10 @@ def _forbidden(*args: object, **kwargs: object) -> None: plan = _plan(_package(), tmp_path) adapter = _RecordingAdapter() - worker_module.bind_runtime_models(adapter, plan.destination_models) + worker_module.bind_runtime_models(adapter, plan.destination.models) - assert adapter.BuiltinTag is plan.destination_models["BuiltinTag"] - assert adapter.LocationSite is plan.destination_models["LocationSite"] + assert adapter.BuiltinTag is plan.destination.models["BuiltinTag"] + assert adapter.LocationSite is plan.destination.models["LocationSite"] def _saved_plan() -> SavedPlan: From 85baaf3de743f48239f9a6113a7d72885793ca03 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:24:45 -0400 Subject: [PATCH 13/27] Render generated string defaults as Python literals A declared default was interpolated between double quotes, so a destination declaring a default containing a quote produced invalid generated Python, and one containing a backslash or a control character produced Python whose value differs from the schema's. Strings are inside the closed runtime domain, so this was also a real parity gap between the two construction mechanisms. Render every default with repr, which is the literal that evaluates back to the declared value. Ruff normalizes the quoting afterwards, so the committed example files are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/generator/__init__.py | 12 +++--- tests/runtime_schema/test_model_builder.py | 48 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/infrahub_sync/generator/__init__.py b/infrahub_sync/generator/__init__.py index 9c1dcfc9..cc95da94 100644 --- a/infrahub_sync/generator/__init__.py +++ b/infrahub_sync/generator/__init__.py @@ -126,13 +126,11 @@ def get_kind(item: Union[RelationshipSchema, AttributeSchema]) -> str: if item.optional: kind = f"{kind} | None" if item.default_value is not None: - # Format the default value based on its type - if isinstance(item.default_value, str): - kind += f' = "{item.default_value}"' - elif isinstance(item.default_value, (int, float, bool)): - kind += f" = {item.default_value}" - else: - kind += f" = {item.default_value!r}" + # `repr` renders every declared default as the Python literal that + # evaluates back to it. Interpolating a string between quotes instead + # emitted invalid or differently-valued Python for any default holding a + # quote, a backslash, or a control character. + kind += f" = {item.default_value!r}" else: kind += " = None" diff --git a/tests/runtime_schema/test_model_builder.py b/tests/runtime_schema/test_model_builder.py index 07108c01..ea559eee 100644 --- a/tests/runtime_schema/test_model_builder.py +++ b/tests/runtime_schema/test_model_builder.py @@ -249,6 +249,54 @@ def test_an_attribute_kind_outside_the_closed_table_refuses_before_extraction() build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=InfrahubModel) +@pytest.mark.parametrize( + "default_value", + [ + pytest.param('a"b', id="double-quote"), + pytest.param("a'b", id="single-quote"), + pytest.param("a\"'b", id="both-quotes"), + pytest.param("a\nb", id="newline"), + pytest.param("a\\b", id="backslash"), + pytest.param("a\tb", id="tab"), + pytest.param("a\rb", id="carriage-return"), + pytest.param("", id="empty"), + pytest.param("caf\u00e9", id="non-ascii"), + pytest.param("\x00", id="null-byte"), + ], +) +def test_a_string_default_matches_the_generator_exactly(tmp_path: Path, default_value: str) -> None: + # A default is inside the declared closed domain, so parity has to hold for every + # string a destination can declare — not only the ones that need no escaping. + node = NodeSchema( + name="Device", + namespace="Infra", + attributes=[ + AttributeSchema(name="name", kind=AttributeKind.TEXT, unique=True), + AttributeSchema(name="label", kind=AttributeKind.TEXT, optional=True, default_value=default_value), + ], + ) + configuration = SyncConfig( + name="string-default", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="InfraDevice", + fields=[SchemaMappingField(name="name"), SchemaMappingField(name="label")], + ) + ], + ) + schema: SchemaMapping = {node.kind: node} + + runtime = _runtime_models(configuration, schema, InfrahubModel) + generated = _generated_models( + SyncInstance(**configuration.model_dump(), directory=str(tmp_path)), schema, tmp_path, "default" + ) + + assert runtime["InfraDevice"].model_fields["label"].default == default_value + assert _describe(runtime["InfraDevice"]) == _describe(generated["InfraDevice"]) + + @pytest.mark.parametrize( ("snapshot_name", "example_name"), [("netbox_example_schema.json", "from-netbox"), ("custom_example_schema.json", "custom-example")], From eaffbef765d51be0bbb50c6894787711a02a92aa Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:26:30 -0400 Subject: [PATCH 14/27] Refuse non-finite defaults and report closed-domain refusals A non-finite float passed both schema extraction and normalization, then failed much later inside canonical encoding as an unrelated serialization error rather than the closed domain's own refusal. Separately, validation swallowed a closed-domain refusal and returned only a null fingerprint, so an operator saw a missing identity with no defect named while a run of the same package refused. Refuse a non-finite default at both boundaries, and give validation a destination-schema-unsupported-semantics finding so the worker and admission present the same verdict on the same snapshot. Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/durable-product-records.mdx | 5 +-- infrahub_sync/configuration/capabilities.py | 9 ++++- .../configuration/schema_validation.py | 21 +++++++++--- infrahub_sync/runtime_schema/domain.py | 10 +++++- tests/configuration/test_schema_validation.py | 33 +++++++++++++++++++ .../runtime_schema/test_accessor_snapshot.py | 22 +++++++++++++ tests/runtime_schema/test_domain.py | 20 +++++++++++ 7 files changed, 112 insertions(+), 8 deletions(-) diff --git a/docs/docs/reference/durable-product-records.mdx b/docs/docs/reference/durable-product-records.mdx index 4b049b6c..7a5c4b85 100644 --- a/docs/docs/reference/durable-product-records.mdx +++ b/docs/docs/reference/durable-product-records.mdx @@ -83,15 +83,16 @@ An adapter's own configuration check keeps its own codes, which are outside this #### Destination schema validation codes -These four codes are emitted **only on the explicit opt-in**: `validate` given a +These five codes are emitted **only on the explicit opt-in**: `validate` given a destination-schema options object. The default `validate` path judges declared content -only, performs no schema read and no network I/O, and never emits them. All four carry an +only, performs no schema read and no network I/O, and never emits them. All five carry an `error` severity. | Code | What it means | Where it points | | ---- | ------------- | --------------- | | `destination-schema-mismatch` | A declared schema mapping disagrees with the destination's schema snapshot: an undeclared kind, a field that is neither an attribute nor a relationship, a relationship reference on an attribute, or a static value whose shape disagrees with the relationship's cardinality. | the mapping entry, field, reference, or static value | | `destination-schema-read-failed` | The destination schema could not be read: a timeout, refused credentials, an unreachable server, a rejected or unusable response, an unresolvable declared token, or unusable declared client settings. The message names the failure class. | `/configuration/destination` | +| `destination-schema-unsupported-semantics` | The destination schema was read, but it declares semantics outside the supported schema domain — an unknown relationship cardinality, a member shape the domain does not define, or a default no JSON encoding can carry. A run of this configuration refuses the same schema. | `/configuration/destination` | | `destination-schema-validation-unsupported` | Schema validation was explicitly requested against a destination adapter that does not declare it. A missing capability needed to determine safety is an error, not a warning. | `/configuration/destination` | | `unsupported-destination-write` | The configuration requests destination write operations the destination adapter does not declare support for. | `/configuration/destination` | diff --git a/infrahub_sync/configuration/capabilities.py b/infrahub_sync/configuration/capabilities.py index d6b5b944..818086f3 100644 --- a/infrahub_sync/configuration/capabilities.py +++ b/infrahub_sync/configuration/capabilities.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import re from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass @@ -341,7 +342,13 @@ def _member_text(value: object) -> object: def _json_native_default(value: object) -> Any: - """Keep a JSON-native declared default; refuse anything a model cannot reproduce.""" + """Keep a JSON-native declared default; refuse anything a model cannot reproduce. + + A non-finite float is refused here rather than carried: JSON has no encoding for it, + so it could not survive the canonical projection a plan is identified by. + """ + if isinstance(value, float) and not math.isfinite(value): + raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") if value is None or isinstance(value, (str, bool, int, float)): return value if isinstance(value, Enum): diff --git a/infrahub_sync/configuration/schema_validation.py b/infrahub_sync/configuration/schema_validation.py index 5b362862..2768d96c 100644 --- a/infrahub_sync/configuration/schema_validation.py +++ b/infrahub_sync/configuration/schema_validation.py @@ -1,7 +1,7 @@ """Destination schema validation — the explicit opt-in checks outside the declared-content core. The declared-content core (``validation.py``) judges declared content only and stays -untouched; this module owns the schema-path checks and their finding codes. Three error +untouched; this module owns the schema-path checks and their finding codes. Four error families live here, each behind its own ``_CODE_`` constant and frozen by this module's own exact-set and reachability tests: @@ -17,6 +17,9 @@ * the unsupported-destination-write family — the operations one configuration requests, derived through the one shared SYNC-78 effective-operation rule, judged against the destination's declared write operations; +* the closed-domain family — a snapshot the accessor delivered but the shared runtime + schema domain refuses. Registered execution refuses the same snapshot, so validation + reports the same defect rather than silently withholding the fingerprint; * the schema-read-failure family — a schema read the accessor reports as failed becomes a typed error finding rather than a generic service-boundary refusal. The bundled accessor classifies SDK-raised errors, HTTP transport and status failures, an unresolvable declared @@ -53,6 +56,7 @@ _CODE_DESTINATION_SCHEMA_MISMATCH = "destination-schema-mismatch" _CODE_DESTINATION_SCHEMA_READ_FAILED = "destination-schema-read-failed" +_CODE_DESTINATION_SCHEMA_UNSUPPORTED_SEMANTICS = "destination-schema-unsupported-semantics" _CODE_DESTINATION_SCHEMA_VALIDATION_UNSUPPORTED = "destination-schema-validation-unsupported" _CODE_UNSUPPORTED_DESTINATION_WRITE = "unsupported-destination-write" @@ -232,8 +236,17 @@ def collect_destination_schema_findings(package: ConfigurationPackage) -> Destin configuration=package.configuration, snapshot=normalize_destination_schema(snapshot) ) except UnsupportedSchemaSemanticsError: - # A snapshot the accessor delivered but the closed domain refuses has no - # identity to report; the content checks below still judge what they can. - fingerprint = None + # The worker refuses this snapshot too, so validation names the same + # defect rather than reporting only a missing fingerprint. + findings.append( + _finding( + code=_CODE_DESTINATION_SCHEMA_UNSUPPORTED_SEMANTICS, + location=_DESTINATION_LOCATION, + message=( + f"destination schema for branch {branch!r} declares semantics outside the " + "supported schema domain" + ), + ) + ) findings.extend(_schema_content_findings(package, snapshot)) return DestinationSchemaValidation(findings=sort_findings(findings), schema_fingerprint=fingerprint) diff --git a/infrahub_sync/runtime_schema/domain.py b/infrahub_sync/runtime_schema/domain.py index 442b20b0..c6517c98 100644 --- a/infrahub_sync/runtime_schema/domain.py +++ b/infrahub_sync/runtime_schema/domain.py @@ -14,6 +14,7 @@ from __future__ import annotations +import math from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any, NoReturn, cast @@ -105,7 +106,14 @@ def _require_str(value: object, *, detail: str) -> str: def _require_json_default(value: object, *, detail: str) -> Any: - """Accept only a JSON-native default, so a model default is reproducible.""" + """Accept only a JSON-native default, so a model default is reproducible. + + Non-finite floats are outside the domain: JSON cannot encode them, so one would fail + canonical encoding later, as an unrelated serialization error rather than the closed + domain's own refusal. + """ + if isinstance(value, float) and not math.isfinite(value): + _refuse(f"{detail} declares a non-finite default") if value is None or isinstance(value, (str, bool, int, float)): return value if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): diff --git a/tests/configuration/test_schema_validation.py b/tests/configuration/test_schema_validation.py index 6bef29aa..08e887d8 100644 --- a/tests/configuration/test_schema_validation.py +++ b/tests/configuration/test_schema_validation.py @@ -77,6 +77,18 @@ def _relationship(peer: str, cardinality: str, *, optional: bool = True, kind: s } +# A snapshot the accessor could deliver but the closed runtime domain refuses: a +# relationship cardinality outside the declared domain. +_UNSUPPORTED_SNAPSHOT: dict[str, Any] = { + "InfraDevice": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": _attribute(optional=False, unique=True)}, + "relationships": {"site": _relationship("LocationSite", "several")}, + }, +} + + class _SpiedAccessor: """An injected accessor that records every read and returns a fixed snapshot.""" @@ -433,6 +445,8 @@ def test_the_wrapper_and_the_core_never_call_a_schema_accessor(monkeypatch: pyte "unsupported-destination-write", # AR9's schema-read-failure family. "destination-schema-read-failed", + # The closed-domain family: a snapshot the shared runtime domain refuses. + "destination-schema-unsupported-semantics", } ) @@ -481,6 +495,10 @@ def test_every_frozen_schema_code_is_reachable_and_nothing_else_is_emitted( reached.add(finding.code) severities.add(finding.severity) _inject(monkeypatch, _table_with_accessor(_raising_accessor("timeout"))) + for finding in collect_destination_schema_findings(package(package_data())).findings: + reached.add(finding.code) + severities.add(finding.severity) + _inject(monkeypatch, _table_with_accessor(_SpiedAccessor(_UNSUPPORTED_SNAPSHOT))) for finding in collect_destination_schema_findings(package(package_data())).findings: reached.add(finding.code) severities.add(finding.severity) @@ -810,3 +828,18 @@ def test_every_frozen_schema_code_is_documented_for_an_operator() -> None: assert "### Finding codes" in documented assert {code for code in FROZEN_SCHEMA_CODES if f"`{code}`" not in documented} == set() + + +# --- F5: unsupported closed-domain semantics are a finding, not a silent null ----------- + + +def test_a_snapshot_outside_the_closed_domain_reports_a_finding(monkeypatch: pytest.MonkeyPatch) -> None: + # Validation and the worker share one closed domain, so a snapshot the domain refuses + # must be visible to an operator rather than only erasing the fingerprint. + _inject(monkeypatch, _table_with_accessor(_SpiedAccessor(_UNSUPPORTED_SNAPSHOT))) + + result = collect_destination_schema_findings(package(package_data())) + + assert result.schema_fingerprint is None + assert "destination-schema-unsupported-semantics" in {finding.code for finding in result.findings} + assert all(finding.severity == "error" for finding in result.findings) diff --git a/tests/runtime_schema/test_accessor_snapshot.py b/tests/runtime_schema/test_accessor_snapshot.py index 1dac220c..e55492b1 100644 --- a/tests/runtime_schema/test_accessor_snapshot.py +++ b/tests/runtime_schema/test_accessor_snapshot.py @@ -79,3 +79,25 @@ def test_a_non_string_identity_path_is_refused_at_the_adapter_boundary() -> None # into the snapshot, and everything derived from one. with pytest.raises(DestinationSchemaReadError): capabilities_module._build_schema_snapshot({"InfraDevice": _NonStringPathNode()}) + + +class _NonFiniteDefaultAttribute: + name = "asn" + kind = "Number" + optional = True + default_value = float("inf") + unique = False + + +class _NonFiniteDefaultNode: + """A node declaring a default no JSON encoding can carry.""" + + human_friendly_id = () + uniqueness_constraints = () + attributes = (_NonFiniteDefaultAttribute(),) + relationships: tuple[object, ...] = () + + +def test_a_non_finite_declared_default_is_refused_at_the_adapter_boundary() -> None: + with pytest.raises(DestinationSchemaReadError): + capabilities_module._build_schema_snapshot({"InfraDevice": _NonFiniteDefaultNode()}) diff --git a/tests/runtime_schema/test_domain.py b/tests/runtime_schema/test_domain.py index 1d1ab2f6..4f9b2a1f 100644 --- a/tests/runtime_schema/test_domain.py +++ b/tests/runtime_schema/test_domain.py @@ -74,6 +74,26 @@ def test_normalization_orders_members_by_name_so_delivery_order_is_irrelevant() assert normalize_destination_schema(reordered) == normalize_destination_schema(_SNAPSHOT) +@pytest.mark.parametrize( + "default_value", + [ + pytest.param(float("nan"), id="nan"), + pytest.param(float("inf"), id="inf"), + pytest.param(float("-inf"), id="-inf"), + ], +) +def test_a_non_finite_default_refuses_before_it_can_reach_the_fingerprint(default_value: float) -> None: + # A non-finite float is not JSON, so it cannot survive canonical encoding. Refusing it + # here keeps the closed domain the one place that answers "unsupported semantics". + entry = { + **_SNAPSHOT["InfraDevice"], + "attributes": {"asn": {"kind": "Number", "optional": True, "default_value": default_value, "unique": False}}, + } + + with pytest.raises(UnsupportedSchemaSemanticsError): + normalize_destination_schema({"InfraDevice": entry}) + + @pytest.mark.parametrize( "mutation", [ From cd2462fb47ab143a5e28fbe12fefa09249ea539f Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:28:30 -0400 Subject: [PATCH 15/27] Make the normalized snapshot immutable to its depth The snapshot documented itself as immutable but copied its kinds into an ordinary dictionary and kept list and mapping defaults mutable, so anything holding one could edit the value the models and the fingerprint were derived from. Freeze the kind mapping and every nested default. A model field's default still has to be mutable, so the attribute hands one out on request rather than sharing its own; canonical encoding reads the frozen forms unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/runtime_schema/domain.py | 34 +++++++++--- infrahub_sync/runtime_schema/models.py | 4 +- tests/runtime_schema/test_domain.py | 74 +++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/infrahub_sync/runtime_schema/domain.py b/infrahub_sync/runtime_schema/domain.py index c6517c98..9a659737 100644 --- a/infrahub_sync/runtime_schema/domain.py +++ b/infrahub_sync/runtime_schema/domain.py @@ -17,6 +17,7 @@ import math from collections.abc import Mapping, Sequence from dataclasses import dataclass +from types import MappingProxyType from typing import Any, NoReturn, cast from .errors import UnsupportedSchemaSemanticsError @@ -26,7 +27,12 @@ @dataclass(frozen=True, slots=True) class NormalizedAttribute: - """One destination attribute, with every property a model or write depends on.""" + """One destination attribute, with every property a model or write depends on. + + ``default_value`` is immutable all the way down, so nothing derived from a snapshot + can edit the snapshot; :meth:`mutable_default` returns the mutable value a model + field's default has to be. + """ name: str kind: str @@ -34,6 +40,10 @@ class NormalizedAttribute: default_value: Any unique: bool + def mutable_default(self) -> Any: + """Return this attribute's declared default as a mutable JSON-native value.""" + return _mutable_json(self.default_value) + @dataclass(frozen=True, slots=True) class NormalizedRelationship: @@ -68,7 +78,7 @@ class DestinationSchemaSnapshot: kinds: Mapping[str, NormalizedKind] def __post_init__(self) -> None: - object.__setattr__(self, "kinds", dict(self.kinds)) + object.__setattr__(self, "kinds", MappingProxyType(dict(self.kinds))) def __eq__(self, other: object) -> bool: if not isinstance(other, DestinationSchemaSnapshot): @@ -105,6 +115,15 @@ def _require_str(value: object, *, detail: str) -> str: return value +def _mutable_json(value: Any) -> Any: + """Return the mutable JSON-native form of an immutable normalized value.""" + if isinstance(value, tuple): + return [_mutable_json(item) for item in value] + if isinstance(value, Mapping): + return {key: _mutable_json(item) for key, item in value.items()} + return value + + def _require_json_default(value: object, *, detail: str) -> Any: """Accept only a JSON-native default, so a model default is reproducible. @@ -117,11 +136,14 @@ def _require_json_default(value: object, *, detail: str) -> Any: if value is None or isinstance(value, (str, bool, int, float)): return value if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): - return [_require_json_default(item, detail=detail) for item in value] + return tuple(_require_json_default(item, detail=detail) for item in value) if isinstance(value, Mapping): - return { - _require_str(key, detail=detail): _require_json_default(item, detail=detail) for key, item in value.items() - } + return MappingProxyType( + { + _require_str(key, detail=detail): _require_json_default(item, detail=detail) + for key, item in value.items() + } + ) _refuse(detail) diff --git a/infrahub_sync/runtime_schema/models.py b/infrahub_sync/runtime_schema/models.py index 304b2cfc..f6757e2b 100644 --- a/infrahub_sync/runtime_schema/models.py +++ b/infrahub_sync/runtime_schema/models.py @@ -71,7 +71,9 @@ def _attribute_field(attribute: NormalizedAttribute, *, kind: str) -> tuple[Any, raise UnsupportedSchemaSemanticsError(msg) from None if not attribute.optional: return python_type, _REQUIRED - return python_type | None, attribute.default_value + # The snapshot holds the default immutably; a model field's default has to be the + # mutable value the generated file would have written. + return python_type | None, attribute.mutable_default() def _relationship_field(relationship: NormalizedRelationship) -> tuple[Any, Any]: diff --git a/tests/runtime_schema/test_domain.py b/tests/runtime_schema/test_domain.py index 4f9b2a1f..dfbc9fdf 100644 --- a/tests/runtime_schema/test_domain.py +++ b/tests/runtime_schema/test_domain.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast import pytest @@ -115,3 +115,75 @@ def test_unusable_snapshot_members_refuse_with_a_typed_error(mutation: dict[str, with pytest.raises(UnsupportedSchemaSemanticsError): normalize_destination_schema({"InfraDevice": entry}) + + +# --- F7: the snapshot is immutable, so nothing derived from one can be edited under it -- + + +def test_the_snapshot_kind_mapping_cannot_be_reassigned() -> None: + snapshot = normalize_destination_schema(_SNAPSHOT) + + with pytest.raises(TypeError): + snapshot.kinds["InfraDevice"] = snapshot.kinds["InfraDevice"] # ty: ignore[invalid-assignment] + + +def test_a_container_default_is_immutable_in_the_snapshot() -> None: + entry = { + **_SNAPSHOT["InfraDevice"], + "attributes": { + "tags": { + "kind": "List", + "optional": True, + "default_value": ["alpha", {"nested": ["beta"]}], + "unique": False, + } + }, + } + + default = normalize_destination_schema({"InfraDevice": entry}).kinds["InfraDevice"].attributes[0].default_value + + assert default == ("alpha", {"nested": ("beta",)}) + with pytest.raises((TypeError, AttributeError)): + default.append("gamma") + with pytest.raises(TypeError): + default[1]["nested"] = () + + +def test_an_immutable_container_default_still_reaches_a_model_and_canonical_json() -> None: + from diffsync import DiffSyncModel + + from infrahub_sync import SchemaMappingField, SchemaMappingModel, SyncAdapter, SyncConfig + from infrahub_sync.plan.canonical import canonical_json_bytes + from infrahub_sync.runtime_schema import build_runtime_models, compute_consumed_schema_fingerprint + + entry = { + **_SNAPSHOT["InfraDevice"], + "attributes": { + "name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "tags": {"kind": "List", "optional": True, "default_value": ["alpha"], "unique": False}, + }, + } + snapshot = normalize_destination_schema({"InfraDevice": entry}) + configuration = SyncConfig( + name="immutable-default", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="InfraDevice", + fields=[SchemaMappingField(name="name"), SchemaMappingField(name="tags")], + ) + ], + ) + + device = cast( + "Any", + build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=DiffSyncModel)["InfraDevice"], + ) + instance = device(name="leaf01") + + assert instance.tags == ["alpha"] + instance.tags.append("beta") + assert device(name="leaf02").tags == ["alpha"] + assert canonical_json_bytes(snapshot.kinds["InfraDevice"].attributes[1].default_value) == b'["alpha"]' + assert len(compute_consumed_schema_fingerprint(configuration=configuration, snapshot=snapshot)) == 64 From 9b60b8cb9464ba9ff014f8db00c0f20a0eb75c82 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:29:14 -0400 Subject: [PATCH 16/27] Stop claiming a plan already records the schema fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation contract said the fingerprint it reports is the one a run records on its plan. No plan manifest field, retained-plan comparison, or pre-write gate exists yet — that is the plan-guard unit that follows. Say what is true now: validation and registered worker construction compute the same projection, and recording it on a plan comes later. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/configuration/schema_validation.py | 6 +++--- infrahub_sync/product_store/configs.py | 2 +- infrahub_sync/runtime_schema/projection.py | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/infrahub_sync/configuration/schema_validation.py b/infrahub_sync/configuration/schema_validation.py index 2768d96c..2c2049ae 100644 --- a/infrahub_sync/configuration/schema_validation.py +++ b/infrahub_sync/configuration/schema_validation.py @@ -80,9 +80,9 @@ class DestinationSchemaValidation: ``schema_fingerprint`` is the consumed-semantics identity of the snapshot the content checks actually judged — ``None`` whenever no snapshot was read: a non-declaring - destination, an unknown adapter, or a failed read. Validation, worker construction, - and apply share this one projection, so a fingerprint reported here is the fingerprint - a plan of the same package against the same schema records. + destination, an unknown adapter, or a failed read. Validation and registered worker + construction compute it through the same projection; recording it on a plan, and + comparing it before an apply writes, belong to the plan-guard unit that follows. """ findings: tuple[ValidationFinding, ...] diff --git a/infrahub_sync/product_store/configs.py b/infrahub_sync/product_store/configs.py index 8f0aaa83..130d658a 100644 --- a/infrahub_sync/product_store/configs.py +++ b/infrahub_sync/product_store/configs.py @@ -519,7 +519,7 @@ class ValidationReport: destination schema snapshot the schema checks judged — ``None`` whenever no snapshot was read: the default path, a non-declaring destination, or a failed read. It is what makes "same package, same schema snapshot, same report" auditable rather than - asserted, and it is the same projection a run records on its plan. + asserted. """ config_id: str diff --git a/infrahub_sync/runtime_schema/projection.py b/infrahub_sync/runtime_schema/projection.py index d8e4ae46..926a1e49 100644 --- a/infrahub_sync/runtime_schema/projection.py +++ b/infrahub_sync/runtime_schema/projection.py @@ -1,6 +1,6 @@ """The one compatibility property: a canonical projection of consumed schema semantics. -A plan's schema fingerprint is SHA-256 over this projection. It carries every fact a +The fingerprint is SHA-256 over this projection. It carries every fact a registered configuration consumes — each configured kind, its effective DiffSync identifiers, its ordered destination human-friendly ID and uniqueness-constraint component paths, every mapped field's model- and write-affecting properties, and the @@ -10,6 +10,10 @@ Everything else is compatible growth: an unmapped kind, an optional or defaulted unmapped field, and any difference in snapshot delivery order leave the projection — and so the fingerprint — unchanged. + +Registered configuration validation and registered worker construction compute this +today. Recording it on a saved plan, and comparing a plan's recorded value against the +live schema before an apply writes, is the plan-guard unit that follows this one. """ from __future__ import annotations From 9c3d1d317e8d78909ad8a265461ba66c6105ec84 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:44:07 -0400 Subject: [PATCH 17/27] Prove the acceptance behaviour instead of describing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evidence for AR1, AR3 and AR5 stopped short of the behaviour it claimed: the registered case patched execution before any adapter was built, the generated-file case bound models onto a recording object rather than running installed resolution, isolation was shown by sequential rebuilds, the branch matrix never reached a constructed destination, and the kind inventory only proved the captured examples were a subset of the closed table. Run a registered NetBox to Infrahub plan through real engine assembly, both real adapters, real extraction and a real saved plan, with only the two provider clients faked, and assert creates, updates, the resolved classes, the absence of generated Python, and the effective branch the destination received. Barrier-synchronise isolation across configurations, runs and rebuilds. Compare every admitted attribute kind in every required/optional/default state against the generator, and hold Integer — which the SDK's schema type cannot express, so no oracle can be rendered — to the generator's own type map. Co-Authored-By: Claude Opus 5 (1M context) --- .../test_concurrent_isolation.py | 169 +++++++++++ tests/runtime_schema/test_model_builder.py | 145 +++++++++ .../test_registered_execution.py | 278 ++++++++++++++++++ 3 files changed, 592 insertions(+) create mode 100644 tests/runtime_schema/test_concurrent_isolation.py create mode 100644 tests/runtime_schema/test_registered_execution.py diff --git a/tests/runtime_schema/test_concurrent_isolation.py b/tests/runtime_schema/test_concurrent_isolation.py new file mode 100644 index 00000000..3bd01fe7 --- /dev/null +++ b/tests/runtime_schema/test_concurrent_isolation.py @@ -0,0 +1,169 @@ +"""AR3: concurrent construction across configurations, runs, and rebuilds stays isolated. + +Barrier-synchronised so the threads are genuinely inside construction at the same time; +sequential rebuilds cannot show that two simultaneous runs do not share a class object. +""" + +from __future__ import annotations + +import copy +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sync import SchemaMappingField, SchemaMappingModel, SyncAdapter, SyncConfig +from infrahub_sync.adapters.infrahub import InfrahubModel +from infrahub_sync.runtime_schema import ( + ATTRIBUTE_TYPE_DOMAIN, + build_runtime_models, + compute_consumed_schema_fingerprint, + normalize_destination_schema, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +_SNAPSHOT: dict[str, Any] = { + "BuiltinTag": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": { + "name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "description": {"kind": "Text", "optional": True, "default_value": None, "unique": False}, + "colour": {"kind": "Text", "optional": True, "default_value": None, "unique": False}, + }, + "relationships": {}, + }, +} +_WORKERS = 8 + + +def _configuration(name: str, fields: list[str]) -> SyncConfig: + return SyncConfig( + name=name, + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="BuiltinTag", + identifiers=["name"], + fields=[SchemaMappingField(name=field) for field in fields], + ) + ], + ) + + +def _run_together(work: Sequence[Callable[..., Any]]) -> list[Any]: + """Run every callable with all of them inside construction at the same time.""" + barrier = threading.Barrier(len(work)) + + def _start(item: Callable[..., object]) -> object: + barrier.wait(timeout=30) + return item() + + with ThreadPoolExecutor(max_workers=len(work)) as pool: + return list(pool.map(_start, work)) + + +def test_concurrent_configurations_sharing_a_kind_never_share_a_class() -> None: + snapshot = normalize_destination_schema(_SNAPSHOT) + fields = [["name"], ["name", "description"], ["name", "colour"], ["name", "description", "colour"]] + work = [ + ( + lambda index=index: build_runtime_models( + snapshot=snapshot, + configuration=_configuration(f"configuration-{index % len(fields)}", fields[index % len(fields)]), + model_base=InfrahubModel, + ) + ) + for index in range(_WORKERS) + ] + + built = _run_together(work) + + classes = [models["BuiltinTag"] for models in built] + assert len({id(model) for model in classes}) == _WORKERS + for index, model in enumerate(classes): + declared = set(model.model_fields) - set(InfrahubModel.model_fields) - {"local_id", "local_data"} + assert declared == set(fields[index % len(fields)]) + + +def test_concurrent_runs_of_one_configuration_never_share_a_class() -> None: + snapshot = normalize_destination_schema(_SNAPSHOT) + configuration = _configuration("one-configuration", ["name", "description"]) + work = [ + (lambda: build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=InfrahubModel)) + for _ in range(_WORKERS) + ] + + built = _run_together(work) + + classes = [models["BuiltinTag"] for models in built] + assert len({id(model) for model in classes}) == _WORKERS + assert len({model.__mro__[1] for model in classes}) == _WORKERS + + +def test_a_concurrent_rebuild_on_a_grown_schema_cannot_reach_the_earlier_classes() -> None: + configuration = _configuration("grown", ["name", "description"]) + before = normalize_destination_schema(_SNAPSHOT) + grown_snapshot = copy.deepcopy(_SNAPSHOT) + grown_snapshot["BuiltinTag"]["attributes"]["extra"] = { + "kind": "Text", + "optional": True, + "default_value": None, + "unique": False, + } + after = normalize_destination_schema(grown_snapshot) + original = build_runtime_models(snapshot=before, configuration=configuration, model_base=InfrahubModel) + work = [ + ( + lambda snapshot=snapshot: build_runtime_models( + snapshot=snapshot, configuration=configuration, model_base=InfrahubModel + ) + ) + for snapshot in ([before, after] * (_WORKERS // 2)) + ] + + built = _run_together(work) + + assert "extra" not in original["BuiltinTag"].model_fields + rebuilt = [models["BuiltinTag"] for models in built] + assert all(model is not original["BuiltinTag"] for model in rebuilt) + # The grown attribute is unmapped, so no rebuild declares it and none changes identity. + assert all("extra" not in model.model_fields for model in rebuilt) + assert compute_consumed_schema_fingerprint( + configuration=configuration, snapshot=after + ) == compute_consumed_schema_fingerprint(configuration=configuration, snapshot=before) + + +def test_the_shared_type_table_is_unchanged_by_concurrent_construction() -> None: + snapshot = normalize_destination_schema(_SNAPSHOT) + configuration = _configuration("table", ["name"]) + before = dict(ATTRIBUTE_TYPE_DOMAIN) + + _run_together( + [ + (lambda: build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=InfrahubModel)) + for _ in range(_WORKERS) + ] + ) + + assert dict(ATTRIBUTE_TYPE_DOMAIN) == before + + +@pytest.mark.parametrize("attempt", range(3)) +def test_concurrent_fingerprints_of_one_snapshot_agree(attempt: int) -> None: + del attempt + snapshot = normalize_destination_schema(_SNAPSHOT) + configuration = _configuration("fingerprint", ["name", "description"]) + + fingerprints = _run_together( + [ + (lambda: compute_consumed_schema_fingerprint(configuration=configuration, snapshot=snapshot)) + for _ in range(_WORKERS) + ] + ) + + assert len(set(fingerprints)) == 1 diff --git a/tests/runtime_schema/test_model_builder.py b/tests/runtime_schema/test_model_builder.py index ea559eee..d42d99ab 100644 --- a/tests/runtime_schema/test_model_builder.py +++ b/tests/runtime_schema/test_model_builder.py @@ -30,6 +30,7 @@ ) from infrahub_sync.adapters.infrahub import InfrahubModel from infrahub_sync.configuration import capabilities as capabilities_module +from infrahub_sync.generator import ATTRIBUTE_KIND_MAP from infrahub_sync.runtime_schema import ( ATTRIBUTE_TYPE_DOMAIN, UnsupportedSchemaSemanticsError, @@ -249,6 +250,98 @@ def test_an_attribute_kind_outside_the_closed_table_refuses_before_extraction() build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=InfrahubModel) +_KIND_DEFAULTS: dict[str, object] = { + "Text": "a-default", + "String": "a-default", + "TextArea": "line one\nline two", + "DateTime": "2026-08-30T00:00:00+00:00", + "HashedPassword": "hashed", + "Dropdown": "leaf", + "MacAddress": "00:11:22:33:44:55", + "IPHost": "10.0.0.1/32", + "IPNetwork": "10.0.0.0/24", + "Number": 7, + "Integer": 7, + "Boolean": True, + "Checkbox": False, + "List": ["alpha", "beta"], +} + + +# `Integer` is in the generator's own kind map, so the closed table keeps it, but the +# SDK's AttributeKind cannot express it — a live destination therefore never declares it, +# and no generator oracle can be rendered for it. It is asserted directly instead. +UNRENDERABLE_KINDS = frozenset({"Integer"}) + + +def _matrix_schema() -> tuple[SchemaMapping, SyncConfig]: + """Every admitted attribute kind in every required/optional/default state. + + Three attributes per kind — required, optional without a default, optional with one — + plus the four relationship shapes, so the oracle covers the declared domain rather + than whichever states one captured example happens to contain. + """ + attributes = [AttributeSchema(name="key", kind=AttributeKind.TEXT, unique=True)] + field_names = ["key"] + for kind, default in sorted(_KIND_DEFAULTS.items()): + if kind in UNRENDERABLE_KINDS: + continue + slug = kind.lower() + attributes.extend( + [ + AttributeSchema(name=f"{slug}_required", kind=AttributeKind(kind), optional=False), + AttributeSchema(name=f"{slug}_optional", kind=AttributeKind(kind), optional=True), + AttributeSchema(name=f"{slug}_default", kind=AttributeKind(kind), optional=True, default_value=default), + ] + ) + field_names.extend([f"{slug}_required", f"{slug}_optional", f"{slug}_default"]) + relationships = [ + RelationshipSchema(name="one_required", peer="LocationSite", cardinality="one", optional=False), + RelationshipSchema(name="one_optional", peer="LocationSite", cardinality="one", optional=True), + RelationshipSchema(name="many_required", peer="BuiltinTag", cardinality="many", optional=False), + RelationshipSchema(name="many_optional", peer="BuiltinTag", cardinality="many", optional=True), + RelationshipSchema(name="component_many", peer="InfraInterface", cardinality="many", optional=True), + ] + relationships[-1].kind = RelationshipKind.COMPONENT + field_names.extend(relationship.name for relationship in relationships) + node = NodeSchema(name="Device", namespace="Infra", attributes=attributes, relationships=relationships) + configuration = SyncConfig( + name="kind-matrix", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel(name="InfraDevice", fields=[SchemaMappingField(name=name) for name in field_names]) + ], + ) + return {node.kind: node}, configuration + + +def test_every_admitted_kind_and_state_matches_the_generator(tmp_path: Path) -> None: + schema, configuration = _matrix_schema() + + runtime = _runtime_models(configuration, schema, InfrahubModel) + generated = _generated_models( + SyncInstance(**configuration.model_dump(), directory=str(tmp_path)), schema, tmp_path, "matrix" + ) + + described = _describe(runtime["InfraDevice"]) + assert described == _describe(generated["InfraDevice"]) + # The matrix really covers the declared domain and every state of it. + assert set(_KIND_DEFAULTS) == set(ATTRIBUTE_TYPE_DOMAIN) + assert {kind for kind in ATTRIBUTE_TYPE_DOMAIN if kind not in AttributeKind.__members__.values()} == ( + UNRENDERABLE_KINDS + ) + for kind in set(ATTRIBUTE_TYPE_DOMAIN) - UNRENDERABLE_KINDS: + slug = kind.lower() + assert described["fields"][f"{slug}_required"]["default"] == "PydanticUndefined" + assert described["fields"][f"{slug}_optional"]["default"] == "None" + assert described["fields"][f"{slug}_default"]["default"] == repr(_KIND_DEFAULTS[kind]) + assert described["fields"]["one_required"]["annotation"] == "" + assert described["fields"]["many_required"]["default"] == "[]" + assert "component_many" in described["fields"] + assert "component_many" not in described["attributes"] + + @pytest.mark.parametrize( "default_value", [ @@ -310,3 +403,55 @@ def test_every_captured_mapped_attribute_kind_is_inside_the_closed_table(snapsho assert captured assert captured <= set(ATTRIBUTE_TYPE_DOMAIN) + + +@pytest.mark.parametrize( + ("optional", "default_value", "annotation", "default"), + [ + pytest.param(False, None, "", "PydanticUndefined", id="required"), + pytest.param(True, None, "int | None", "None", id="optional"), + pytest.param(True, 7, "int | None", "7", id="optional-with-default"), + ], +) +def test_the_unrenderable_integer_kind_matches_the_generator_type_map( + *, optional: bool, default_value: object, annotation: str, default: str +) -> None: + # The SDK's AttributeKind cannot express `Integer`, so there is no rendered oracle to + # compare against; the closed table's mapping is held to the generator's own map. + assert {"Integer"} == UNRENDERABLE_KINDS + assert ATTRIBUTE_KIND_MAP["Integer"] == "int" + snapshot = normalize_destination_schema( + { + "InfraDevice": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": { + "key": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "count": { + "kind": "Integer", + "optional": optional, + "default_value": default_value, + "unique": False, + }, + }, + "relationships": {}, + } + } + ) + configuration = SyncConfig( + name="integer-kind", + source=SyncAdapter(name="netbox"), + destination=SyncAdapter(name="infrahub"), + schema_mapping=[ + SchemaMappingModel( + name="InfraDevice", + fields=[SchemaMappingField(name="key"), SchemaMappingField(name="count")], + ) + ], + ) + + built = build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=InfrahubModel) + + info = built["InfraDevice"].model_fields["count"] + assert str(info.annotation).replace("typing.", "") == annotation + assert repr(info.default) == default diff --git a/tests/runtime_schema/test_registered_execution.py b/tests/runtime_schema/test_registered_execution.py new file mode 100644 index 00000000..15951c3d --- /dev/null +++ b/tests/runtime_schema/test_registered_execution.py @@ -0,0 +1,278 @@ +"""AR1/AR5/AR9: a registered NetBox to Infrahub run executes on runtime models. + +The engine, both adapters, extraction, the diff and the saved plan are the product's +own; only the two provider clients are faked, so this exercises the seam a real run uses +rather than a mock standing in for it. No generated Python exists anywhere on the path, +and no network call is possible. +""" + +from __future__ import annotations + +import importlib +import sys +import types +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import pytest +from infrahub_sdk.exceptions import NodeNotFoundError + +from infrahub_sync.configuration import ConfigurationPackage, parse_configuration_package +from infrahub_sync.configuration.runtime import resolve_runtime_instance +from infrahub_sync.execution import execute_run +from infrahub_sync.plan.review import SavedPlan +from infrahub_sync.runtime_schema import build_runtime_model_plan +from infrahub_sync.runtime_schema import worker as worker_module +from infrahub_sync.utils import get_potenda_from_instance +from tests.configuration.validation_packages import package_data + +if TYPE_CHECKING: + from collections.abc import Iterator + + from infrahub_sync import SyncInstance + +TAG_ATTRIBUTES = { + "name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}, + "description": {"kind": "Text", "optional": True, "default_value": None, "unique": False}, +} +SNAPSHOT: dict[str, Any] = { + "BuiltinTag": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": TAG_ATTRIBUTES, + "relationships": {}, + }, +} +MAPPING = [ + { + "name": "BuiltinTag", + "mapping": "extras.tags", + "fields": [{"name": "name", "mapping": "name"}, {"name": "description", "mapping": "description"}], + } +] +# One NetBox tag the destination does not have, and one whose description differs. +NETBOX_TAGS = [ + {"id": 1, "name": "blue", "description": "cool"}, + {"id": 2, "name": "green", "description": "fresh"}, +] +INFRAHUB_TAGS = [{"id": "node-green", "name": "green", "description": "stale"}] + + +# --- narrow provider fakes -------------------------------------------------------------- + + +@dataclass +class _Attribute: + value: Any + + +@dataclass +class _NodeSchema: + kind: str + attribute_names: list[str] = field(default_factory=list) + relationships: list[Any] = field(default_factory=list) + relationship_names: list[str] = field(default_factory=list) + human_friendly_id: list[str] = field(default_factory=lambda: ["name__value"]) + uniqueness_constraints: list[list[str]] = field(default_factory=lambda: [["name__value"]]) + + +class _Node: + """One destination node, in the shape the adapter reads.""" + + def __init__(self, node_id: str, kind: str, attributes: dict[str, Any]) -> None: + self.id = node_id + self._schema = _NodeSchema(kind=kind, attribute_names=list(attributes)) + for name, value in attributes.items(): + setattr(self, name, _Attribute(value=value)) + + +class _Store: + def __init__(self) -> None: + self.nodes: dict[str, _Node] = {} + + def set(self, key: str, node: _Node) -> None: + self.nodes[key] = node + + def get(self, key: str, **_kwargs: object) -> _Node | None: + return self.nodes.get(key) + + +class _SchemaEndpoint: + def __init__(self, schema: dict[str, Any]) -> None: + self._schema = schema + self.branches: list[str | None] = [] + + def all(self, branch: str | None = None) -> dict[str, Any]: + self.branches.append(branch) + return self._schema + + +class _InfrahubClient: + """The Infrahub SDK surface the destination adapter uses, and nothing else.""" + + def __init__(self, address: str, config: object) -> None: + self.address = address + self.config = config + self.schema = _SchemaEndpoint({kind: _NodeSchema(kind=kind) for kind in SNAPSHOT}) + self.store = _Store() + self.created: list[tuple[str, dict[str, Any]]] = [] + + @staticmethod + def get(*_args: object, **kwargs: object) -> _Node: + raise NodeNotFoundError(identifier={"key": [str(kwargs)]}, node_type=str(kwargs.get("kind"))) + + @staticmethod + def all(kind: str, **_kwargs: object) -> list[_Node]: + if kind != "BuiltinTag": + return [] + return [ + _Node(tag["id"], kind, {"name": tag["name"], "description": tag["description"]}) for tag in INFRAHUB_TAGS + ] + + +class _NetboxEndpoint: + def __init__(self, records: list[dict[str, Any]]) -> None: + self._records = records + + def all(self) -> list[dict[str, Any]]: + return [dict(record) for record in self._records] + + +def _forget_netbox_adapter() -> None: + """Drop the NetBox adapter so the next import binds the current pynetbox stub. + + Only NetBox needs this: it imports its driver at module load, so a module another + suite imported against a different stub keeps that stub. The Infrahub adapter's + client is patched by attribute on the live module, which needs no reimport. + """ + sys.modules.pop("infrahub_sync.adapters.netbox", None) + + +@pytest.fixture(name="providers", autouse=True) +def _providers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[dict[str, Any]]: + """Install both provider clients and the run cache; nothing may reach a network.""" + clients: dict[str, Any] = {} + + def _netbox_api(url: str, token: str) -> types.SimpleNamespace: + del url, token + api = types.SimpleNamespace(extras=types.SimpleNamespace(tags=_NetboxEndpoint(NETBOX_TAGS))) + clients["netbox"] = api + return api + + def _infrahub_client(address: str, config: object) -> _InfrahubClient: + client = _InfrahubClient(address, config) + clients["infrahub"] = client + return client + + driver = cast("Any", types.ModuleType("pynetbox")) + driver.api = _netbox_api + monkeypatch.setitem(sys.modules, "pynetbox", driver) + _forget_netbox_adapter() + monkeypatch.setattr("infrahub_sync.adapters.infrahub.InfrahubClientSync", _infrahub_client) + monkeypatch.setattr(worker_module, "read_destination_schema_snapshot", lambda _package, _branch: SNAPSHOT) + monkeypatch.setenv("INFRAHUB_SYNC_CACHE_DIR", str(tmp_path / "runs")) + monkeypatch.setenv("NETBOX_TOKEN", "netbox-execution-canary") + monkeypatch.setenv("INFRAHUB_API_TOKEN", "infrahub-execution-canary") + yield clients + _forget_netbox_adapter() + + +def _registered_instance(tmp_path: Path, *, branch: str | None = None, run_branch: str | None = None) -> SyncInstance: + """One registered package resolved and prepared exactly as the worker prepares it.""" + content = package_data() + content["configuration"]["schema_mapping"] = MAPPING + if branch is not None: + content["configuration"]["destination"]["settings"]["branch"] = branch + package: ConfigurationPackage = parse_configuration_package(content) + instance = resolve_runtime_instance(package, directory=str(tmp_path / "config")) + (tmp_path / "config").mkdir(exist_ok=True) + instance._runtime_models = build_runtime_model_plan( + package=package, instance=instance, run_branch=run_branch, scope="both" + ) + return instance + + +# --- AR1: a registered run plans creates and updates on runtime models ------------------- + + +def test_a_registered_run_plans_creates_and_updates_through_engine_assembly(tmp_path: Path) -> None: + instance = _registered_instance(tmp_path) + + saved = execute_run( + instance, + operation="plan", + run_id="registered-execution", + show_progress=False, + print_diff=False, + _return_saved_plan=True, + ) + + assert isinstance(saved, SavedPlan) + summary = saved.summary() + assert summary.by_action == {"create": 1, "update": 1} + assert summary.by_kind == {"BuiltinTag": 2} + actions = {(operation.action, operation.kind) for operation in saved.operations()} + assert actions == {("create", "BuiltinTag"), ("update", "BuiltinTag")} + + +def test_the_run_binds_runtime_models_onto_both_installed_adapters(tmp_path: Path) -> None: + instance = _registered_instance(tmp_path) + plan = instance._runtime_models + assert plan is not None + assert plan.source is not None + + engine = get_potenda_from_instance(sync_instance=instance, run_id="registered-binding") + + live = importlib.import_module("infrahub_sync.adapters.infrahub") + source = cast("Any", engine.source) + destination = cast("Any", engine.destination) + # Installed resolution reached execution: these are the classes the plan resolved. + assert type(source) is plan.source.adapter_class + assert type(destination) is plan.destination.adapter_class + assert isinstance(destination, live.InfrahubAdapter) + # And the classes each adapter loads with are this run's, over that side's base. + assert source.BuiltinTag is plan.source.models["BuiltinTag"] + assert destination.BuiltinTag is plan.destination.models["BuiltinTag"] + assert issubclass(destination.BuiltinTag, live.InfrahubModel) + + +def test_no_generated_python_is_written_or_read_by_a_registered_run(tmp_path: Path) -> None: + instance = _registered_instance(tmp_path) + # Scoped to this run: the legacy path legitimately imports generated wrappers, and + # other suites leave those modules behind. + before = {name for name in sys.modules if name.endswith(".adapter")} + + execute_run( + instance, + operation="plan", + run_id="registered-no-generated-files", + show_progress=False, + print_diff=False, + ) + + assert list((tmp_path / "config").rglob("*.py")) == [] + assert {name for name in sys.modules if name.endswith(".adapter")} == before + + +# --- AR5: the constructed destination works against the effective branch ----------------- + + +@pytest.mark.parametrize( + ("declared", "run_branch", "expected"), + [("staging", "review", "staging"), (None, "review", "review"), (None, None, "main")], +) +def test_the_constructed_destination_receives_the_effective_branch( + tmp_path: Path, declared: str | None, run_branch: str | None, expected: str +) -> None: + instance = _registered_instance(tmp_path, branch=declared, run_branch=run_branch) + plan = instance._runtime_models + assert plan is not None + + engine = get_potenda_from_instance(sync_instance=instance, branch=run_branch, run_id="registered-branch") + + destination = cast("Any", engine.destination) + assert plan.branch == expected + assert destination.destination_binding.branch == expected + assert destination.client.config.default_branch == expected + assert destination.client.schema.branches == [expected] From 0ea74e0b79b7e6c36a7dfe600e07cfc525de7be1 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Sun, 30 Aug 2026 23:58:33 -0400 Subject: [PATCH 18/27] Make the isolation and no-source oracles able to fail Binding was only ever asserted on a single adapter instance, so a regression that bound onto the adapter class instead would have satisfied every assertion. Bind distinct plans concurrently onto several instances of one shared adapter class and check both halves: each instance keeps its own models, and the class stays untouched. The no-source apply oracle replaced both resolvers with one that always raises, and the destination is resolved first, so it reported the required destination call as though it were the source. Record the calls in order instead and assert the exact arguments, with a two-sided case proving the recorder distinguishes the sides at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../test_concurrent_isolation.py | 73 ++++++++++++++++++- tests/runtime_schema/test_worker_path.py | 56 +++++++++++--- 2 files changed, 117 insertions(+), 12 deletions(-) diff --git a/tests/runtime_schema/test_concurrent_isolation.py b/tests/runtime_schema/test_concurrent_isolation.py index 3bd01fe7..e6b56826 100644 --- a/tests/runtime_schema/test_concurrent_isolation.py +++ b/tests/runtime_schema/test_concurrent_isolation.py @@ -14,9 +14,10 @@ import pytest from infrahub_sync import SchemaMappingField, SchemaMappingModel, SyncAdapter, SyncConfig -from infrahub_sync.adapters.infrahub import InfrahubModel +from infrahub_sync.adapters.infrahub import InfrahubAdapter, InfrahubModel from infrahub_sync.runtime_schema import ( ATTRIBUTE_TYPE_DOMAIN, + bind_runtime_models, build_runtime_models, compute_consumed_schema_fingerprint, normalize_destination_schema, @@ -167,3 +168,73 @@ def test_concurrent_fingerprints_of_one_snapshot_agree(attempt: int) -> None: ) assert len(set(fingerprints)) == 1 + + +# --- AR3: binding is per adapter instance, never onto the shared adapter class ---------- + + +class _SharedAdapter(InfrahubAdapter): + """One adapter class with many instances — the shape binding must never mutate. + + A local subclass so a regression that bound onto the class could not leak into the + bundled adapter and change another test's meaning. + """ + + +def test_concurrent_binding_keeps_each_adapter_instance_to_its_own_models() -> None: + # A regression that bound onto `type(adapter)` would satisfy every per-instance + # assertion elsewhere, because there is only one instance in those tests. Here the + # instances share a class, so class-level binding makes the last writer win. + snapshot = normalize_destination_schema(_SNAPSHOT) + configuration = _configuration("concurrent-binding", ["name", "description"]) + plans = [ + build_runtime_models(snapshot=snapshot, configuration=configuration, model_base=InfrahubModel) + for _ in range(_WORKERS) + ] + # Constructed without __init__: binding is the only behaviour under test, and the + # adapter's own constructor would open a client. + adapters = [object.__new__(_SharedAdapter) for _ in range(_WORKERS)] + assert len({id(model["BuiltinTag"]) for model in plans}) == _WORKERS + + _run_together( + [ + (lambda adapter=adapter, models=models: bind_runtime_models(adapter, models)) + for adapter, models in zip(adapters, plans, strict=True) + ] + ) + + for adapter, models in zip(adapters, plans, strict=True): + assert adapter.BuiltinTag is models["BuiltinTag"] + assert "BuiltinTag" in vars(adapter) + assert getattr(_SharedAdapter, "BuiltinTag", None) is None + assert "BuiltinTag" not in vars(_SharedAdapter) + assert getattr(InfrahubAdapter, "BuiltinTag", None) is None + + +def test_concurrent_binding_of_distinct_configurations_stays_per_instance() -> None: + # The same property when the plans differ in shape as well as identity. + snapshot = normalize_destination_schema(_SNAPSHOT) + fields = [["name"], ["name", "description"], ["name", "colour"], ["name", "description", "colour"]] + plans = [ + build_runtime_models( + snapshot=snapshot, + configuration=_configuration(f"configuration-{index}", fields[index % len(fields)]), + model_base=InfrahubModel, + ) + for index in range(_WORKERS) + ] + adapters = [object.__new__(_SharedAdapter) for _ in range(_WORKERS)] + + _run_together( + [ + (lambda adapter=adapter, models=models: bind_runtime_models(adapter, models)) + for adapter, models in zip(adapters, plans, strict=True) + ] + ) + + for index, (adapter, models) in enumerate(zip(adapters, plans, strict=True)): + bound = adapter.BuiltinTag + assert bound is models["BuiltinTag"] + declared = set(bound.model_fields) - set(InfrahubModel.model_fields) - {"local_id", "local_data"} + assert declared == set(fields[index % len(fields)]) + assert "BuiltinTag" not in vars(_SharedAdapter) diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index e5233d5e..249bf79b 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -36,7 +36,7 @@ if TYPE_CHECKING: from collections.abc import Iterator - from infrahub_sync import SyncInstance + from infrahub_sync import SyncAdapter, SyncInstance from infrahub_sync.product_store import ProductProjection pytest.importorskip("prefect") @@ -290,21 +290,55 @@ def test_a_saved_plan_apply_builds_no_source_requirements(spy: _SnapshotSpy, tmp assert spy.branches == ["main"] -def test_an_apply_scoped_plan_never_resolves_the_source_adapter( +def _recording_resolution(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str]]: + """Record every installed-resolution call in order, passing each one through. + + Recording rather than refusing: the destination is resolved first, so a resolver that + always raises reports the destination call as though it were the source. + """ + calls: list[tuple[str, str]] = [] + resolve_adapter = worker_module.resolve_installed_adapter_class + resolve_base = worker_module.resolve_installed_model_base + + def _adapter(adapter: SyncAdapter) -> type: + calls.append(("adapter_class", adapter.name)) + return resolve_adapter(adapter) + + def _base(adapter: SyncAdapter) -> type: + calls.append(("model_base", adapter.name)) + return resolve_base(adapter) + + monkeypatch.setattr(worker_module, "resolve_installed_adapter_class", _adapter) + monkeypatch.setattr(worker_module, "resolve_installed_model_base", _base) + return calls + + +def test_an_apply_scoped_plan_resolves_only_destination_requirements( spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - def _forbidden(adapter: object) -> type: - del adapter - msg = "apply resolved a source adapter" - raise AssertionError(msg) + calls = _recording_resolution(monkeypatch) - monkeypatch.setattr(worker_module, "resolve_installed_adapter_class", _forbidden) - monkeypatch.setattr(worker_module, "resolve_installed_model_base", _forbidden) + plan = _plan(_package(), tmp_path, scope="destination") + + assert plan.source is None + assert calls == [("adapter_class", "infrahub"), ("model_base", "infrahub")] + assert spy.branches == ["main"] + + +def test_a_two_sided_plan_resolves_both_sides_so_the_oracle_can_tell_them_apart( + spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Without this the apply case above would pass against a plan that resolves nothing. + calls = _recording_resolution(monkeypatch) - with pytest.raises(AssertionError, match="apply resolved a source adapter"): - # The destination is still resolved, so the spy must fire for it and only it. - _plan(_package(), tmp_path, scope="destination") + _plan(_package(), tmp_path, scope="both") + assert calls == [ + ("adapter_class", "infrahub"), + ("model_base", "infrahub"), + ("adapter_class", "netbox"), + ("model_base", "netbox"), + ] assert spy.branches == ["main"] From e4510083e6ec42a9f8ab586d1103be4692563bc0 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 00:00:35 -0400 Subject: [PATCH 19/27] Resolve a class-valued entry point's model base from its own module A distribution ordinarily publishes one entry point per adapter naming the adapter class. Entry-point resolution returned that class for every request, so asking the same entry point for the model base returned the adapter class, and a registered run built its models over an adapter. Answer whichever class the caller asked for: a loaded class that is not the requested base resolves through the module that defines it, the same module a module-valued entry point would have named. A module declaring no such class refuses. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/plugin_loader.py | 40 +++- .../test_entry_point_resolution.py | 194 ++++++++++++++++++ 2 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 tests/runtime_schema/test_entry_point_resolution.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index e1ba7b79..44e5aef6 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -36,6 +36,15 @@ class PluginLoadError(Exception): """Exception raised when a plugin cannot be loaded.""" +def _target_base_class(default_class_candidates: tuple[str, ...]) -> type[Any] | None: + """The base class a resolution request is really asking for, if it names one.""" + if "Adapter" in default_class_candidates: + return Adapter + if "Model" in default_class_candidates: + return DiffSyncModel + return None + + class Plugintype(str, Enum): """Plugin type enum for categorizing how a plugin was loaded.""" @@ -335,15 +344,36 @@ def _resolve_from_entry_point( # If it's a module, find the class if inspect.ismodule(obj): return self._find_class_in_module(obj, class_name, name, default_class_candidates) - # If it's already a class, return it if inspect.isclass(obj): - return cast("type[Any]", obj) + return self._entry_point_class(cast("type[Any]", obj), class_name, name, default_class_candidates) except (ImportError, AttributeError): pass return None + def _entry_point_class( + self, + loaded: type[Any], + class_name: str | None, + name: str, + default_class_candidates: tuple[str, ...], + ) -> type[Any] | None: + """Answer a class-valued entry point for whichever class the caller asked for. + + A distribution publishes one entry point per adapter, and it ordinarily names the + adapter class. That one entry point has to answer both questions the loader asks, + so a loaded class that is not what was requested is resolved from the module that + defines it — the same module a module-valued entry point would have named. + """ + target = _target_base_class(default_class_candidates) + if target is None or issubclass(loaded, target): + return loaded + defining_module = sys.modules.get(loaded.__module__) + if defining_module is None: + return None + return self._find_class_in_module(defining_module, class_name, name, default_class_candidates) + def _resolve_from_builtin( self, name: str, class_name: str | None, default_class_candidates: tuple[str, ...] ) -> type[Any] | None: @@ -455,11 +485,7 @@ def _find_class_in_module( if obj.__module__ == module.__name__ and not issubclass(obj, BaseException) ] - target_base_class = None - if "Adapter" in default_class_candidates: - target_base_class = Adapter - elif "Model" in default_class_candidates: - target_base_class = DiffSyncModel + target_base_class = _target_base_class(default_class_candidates) if target_base_class: for cls in classes_in_module: diff --git a/tests/runtime_schema/test_entry_point_resolution.py b/tests/runtime_schema/test_entry_point_resolution.py new file mode 100644 index 00000000..535f2939 --- /dev/null +++ b/tests/runtime_schema/test_entry_point_resolution.py @@ -0,0 +1,194 @@ +"""R2: a class-valued entry point resolves both the adapter class and the model base. + +A distribution publishing `myplugin = myplugin.adapters:MyAdapter` is ordinary; the +entry point names the adapter, and the model base has to come from the module that +adapter is defined in. +""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from diffsync import Adapter, DiffSyncModel + +from infrahub_sync import SyncAdapter, SyncInstance +from infrahub_sync.plugin_loader import ( + PluginLoadError, + resolve_installed_adapter_class, + resolve_installed_model_base, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + +ENTRY_POINT = "class_valued_entry_point" + + +def _plugin_module(*, with_model: bool) -> types.ModuleType: + """One installed plugin distribution's adapter module.""" + module = types.ModuleType("class_valued_plugin") + + class PluginAdapter(Adapter): + type = "ClassValuedPlugin" + + PluginAdapter.__module__ = module.__name__ + module.PluginAdapter = PluginAdapter # ty: ignore[unresolved-attribute] + if with_model: + + class PluginModel(DiffSyncModel): + _modelname = "PluginModel" + _identifiers = ("name",) + name: str + + PluginModel.__module__ = module.__name__ + module.PluginModel = PluginModel # ty: ignore[unresolved-attribute] + return module + + +class _EntryPoint: + def __init__(self, name: str, target: object) -> None: + self.name = name + self._target = target + + def load(self) -> object: + return self._target + + +class _EntryPoints: + def __init__(self, entry_point: _EntryPoint) -> None: + self._entry_point = entry_point + + def select(self, *, group: str, name: str) -> tuple[_EntryPoint, ...]: + if group == "infrahub_sync.adapters" and name == self._entry_point.name: + return (self._entry_point,) + return () + + +def _publish(monkeypatch: pytest.MonkeyPatch, module: types.ModuleType, *, value: object) -> None: + """Publish `value` under the plugin entry-point group, with its module importable.""" + monkeypatch.setitem(sys.modules, module.__name__, module) + monkeypatch.setattr( + "infrahub_sync.plugin_loader.entry_points", lambda: _EntryPoints(_EntryPoint(ENTRY_POINT, value)) + ) + + +@pytest.fixture(name="adapter") +def _adapter() -> SyncAdapter: + return SyncAdapter(name="plugin", adapter=ENTRY_POINT) + + +def _instance(adapter: SyncAdapter) -> SyncInstance: + return SyncInstance( + name="entry-point-resolution", source=adapter, destination=SyncAdapter(name="infrahub"), directory="/none" + ) + + +def test_a_class_valued_entry_point_resolves_its_adapter_class( + monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter +) -> None: + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter) + + assert resolve_installed_adapter_class(adapter) is module.PluginAdapter + + +def test_a_class_valued_entry_point_resolves_the_model_base_from_its_module( + monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter +) -> None: + # The entry point names the adapter; the model base is the DiffSync model its + # defining module declares, not the adapter class itself. + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter) + + resolved = resolve_installed_model_base(adapter) + + assert resolved is module.PluginModel + assert resolved is not module.PluginAdapter + + +def test_a_class_valued_entry_point_without_a_model_refuses( + monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter +) -> None: + module = _plugin_module(with_model=False) + _publish(monkeypatch, module, value=module.PluginAdapter) + + with pytest.raises(PluginLoadError): + resolve_installed_model_base(adapter) + + +def test_a_model_valued_entry_point_resolves_the_adapter_from_its_module( + monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter +) -> None: + # The mirror case: whichever class the entry point names, the other one comes from + # the same module. + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginModel) + + assert resolve_installed_adapter_class(adapter) is module.PluginAdapter + assert resolve_installed_model_base(adapter) is module.PluginModel + + +def test_a_module_valued_entry_point_still_resolves_both(monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter) -> None: + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module) + + assert resolve_installed_adapter_class(adapter) is module.PluginAdapter + assert resolve_installed_model_base(adapter) is module.PluginModel + + +def test_an_entry_point_naming_an_unusable_object_refuses( + monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter +) -> None: + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=object()) + + with pytest.raises(PluginLoadError): + resolve_installed_adapter_class(adapter) + + +def test_a_class_valued_entry_point_reaches_a_runtime_model_plan( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The execution-level case: a plan built on a class-valued entry-point source.""" + from infrahub_sync.configuration import parse_configuration_package + from infrahub_sync.configuration.runtime import resolve_runtime_instance + from infrahub_sync.runtime_schema import build_runtime_model_plan + from infrahub_sync.runtime_schema import worker as worker_module + from tests.configuration.validation_packages import package_data + + snapshot = { + "BuiltinTag": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}}, + "relationships": {}, + } + } + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter) + monkeypatch.setattr(worker_module, "read_destination_schema_snapshot", lambda _package, _branch: snapshot) + monkeypatch.setenv("NETBOX_TOKEN", "entry-point-canary") + monkeypatch.setenv("INFRAHUB_API_TOKEN", "entry-point-canary") + content = package_data() + content["configuration"]["schema_mapping"] = [ + {"name": "BuiltinTag", "mapping": "extras.tags", "fields": [{"name": "name", "mapping": "name"}]} + ] + content["configuration"]["source"]["adapter"] = ENTRY_POINT + package = parse_configuration_package(content) + instance = resolve_runtime_instance(package, directory=str(tmp_path)) + + plan = build_runtime_model_plan(package=package, instance=instance, run_branch=None, scope="both") + + assert plan.source is not None + assert plan.source.adapter_class is module.PluginAdapter + assert issubclass(plan.source.models["BuiltinTag"], module.PluginModel) + + +@pytest.fixture(autouse=True) +def _no_module_leak() -> Iterator[None]: + yield + sys.modules.pop("class_valued_plugin", None) From 01ae678ad7a42cf20ba42c72e0a91a634fa2f573 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 00:07:25 -0400 Subject: [PATCH 20/27] Admit a registered dotted source only from an installed distribution Disabling filesystem resolution closed the explicit path and adapter-path arms, but a dotted spec still went through importlib, which searches sys.path and so reaches any module a checkout or the working directory makes importable. Give registered resolution a provenance rule instead: a dotted target is admitted when its top-level package is this distribution's own, or when installed distribution metadata reports an owning distribution. It is answered from metadata alone, before the import, so an uninstalled module is never even loaded. A third-party adapter installed editable reports no owner and is deliberately outside the registered profile. Restore the local path's fallback to the general loader at the same time: the earlier correction had routed it through installed-only resolution, which silently narrowed adapter-path, environment and filesystem resolution for the CLI. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/plugin_loader.py | 77 ++++++--- infrahub_sync/utils.py | 10 +- .../runtime_schema/test_dotted_provenance.py | 149 ++++++++++++++++++ .../test_registered_resolution.py | 44 ++++++ .../test_registered_source_declaration.py | 2 +- tests/runtime_schema/test_worker_path.py | 8 +- 6 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 tests/runtime_schema/test_dotted_provenance.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index 44e5aef6..b38fd7e5 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -20,7 +20,7 @@ import re import sys from enum import Enum -from importlib.metadata import entry_points +from importlib.metadata import entry_points, packages_distributions from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -36,6 +36,32 @@ class PluginLoadError(Exception): """Exception raised when a plugin cannot be loaded.""" +# This distribution's own package. Its bundled adapters ship with the product, so they +# are admitted whatever the install style reports — an editable or source checkout has no +# distribution metadata mapping `infrahub_sync` to a distribution at all. +BUNDLED_PACKAGE = "infrahub_sync" + + +def is_installed_distribution_module(spec_path: str) -> bool: + """Whether a dotted target belongs to code installed into this environment. + + The provenance rule registered resolution admits by: a dotted spec is admitted when + its top-level package is this distribution's own, or when installed distribution + metadata reports that package as owned by a distribution. Everything else — a module + that is importable only because a checkout or the working directory is on + ``sys.path``, and the standard library, which no distribution owns — is refused. + + Deliberately conservative at one edge: a third-party adapter installed in editable + mode reports no owning distribution either, so it sits outside the registered profile + until it is installed normally. Answered from metadata alone, so the verdict is + reached before the target is imported. + """ + top_level = spec_path.partition(".")[0] + if top_level == BUNDLED_PACKAGE: + return True + return bool(packages_distributions().get(top_level)) + + def _target_base_class(default_class_candidates: tuple[str, ...]) -> type[Any] | None: """The base class a resolution request is really asking for, if it names one.""" if "Adapter" in default_class_candidates: @@ -65,30 +91,32 @@ class PluginLoader: - Python entry points: group infrahub_sync.adapters """ - def __init__(self, adapter_paths: Iterable[str] | None = None, *, allow_filesystem: bool = True) -> None: + def __init__(self, adapter_paths: Iterable[str] | None = None, *, installed_only: bool = False) -> None: """ Initialize a new PluginLoader. Args: adapter_paths: Optional list of paths to search for adapters. - allow_filesystem: Whether filesystem resolution may run at all. False makes - the loader structurally incapable of loading a module from a path, an - adapter-path directory, or the working directory. + installed_only: Whether resolution is restricted to installed code. True + disables filesystem resolution outright and admits a dotted target only + when :func:`is_installed_distribution_module` owns it. """ self.adapter_paths = list(adapter_paths) if adapter_paths else [] - self.allow_filesystem = allow_filesystem + self.installed_only = installed_only self._cache: dict[str, tuple[type[Any], Plugintype]] = {} @classmethod - def installed_only(cls) -> PluginLoader: + def installed_only_loader(cls) -> PluginLoader: """Return a loader that resolves installed code and nothing else. - Dotted imports, entry points, and bundled adapter modules only: no configured - adapter paths, no ``INFRAHUB_SYNC_ADAPTER_PATHS``, and no working directory. This - is the loader registered execution resolves through, so an adapter that is not - installed in the worker's environment cannot enter a registered run. + Entry points, bundled adapter modules, and dotted targets an installed + distribution owns: no configured adapter paths, no + ``INFRAHUB_SYNC_ADAPTER_PATHS``, no working directory, and no module that is + importable only because a checkout is on ``sys.path``. This is the loader + registered execution resolves through, so an adapter that is not installed in + the worker's environment cannot enter a registered run. """ - return cls(adapter_paths=None, allow_filesystem=False) + return cls(adapter_paths=None, installed_only=True) @classmethod def from_env_and_args(cls, adapter_paths: Iterable[str] | None = None) -> PluginLoader: @@ -184,14 +212,14 @@ def resolve(self, spec: str, default_class_candidates: tuple[str, ...] = ("Adapt ) # 1. Dotted path (if it looks like one) - if is_dotted: + if is_dotted and (not self.installed_only or is_installed_distribution_module(spec_path)): cls = self._resolve_from_dotted_path(spec_path, class_name, default_class_candidates) if cls: self._cache[spec] = (cls, Plugintype.DOTTED_PATH) return cls # 2. Filesystem path (search adapter_paths and CWD) - if self.allow_filesystem: + if not self.installed_only: cls = self._resolve_from_filesystem( path=spec_path, class_name=class_name, default_class_candidates=default_class_candidates ) @@ -214,12 +242,17 @@ def resolve(self, spec: str, default_class_candidates: tuple[str, ...] = ("Adapt return cls # If we get here, we couldn't resolve the class - tried = ( - "dotted path, filesystem, entry point, and built-in" - if self.allow_filesystem - else ("dotted path, entry point, and built-in") + if not self.installed_only: + msg = ( + f"Could not resolve adapter class for spec '{spec}'. " + f"Tried dotted path, filesystem, entry point, and built-in resolution." + ) + raise PluginLoadError(msg) + msg = ( + f"Could not resolve adapter class for spec '{spec}' from installed code. " + f"Tried entry point and built-in resolution, and dotted import restricted to a " + f"top-level package owned by an installed distribution." ) - msg = f"Could not resolve adapter class for spec '{spec}'. Tried {tried} resolution." raise PluginLoadError(msg) def _resolve_from_dotted_path( @@ -531,7 +564,7 @@ def resolve_installed_adapter_class(adapter: SyncAdapter) -> type[Any]: """Resolve one side's adapter class from installed code only. The registered worker's resolution seam. It resolves through - :meth:`PluginLoader.installed_only`, so neither generated Python in the configuration + :meth:`PluginLoader.installed_only_loader`, so neither generated Python in the configuration directory nor any filesystem plugin — a configured adapter path, the ``INFRAHUB_SYNC_ADAPTER_PATHS`` environment, or the working directory — can enter a registered run. @@ -539,7 +572,7 @@ def resolve_installed_adapter_class(adapter: SyncAdapter) -> type[Any]: Raises: PluginLoadError: no installed class answers the declared adapter. """ - return PluginLoader.installed_only().resolve(adapter.adapter or adapter.name) + return PluginLoader.installed_only_loader().resolve(adapter.adapter or adapter.name) def resolve_installed_model_base(adapter: SyncAdapter) -> type[Any]: @@ -554,4 +587,4 @@ def resolve_installed_model_base(adapter: SyncAdapter) -> type[Any]: PluginLoadError: no installed class answers the declared adapter. """ spec = adapter.adapter.split(":")[0] if adapter.adapter else adapter.name - return PluginLoader.installed_only().resolve(spec, default_class_candidates=("Model",)) + return PluginLoader.installed_only_loader().resolve(spec, default_class_candidates=("Model",)) diff --git a/infrahub_sync/utils.py b/infrahub_sync/utils.py index f9803650..69826cf0 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -18,7 +18,7 @@ from infrahub_sync.plan.errors import PlanVerificationError from infrahub_sync.plan.reader import read_plan_artifact_bytes from infrahub_sync.plan.verify import destination_binding_failure -from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError, resolve_installed_adapter_class +from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError from infrahub_sync.potenda import Potenda from infrahub_sync.runtime_schema import RuntimeModelScopeError, bind_runtime_models @@ -107,10 +107,12 @@ def import_adapter(sync_instance: SyncInstance, adapter: SyncAdapter): except (ImportError, AttributeError, SyntaxError, TypeError, ValueError, OSError) as exc: logger.warning("Could not load generated adapter from %s: %s", adapter_file_path, exc) - # Fall back to installed resolution. - # The "sync" classes could be declared into a separate module + # Fall back to the general loader. The "sync" classes could be declared into a + # separate module, and this local path keeps the adapter-path, environment and + # filesystem resolution it has always had; only registered admission is narrowed. + loader = PluginLoader.from_env_and_args(adapter_paths=sync_instance.adapters_path or []) try: - return resolve_installed_adapter_class(adapter) + return loader.resolve(adapter.adapter or adapter.name) except PluginLoadError as exc: if adapter.adapter: msg = f"Failed to load adapter '{adapter.adapter}': {exc}" diff --git a/tests/runtime_schema/test_dotted_provenance.py b/tests/runtime_schema/test_dotted_provenance.py new file mode 100644 index 00000000..c12c2d21 --- /dev/null +++ b/tests/runtime_schema/test_dotted_provenance.py @@ -0,0 +1,149 @@ +"""R1: registered dotted resolution admits only installed-distribution modules. + +`sys.path` contains the working directory, so a dotted import alone reaches modules that +merely happen to be importable from a checkout. Registered admission answers a provenance +question instead — does an installed distribution own this top-level package — and it +answers it before importing anything. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from infrahub_sync import SyncAdapter +from infrahub_sync.plugin_loader import ( + PluginLoader, + PluginLoadError, + resolve_installed_adapter_class, + resolve_installed_model_base, +) + +CHECKOUT_MODULE = "tests.runtime_schema.installed_source_adapter" + + +def _seam(monkeypatch: pytest.MonkeyPatch, distributions: dict[str, list[str]]) -> None: + """Report exactly these top-level packages as owned by an installed distribution.""" + monkeypatch.setattr("infrahub_sync.plugin_loader.packages_distributions", lambda: distributions) + + +def test_an_uninstalled_checkout_module_refuses_even_though_it_imports() -> None: + # The module imports fine from this checkout; no installed distribution owns `tests`. + assert __import__(CHECKOUT_MODULE) + with pytest.raises(PluginLoadError, match="installed distribution"): + PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) + + +def test_a_refused_dotted_target_is_never_imported(monkeypatch: pytest.MonkeyPatch) -> None: + # Provenance is answered before the import, so an uninstalled module cannot run its + # top level as a side effect of being named. + _seam(monkeypatch, {}) + for name in list(sys.modules): + if name == CHECKOUT_MODULE: + monkeypatch.delitem(sys.modules, name) + + with pytest.raises(PluginLoadError): + PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) + + assert CHECKOUT_MODULE not in sys.modules + + +def test_the_same_module_resolves_once_a_distribution_owns_it(monkeypatch: pytest.MonkeyPatch) -> None: + # The controlled metadata seam is the only difference from the refusal above. + _seam(monkeypatch, {"tests": ["a-plugin-distribution"]}) + + resolved = PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) + + assert resolved.__name__ == "InstalledSourceAdapter" + + +def test_a_genuinely_installed_distribution_is_admitted_without_a_seam() -> None: + # No seam: `diffsync` really is installed, and its metadata says so. + resolved = PluginLoader.installed_only_loader().resolve("diffsync.store:BaseStore") + + assert resolved.__name__ == "BaseStore" + + +def test_the_bundled_package_stays_admitted_when_metadata_reports_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # An editable or source checkout of this project reports no distribution for its own + # package, and its bundled adapters must still resolve. + _seam(monkeypatch, {}) + + resolved = resolve_installed_adapter_class( + SyncAdapter(name="infrahub", adapter="infrahub_sync.adapters.infrahub:InfrahubAdapter") + ) + + assert resolved.__module__ == "infrahub_sync.adapters.infrahub" + + +def test_a_bundled_adapter_name_stays_admitted(monkeypatch: pytest.MonkeyPatch) -> None: + _seam(monkeypatch, {}) + + assert resolve_installed_adapter_class(SyncAdapter(name="infrahub")).__name__ == "InfrahubAdapter" + assert resolve_installed_model_base(SyncAdapter(name="infrahub")).__name__ == "InfrahubModel" + + +def test_a_model_base_from_an_uninstalled_dotted_module_refuses(monkeypatch: pytest.MonkeyPatch) -> None: + _seam(monkeypatch, {}) + + with pytest.raises(PluginLoadError): + resolve_installed_model_base(SyncAdapter(name="plugin", adapter=f"{CHECKOUT_MODULE}:InstalledSourceAdapter")) + + +def test_the_legacy_loader_still_resolves_a_checkout_module() -> None: + # Provenance is a registered-admission rule; the local CLI path is unchanged. + resolved = PluginLoader().resolve(CHECKOUT_MODULE) + + assert resolved.__name__ == "InstalledSourceAdapter" + + +@pytest.mark.parametrize( + ("spec", "admitted"), + [ + pytest.param("diffsync.store", True, id="installed-distribution"), + pytest.param("infrahub_sync.adapters.infrahub", True, id="bundled-package"), + pytest.param("tests.runtime_schema.installed_source_adapter", False, id="checkout-module"), + pytest.param("os.path", False, id="standard-library"), + ], +) +def test_the_provenance_rule_answers_by_top_level_package(spec: str, *, admitted: bool) -> None: + # Stated as a property over the top-level package, not a list of path examples. + from infrahub_sync.plugin_loader import is_installed_distribution_module + + assert is_installed_distribution_module(spec) is admitted + + +def test_a_registered_run_refuses_a_checkout_dotted_source(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The execution-level case: admission, not just the resolver, closes the hole.""" + from infrahub_sync.configuration import parse_configuration_package + from infrahub_sync.configuration.runtime import resolve_runtime_instance + from infrahub_sync.runtime_schema import build_runtime_model_plan + from infrahub_sync.runtime_schema import worker as worker_module + from tests.configuration.validation_packages import package_data + + snapshot = { + "BuiltinTag": { + "human_friendly_id": ["name__value"], + "uniqueness_constraints": [["name__value"]], + "attributes": {"name": {"kind": "Text", "optional": False, "default_value": None, "unique": True}}, + "relationships": {}, + } + } + monkeypatch.setattr(worker_module, "read_destination_schema_snapshot", lambda _package, _branch: snapshot) + monkeypatch.setenv("NETBOX_TOKEN", "provenance-canary") + monkeypatch.setenv("INFRAHUB_API_TOKEN", "provenance-canary") + content = package_data() + content["configuration"]["schema_mapping"] = [ + {"name": "BuiltinTag", "mapping": "extras.tags", "fields": [{"name": "name", "mapping": "name"}]} + ] + # A syntactically admitted dotted spec that no installed distribution owns. + content["configuration"]["source"]["adapter"] = CHECKOUT_MODULE + package = parse_configuration_package(content) + instance = resolve_runtime_instance(package, directory=str(tmp_path)) + + with pytest.raises(PluginLoadError, match="installed distribution"): + build_runtime_model_plan(package=package, instance=instance, run_branch=None, scope="both") diff --git a/tests/runtime_schema/test_registered_resolution.py b/tests/runtime_schema/test_registered_resolution.py index 1c838197..5c9c9e0e 100644 --- a/tests/runtime_schema/test_registered_resolution.py +++ b/tests/runtime_schema/test_registered_resolution.py @@ -12,6 +12,7 @@ resolve_installed_adapter_class, resolve_installed_model_base, ) +from infrahub_sync.utils import import_adapter _ADAPTER_SOURCE = """ from diffsync import Adapter, DiffSyncModel @@ -118,3 +119,46 @@ def test_a_dotted_installed_adapter_still_resolves() -> None: resolved = resolve_installed_adapter_class(instance.source) assert resolved.__name__ == "InfrahubAdapter" + + +# --- the legacy local path keeps the resolution it had ----------------------------------- + + +def test_the_legacy_path_still_resolves_a_configured_adapter_path(sideloaded: Path) -> None: + # Registered admission is what narrowed; `import_adapter` serves the local CLI, whose + # adapters_path and environment resolution must keep working until it is removed. + instance = _instance("sideloaded", adapters_path=[str(sideloaded)]) + + resolved = import_adapter(sync_instance=instance, adapter=instance.source) + + assert resolved is not None + assert resolved.__name__ == "SideloadedAdapter" + + +def test_the_legacy_path_still_resolves_an_adapter_paths_environment_plugin( + sideloaded: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("INFRAHUB_SYNC_ADAPTER_PATHS", str(sideloaded)) + instance = _instance("sideloaded") + + resolved = import_adapter(sync_instance=instance, adapter=instance.source) + + assert resolved is not None + assert resolved.__name__ == "SideloadedAdapter" + + +def test_the_legacy_path_still_resolves_an_explicit_filesystem_spec() -> None: + instance = SyncInstance( + name="registered-resolution", + source=SyncAdapter( + name="mockdb", + adapter="./examples/custom_adapter/custom_adapter_src/custom_adapter.py:MockdbAdapter", + ), + destination=SyncAdapter(name="infrahub"), + directory="/nonexistent", + ) + + resolved = import_adapter(sync_instance=instance, adapter=instance.source) + + assert resolved is not None + assert resolved.__name__ == "MockdbAdapter" diff --git a/tests/runtime_schema/test_registered_source_declaration.py b/tests/runtime_schema/test_registered_source_declaration.py index b19a4cfb..a5287335 100644 --- a/tests/runtime_schema/test_registered_source_declaration.py +++ b/tests/runtime_schema/test_registered_source_declaration.py @@ -137,6 +137,6 @@ def test_every_admitted_declaration_resolves_through_installed_only_loading( declared = package.configuration.source.adapter assert declared is not None - resolved = PluginLoader.installed_only().resolve(declared) + resolved = PluginLoader.installed_only_loader().resolve(declared) assert resolved is registered_entry_point diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index 249bf79b..26ace376 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -411,9 +411,15 @@ def test_a_non_bundled_installed_source_with_an_infrahub_destination_may_execute spy: _SnapshotSpy, tmp_path: Path, source_adapter: str, monkeypatch: pytest.MonkeyPatch ) -> None: # Admitted, not qualified: an installed dotted or entry-point source runs, while a - # filesystem declaration never crosses registered admission at all. + # filesystem declaration never crosses registered admission at all. The dotted cases + # need this module to look like installed code, because registered resolution admits + # a dotted target only when a distribution owns its top-level package. from tests.runtime_schema.installed_source_adapter import InstalledSourceAdapter, InstalledSourceModel + monkeypatch.setattr( + "infrahub_sync.plugin_loader.packages_distributions", + lambda: {"tests": ["a-plugin-distribution"]}, + ) monkeypatch.setattr( "infrahub_sync.plugin_loader.entry_points", lambda: _EntryPoints( From 085b61454a73f32cee6511f94f07144f7938ec07 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 00:35:31 -0400 Subject: [PATCH 21/27] Bind installed source resolution to reviewed code Require registered dotted modules to resolve to files shipped by an installed distribution, and honor explicit entry-point class names exactly for both adapter and model resolution. This combines the dependent provenance and entry-point corrections into one green, bisectable unit. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/plugin_loader.py | 143 +++++++++++--- .../runtime_schema/installed_distribution.py | 80 ++++++++ .../runtime_schema/test_dotted_provenance.py | 187 ++++++++++++------ .../test_entry_point_resolution.py | 70 +++++++ .../test_registered_execution.py | 110 ++++++++++- tests/runtime_schema/test_worker_path.py | 49 ++--- 6 files changed, 526 insertions(+), 113 deletions(-) create mode 100644 tests/runtime_schema/installed_distribution.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index b38fd7e5..265fd227 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -20,14 +20,14 @@ import re import sys from enum import Enum -from importlib.metadata import entry_points, packages_distributions +from importlib.metadata import distributions, entry_points from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, NoReturn, cast from diffsync import Adapter, DiffSyncModel if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence from infrahub_sync import SyncAdapter @@ -42,24 +42,98 @@ class PluginLoadError(Exception): BUNDLED_PACKAGE = "infrahub_sync" +def installed_module_origins(spec_path: str) -> set[Path]: + """Every location an installed distribution ships this exact dotted module at. + + Read from distribution file metadata, so it names the installed copy rather than + whatever happens to answer the name on ``sys.path``. + """ + relative = spec_path.replace(".", "/") + wanted = {f"{relative}.py", f"{relative}/__init__.py"} + origins: set[Path] = set() + for distribution in distributions(): + for entry in distribution.files or (): + if str(entry).replace("\\", "/") in wanted: + origins.add(Path(str(distribution.locate_file(entry)))) + return origins + + +def _provides_top_level(base: Path, name: str) -> bool: + """Whether this import-path entry answers a top-level name at all.""" + return (base / name / "__init__.py").is_file() or (base / f"{name}.py").is_file() + + +def _module_file(base: Path, parts: Sequence[str]) -> Path | None: + """The file this import-path entry would supply for a dotted name, or None.""" + current = base + for index, part in enumerate(parts): + package_init = current / part / "__init__.py" + if index == len(parts) - 1: + # Packages win over same-named modules, as the import system orders them. + if package_init.is_file(): + return package_init + module = current / f"{part}.py" + return module if module.is_file() else None + if not package_init.is_file(): + return None + current /= part + return None + + +def effective_module_origin(spec_path: str) -> Path | None: + """The file an import of this dotted name would load, found without importing it. + + Walks ``sys.path`` in order, the way the path finder does. The first entry that + answers the top-level name decides the answer even when it does not supply the + submodule, because that entry shadows every later one — which is exactly the case a + checkout creates over an installed distribution. Anything this cannot resolve — a zip + import, a namespace package, a custom finder — returns None and is refused. + """ + parts = spec_path.split(".") + for entry in sys.path: + base = Path(entry) if entry else Path.cwd() + origin = _module_file(base, parts) + if origin is not None: + return origin + if _provides_top_level(base, parts[0]): + return None + return None + + def is_installed_distribution_module(spec_path: str) -> bool: - """Whether a dotted target belongs to code installed into this environment. + """Whether a dotted target is the module an installed distribution actually ships. - The provenance rule registered resolution admits by: a dotted spec is admitted when - its top-level package is this distribution's own, or when installed distribution - metadata reports that package as owned by a distribution. Everything else — a module - that is importable only because a checkout or the working directory is on - ``sys.path``, and the standard library, which no distribution owns — is refused. + The provenance rule registered resolution admits by. A dotted spec is admitted when + its top-level package is this distribution's own, or when the file an import would + load is exactly a file some installed distribution ships. Owning the top-level name + is not enough: a checkout module that shadows an installed package answers the name + from a different file, and is refused. Deliberately conservative at one edge: a third-party adapter installed in editable - mode reports no owning distribution either, so it sits outside the registered profile - until it is installed normally. Answered from metadata alone, so the verdict is - reached before the target is imported. + mode ships no module files in its metadata either, so it sits outside the registered + profile until it is installed normally. Answered from distribution metadata and the + filesystem, so the verdict is reached without importing the candidate. """ - top_level = spec_path.partition(".")[0] - if top_level == BUNDLED_PACKAGE: + if spec_path.partition(".")[0] == BUNDLED_PACKAGE: return True - return bool(packages_distributions().get(top_level)) + origins = installed_module_origins(spec_path) + if not origins: + return False + effective = effective_module_origin(spec_path) + if effective is None: + return False + resolved = effective.resolve() + return any(origin.resolve() == resolved for origin in origins) + + +def _raise_declared_class_unavailable( + name: str, class_name: str, default_class_candidates: tuple[str, ...] +) -> NoReturn: + """Refuse a declaration whose named class the entry point's module cannot supply.""" + target = _target_base_class(default_class_candidates) + required = "" if target is None else f" as a {target.__name__} subclass" + msg = f"Entry point '{name}' does not declare a class named '{class_name}'{required}." + raise PluginLoadError(msg) def _target_base_class(default_class_candidates: tuple[str, ...]) -> type[Any] | None: @@ -376,7 +450,10 @@ def _resolve_from_entry_point( # If it's a module, find the class if inspect.ismodule(obj): - return self._find_class_in_module(obj, class_name, name, default_class_candidates) + resolved = self._find_class_in_module(obj, class_name, name, default_class_candidates) + if resolved is None and class_name is not None: + _raise_declared_class_unavailable(name, class_name, default_class_candidates) + return resolved if inspect.isclass(obj): return self._entry_point_class(cast("type[Any]", obj), class_name, name, default_class_candidates) @@ -398,14 +475,24 @@ def _entry_point_class( adapter class. That one entry point has to answer both questions the loader asks, so a loaded class that is not what was requested is resolved from the module that defines it — the same module a module-valued entry point would have named. + + A declaration carrying an explicit ``:ClassName`` is answered by that name and no + other, because the declared name is what the package checksum covers: resolving + some other class would let the executed identity differ from the reviewed one. """ target = _target_base_class(default_class_candidates) - if target is None or issubclass(loaded, target): + satisfies = target is None or issubclass(loaded, target) + if satisfies and (class_name is None or loaded.__name__ == class_name): return loaded defining_module = sys.modules.get(loaded.__module__) - if defining_module is None: - return None - return self._find_class_in_module(defining_module, class_name, name, default_class_candidates) + resolved = ( + None + if defining_module is None + else self._find_class_in_module(defining_module, class_name, name, default_class_candidates) + ) + if resolved is None and class_name is not None: + _raise_declared_class_unavailable(name, class_name, default_class_candidates) + return resolved def _resolve_from_builtin( self, name: str, class_name: str | None, default_class_candidates: tuple[str, ...] @@ -503,13 +590,17 @@ def _find_class_in_module( Returns: The found class, or None if not found. """ - # If class name is specified, look for it directly + # If class name is specified, look for it directly. It has to be a class, and it + # has to be the kind of class the caller asked for: a declaration naming the + # model where an adapter is required is an error, not a hint to look elsewhere. if class_name: - if hasattr(module, class_name): - cls = getattr(module, class_name) - if inspect.isclass(cls): - return cls - return None + cls = getattr(module, class_name, None) + if not inspect.isclass(cls): + return None + target = _target_base_class(default_class_candidates) + if target is not None and not issubclass(cls, target): + return None + return cls # Get all classes defined in the module classes_in_module = [ diff --git a/tests/runtime_schema/installed_distribution.py b/tests/runtime_schema/installed_distribution.py new file mode 100644 index 00000000..f389ab7c --- /dev/null +++ b/tests/runtime_schema/installed_distribution.py @@ -0,0 +1,80 @@ +"""Simulate an installed distribution: real files on the import path, real metadata. + +Registered admission binds a dotted target's origin to the files a distribution actually +ships, so a test that only claims ownership proves nothing. These helpers lay a module +out the way an install does — inside a site-packages-shaped directory that is on +``sys.path`` — and publish metadata whose file list locates it there. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pytest + +ADAPTER_SOURCE = ''' +from diffsync import Adapter, DiffSyncModel + +from infrahub_sync import DiffSyncMixin, DiffSyncModelMixin + + +class WheelModel(DiffSyncModelMixin, DiffSyncModel): + """The model base a runtime-built class derives from.""" + + +class WheelAdapter(DiffSyncMixin, Adapter): + """The adapter class registered resolution loads.""" + + type = "Wheel" +''' + + +@dataclass(frozen=True, slots=True) +class _FakeDistribution: + """The two parts of distribution metadata provenance reads.""" + + root: Path + relative_files: tuple[str, ...] + + @property + def files(self) -> tuple[Path, ...]: + return tuple(Path(name) for name in self.relative_files) + + def locate_file(self, path: str | Path) -> Path: + return self.root / path + + +def install_distribution( # noqa: PLR0913 - one call describes a whole install layout + monkeypatch: pytest.MonkeyPatch, + *, + site_packages: Path, + package: str, + module: str, + source: str = ADAPTER_SOURCE, + on_import_path: bool = True, + claimed_root: Path | None = None, +) -> str: + """Lay `package/module.py` out under `site_packages` and publish its metadata. + + Returns the dotted name. `claimed_root` overrides where the metadata says the files + live, which is how a shadowing case is built: the claim points at the installed copy + while `sys.path` reaches a different one first. + """ + import sys + + directory = site_packages / package + directory.mkdir(parents=True, exist_ok=True) + (directory / "__init__.py").write_text("", encoding="utf-8") + (directory / f"{module}.py").write_text(source, encoding="utf-8") + relative = (f"{package}/__init__.py", f"{package}/{module}.py") + distribution = _FakeDistribution(root=claimed_root or site_packages, relative_files=relative) + monkeypatch.setattr("infrahub_sync.plugin_loader.distributions", lambda: [distribution]) + if on_import_path: + monkeypatch.syspath_prepend(str(site_packages)) + for name in list(sys.modules): + if name == package or name.startswith(f"{package}."): + monkeypatch.delitem(sys.modules, name) + return f"{package}.{module}" diff --git a/tests/runtime_schema/test_dotted_provenance.py b/tests/runtime_schema/test_dotted_provenance.py index c12c2d21..e36e2b7f 100644 --- a/tests/runtime_schema/test_dotted_provenance.py +++ b/tests/runtime_schema/test_dotted_provenance.py @@ -1,9 +1,10 @@ -"""R1: registered dotted resolution admits only installed-distribution modules. +"""R1: registered dotted resolution binds a module's origin to an installed distribution. -`sys.path` contains the working directory, so a dotted import alone reaches modules that -merely happen to be importable from a checkout. Registered admission answers a provenance -question instead — does an installed distribution own this top-level package — and it -answers it before importing anything. +`sys.path` contains the working directory, so a dotted import alone reaches whatever a +checkout makes importable — including a module that shadows a name an installed +distribution owns. Registered admission answers a provenance question instead: is the +file the import system would load one an installed distribution actually ships? It is +answered from distribution metadata and the filesystem, without importing the candidate. """ from __future__ import annotations @@ -17,104 +18,163 @@ from infrahub_sync.plugin_loader import ( PluginLoader, PluginLoadError, + installed_module_origins, + is_installed_distribution_module, resolve_installed_adapter_class, resolve_installed_model_base, ) +from tests.runtime_schema.installed_distribution import install_distribution CHECKOUT_MODULE = "tests.runtime_schema.installed_source_adapter" -def _seam(monkeypatch: pytest.MonkeyPatch, distributions: dict[str, list[str]]) -> None: - """Report exactly these top-level packages as owned by an installed distribution.""" - monkeypatch.setattr("infrahub_sync.plugin_loader.packages_distributions", lambda: distributions) +def _no_distributions(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("infrahub_sync.plugin_loader.distributions", list) -def test_an_uninstalled_checkout_module_refuses_even_though_it_imports() -> None: - # The module imports fine from this checkout; no installed distribution owns `tests`. - assert __import__(CHECKOUT_MODULE) - with pytest.raises(PluginLoadError, match="installed distribution"): - PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) - +# --- the property, over real installed metadata ------------------------------------------ -def test_a_refused_dotted_target_is_never_imported(monkeypatch: pytest.MonkeyPatch) -> None: - # Provenance is answered before the import, so an uninstalled module cannot run its - # top level as a side effect of being named. - _seam(monkeypatch, {}) - for name in list(sys.modules): - if name == CHECKOUT_MODULE: - monkeypatch.delitem(sys.modules, name) - with pytest.raises(PluginLoadError): - PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) +@pytest.mark.parametrize( + ("spec", "admitted"), + [ + pytest.param("diffsync.store", True, id="installed-distribution-package"), + pytest.param("diffsync.enum", True, id="installed-distribution-module"), + pytest.param("infrahub_sync.adapters.infrahub", True, id="bundled-package"), + pytest.param(CHECKOUT_MODULE, False, id="checkout-module"), + pytest.param("os.path", False, id="standard-library"), + pytest.param("nowhere.at.all", False, id="absent"), + ], +) +def test_the_provenance_rule_answers_from_real_installed_metadata(spec: str, *, admitted: bool) -> None: + assert is_installed_distribution_module(spec) is admitted - assert CHECKOUT_MODULE not in sys.modules +def test_an_installed_distribution_reports_the_exact_file_it_ships() -> None: + origins = installed_module_origins("diffsync.enum") -def test_the_same_module_resolves_once_a_distribution_owns_it(monkeypatch: pytest.MonkeyPatch) -> None: - # The controlled metadata seam is the only difference from the refusal above. - _seam(monkeypatch, {"tests": ["a-plugin-distribution"]}) + assert origins + assert all(origin.name == "enum.py" and origin.parent.name == "diffsync" for origin in origins) - resolved = PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) - assert resolved.__name__ == "InstalledSourceAdapter" +# --- acceptance: a module laid out and claimed the way an install lays it out ------------- -def test_a_genuinely_installed_distribution_is_admitted_without_a_seam() -> None: - # No seam: `diffsync` really is installed, and its metadata says so. - resolved = PluginLoader.installed_only_loader().resolve("diffsync.store:BaseStore") +def test_an_installed_module_on_the_import_path_is_admitted(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + dotted = install_distribution( + monkeypatch, site_packages=tmp_path / "site-packages", package="wheelpkg", module="adapter" + ) - assert resolved.__name__ == "BaseStore" + assert is_installed_distribution_module(dotted) is True + assert PluginLoader.installed_only_loader().resolve(dotted).__name__ == "WheelAdapter" def test_the_bundled_package_stays_admitted_when_metadata_reports_nothing( monkeypatch: pytest.MonkeyPatch, ) -> None: - # An editable or source checkout of this project reports no distribution for its own - # package, and its bundled adapters must still resolve. - _seam(monkeypatch, {}) + # An editable or source checkout of this project ships no module files in its own + # metadata, and its bundled adapters must still resolve. + _no_distributions(monkeypatch) + + assert ( + resolve_installed_adapter_class( + SyncAdapter(name="infrahub", adapter="infrahub_sync.adapters.infrahub:InfrahubAdapter") + ).__module__ + == "infrahub_sync.adapters.infrahub" + ) + assert resolve_installed_adapter_class(SyncAdapter(name="infrahub")).__name__ == "InfrahubAdapter" + assert resolve_installed_model_base(SyncAdapter(name="infrahub")).__name__ == "InfrahubModel" + + +# --- refusal: shadowing, and modules no distribution ships ------------------------------- - resolved = resolve_installed_adapter_class( - SyncAdapter(name="infrahub", adapter="infrahub_sync.adapters.infrahub:InfrahubAdapter") + +def test_a_local_module_shadowing_an_installed_distribution_refuses( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # The distribution really ships `shadowpkg/adapter.py`, but a checkout earlier on + # sys.path provides its own. Ownership of the name is not ownership of the module. + installed = tmp_path / "site-packages" + shadow = tmp_path / "checkout" + dotted = install_distribution( + monkeypatch, site_packages=installed, package="shadowpkg", module="adapter", on_import_path=False ) + (shadow / "shadowpkg").mkdir(parents=True) + (shadow / "shadowpkg" / "__init__.py").write_text("", encoding="utf-8") + (shadow / "shadowpkg" / "adapter.py").write_text( + "raise AssertionError('a shadowing module was imported')\n", encoding="utf-8" + ) + monkeypatch.syspath_prepend(str(installed)) + monkeypatch.syspath_prepend(str(shadow)) - assert resolved.__module__ == "infrahub_sync.adapters.infrahub" + assert is_installed_distribution_module(dotted) is False + with pytest.raises(PluginLoadError, match="installed distribution"): + PluginLoader.installed_only_loader().resolve(dotted) + assert dotted not in sys.modules -def test_a_bundled_adapter_name_stays_admitted(monkeypatch: pytest.MonkeyPatch) -> None: - _seam(monkeypatch, {}) +def test_a_shadowing_package_refuses_even_without_the_shadowed_submodule( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # A checkout package that merely occupies the name still shadows the installed copy, + # so admission must not fall through to the installed file behind it. + installed = tmp_path / "site-packages" + shadow = tmp_path / "checkout" + dotted = install_distribution( + monkeypatch, site_packages=installed, package="partialpkg", module="adapter", on_import_path=False + ) + (shadow / "partialpkg").mkdir(parents=True) + (shadow / "partialpkg" / "__init__.py").write_text("", encoding="utf-8") + monkeypatch.syspath_prepend(str(installed)) + monkeypatch.syspath_prepend(str(shadow)) - assert resolve_installed_adapter_class(SyncAdapter(name="infrahub")).__name__ == "InfrahubAdapter" - assert resolve_installed_model_base(SyncAdapter(name="infrahub")).__name__ == "InfrahubModel" + assert is_installed_distribution_module(dotted) is False -def test_a_model_base_from_an_uninstalled_dotted_module_refuses(monkeypatch: pytest.MonkeyPatch) -> None: - _seam(monkeypatch, {}) +def test_an_uninstalled_checkout_module_refuses_even_though_it_imports() -> None: + assert __import__(CHECKOUT_MODULE) + with pytest.raises(PluginLoadError, match="installed distribution"): + PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) + + +def test_a_refused_dotted_target_is_never_imported(monkeypatch: pytest.MonkeyPatch) -> None: + _no_distributions(monkeypatch) + for name in list(sys.modules): + if name == CHECKOUT_MODULE: + monkeypatch.delitem(sys.modules, name) with pytest.raises(PluginLoadError): - resolve_installed_model_base(SyncAdapter(name="plugin", adapter=f"{CHECKOUT_MODULE}:InstalledSourceAdapter")) + PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) + assert CHECKOUT_MODULE not in sys.modules -def test_the_legacy_loader_still_resolves_a_checkout_module() -> None: - # Provenance is a registered-admission rule; the local CLI path is unchanged. - resolved = PluginLoader().resolve(CHECKOUT_MODULE) - assert resolved.__name__ == "InstalledSourceAdapter" +def test_owning_the_top_level_name_without_shipping_the_module_refuses( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # A distribution that owns the top-level name but ships some other module is not + # proof of this module's provenance: ownership of a name is not ownership of a file. + install_distribution( + monkeypatch, + site_packages=tmp_path / "site-packages", + package="tests", + module="something_else", + on_import_path=False, + ) + + assert is_installed_distribution_module(CHECKOUT_MODULE) is False -@pytest.mark.parametrize( - ("spec", "admitted"), - [ - pytest.param("diffsync.store", True, id="installed-distribution"), - pytest.param("infrahub_sync.adapters.infrahub", True, id="bundled-package"), - pytest.param("tests.runtime_schema.installed_source_adapter", False, id="checkout-module"), - pytest.param("os.path", False, id="standard-library"), - ], -) -def test_the_provenance_rule_answers_by_top_level_package(spec: str, *, admitted: bool) -> None: - # Stated as a property over the top-level package, not a list of path examples. - from infrahub_sync.plugin_loader import is_installed_distribution_module +def test_a_model_base_from_an_uninstalled_dotted_module_refuses(monkeypatch: pytest.MonkeyPatch) -> None: + _no_distributions(monkeypatch) - assert is_installed_distribution_module(spec) is admitted + with pytest.raises(PluginLoadError): + resolve_installed_model_base(SyncAdapter(name="plugin", adapter=f"{CHECKOUT_MODULE}:InstalledSourceAdapter")) + + +def test_the_legacy_loader_still_resolves_a_checkout_module() -> None: + # Provenance is a registered-admission rule; the local CLI path is unchanged. + assert PluginLoader().resolve(CHECKOUT_MODULE).__name__ == "InstalledSourceAdapter" def test_a_registered_run_refuses_a_checkout_dotted_source(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -140,7 +200,6 @@ def test_a_registered_run_refuses_a_checkout_dotted_source(monkeypatch: pytest.M content["configuration"]["schema_mapping"] = [ {"name": "BuiltinTag", "mapping": "extras.tags", "fields": [{"name": "name", "mapping": "name"}]} ] - # A syntactically admitted dotted spec that no installed distribution owns. content["configuration"]["source"]["adapter"] = CHECKOUT_MODULE package = parse_configuration_package(content) instance = resolve_runtime_instance(package, directory=str(tmp_path)) diff --git a/tests/runtime_schema/test_entry_point_resolution.py b/tests/runtime_schema/test_entry_point_resolution.py index 535f2939..0f24d0f4 100644 --- a/tests/runtime_schema/test_entry_point_resolution.py +++ b/tests/runtime_schema/test_entry_point_resolution.py @@ -140,6 +140,76 @@ def test_a_module_valued_entry_point_still_resolves_both(monkeypatch: pytest.Mon assert resolve_installed_model_base(adapter) is module.PluginModel +# --- an explicit :ClassName is the declaration, and it is honoured exactly --------------- + + +@pytest.mark.parametrize("value", ["class", "module"], ids=["class-valued", "module-valued"]) +def test_an_explicit_class_name_resolves_that_exact_class(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter if value == "class" else module) + adapter = SyncAdapter(name="plugin", adapter=f"{ENTRY_POINT}:PluginAdapter") + + assert resolve_installed_adapter_class(adapter) is module.PluginAdapter + + +@pytest.mark.parametrize("value", ["class", "module"], ids=["class-valued", "module-valued"]) +def test_an_explicit_class_name_the_distribution_does_not_declare_refuses( + monkeypatch: pytest.MonkeyPatch, value: str +) -> None: + # The declared class is checksummed into the package, so resolving a different one + # would let the executed identity differ from the reviewed one. + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter if value == "class" else module) + adapter = SyncAdapter(name="plugin", adapter=f"{ENTRY_POINT}:DefinitelyNotTheAdapter") + + with pytest.raises(PluginLoadError, match="DefinitelyNotTheAdapter"): + resolve_installed_adapter_class(adapter) + + +@pytest.mark.parametrize("value", ["class", "module"], ids=["class-valued", "module-valued"]) +def test_an_explicit_class_name_of_the_wrong_base_refuses(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + # Naming the model where an adapter is required is a declaration error, not a hint + # to go looking for the adapter. + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter if value == "class" else module) + adapter = SyncAdapter(name="plugin", adapter=f"{ENTRY_POINT}:PluginModel") + + with pytest.raises(PluginLoadError, match="PluginModel"): + resolve_installed_adapter_class(adapter) + + +def test_an_explicit_class_name_that_is_not_a_class_refuses(monkeypatch: pytest.MonkeyPatch) -> None: + module = _plugin_module(with_model=True) + module.PluginAdapterFactory = lambda: None # ty: ignore[unresolved-attribute] + _publish(monkeypatch, module, value=module.PluginAdapter) + adapter = SyncAdapter(name="plugin", adapter=f"{ENTRY_POINT}:PluginAdapterFactory") + + with pytest.raises(PluginLoadError, match="PluginAdapterFactory"): + resolve_installed_adapter_class(adapter) + + +def test_an_explicit_class_name_still_resolves_the_model_base_by_kind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The model base is resolved from the module half of the spec, so naming the adapter + # class does not make the model base the adapter. + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter) + adapter = SyncAdapter(name="plugin", adapter=f"{ENTRY_POINT}:PluginAdapter") + + assert resolve_installed_model_base(adapter) is module.PluginModel + + +def test_without_an_explicit_name_the_requested_base_still_decides( + monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter +) -> None: + module = _plugin_module(with_model=True) + _publish(monkeypatch, module, value=module.PluginAdapter) + + assert resolve_installed_adapter_class(adapter) is module.PluginAdapter + assert resolve_installed_model_base(adapter) is module.PluginModel + + def test_an_entry_point_naming_an_unusable_object_refuses( monkeypatch: pytest.MonkeyPatch, adapter: SyncAdapter ) -> None: diff --git a/tests/runtime_schema/test_registered_execution.py b/tests/runtime_schema/test_registered_execution.py index 15951c3d..b7ec3546 100644 --- a/tests/runtime_schema/test_registered_execution.py +++ b/tests/runtime_schema/test_registered_execution.py @@ -22,6 +22,7 @@ from infrahub_sync.configuration.runtime import resolve_runtime_instance from infrahub_sync.execution import execute_run from infrahub_sync.plan.review import SavedPlan +from infrahub_sync.plugin_loader import PluginLoadError from infrahub_sync.runtime_schema import build_runtime_model_plan from infrahub_sync.runtime_schema import worker as worker_module from infrahub_sync.utils import get_potenda_from_instance @@ -178,12 +179,20 @@ def _infrahub_client(address: str, config: object) -> _InfrahubClient: _forget_netbox_adapter() -def _registered_instance(tmp_path: Path, *, branch: str | None = None, run_branch: str | None = None) -> SyncInstance: +def _registered_instance( + tmp_path: Path, + *, + branch: str | None = None, + run_branch: str | None = None, + source_adapter: str | None = None, +) -> SyncInstance: """One registered package resolved and prepared exactly as the worker prepares it.""" content = package_data() content["configuration"]["schema_mapping"] = MAPPING if branch is not None: content["configuration"]["destination"]["settings"]["branch"] = branch + if source_adapter is not None: + content["configuration"]["source"]["adapter"] = source_adapter package: ConfigurationPackage = parse_configuration_package(content) instance = resolve_runtime_instance(package, directory=str(tmp_path / "config")) (tmp_path / "config").mkdir(exist_ok=True) @@ -276,3 +285,102 @@ def test_the_constructed_destination_receives_the_effective_branch( assert destination.destination_binding.branch == expected assert destination.client.config.default_branch == expected assert destination.client.schema.branches == [expected] + + +# --- R2: the declared class is the class the engine runs --------------------------------- + + +class _EntryPoint: + def __init__(self, name: str, target: object) -> None: + self.name = name + self._target = target + + def load(self) -> object: + return self._target + + +class _EntryPoints: + """The packaging metadata one installed plugin distribution publishes.""" + + def __init__(self, entry_point: _EntryPoint) -> None: + self._entry_point = entry_point + + def select(self, *, group: str, name: str) -> tuple[_EntryPoint, ...]: + if group == "infrahub_sync.adapters" and name == self._entry_point.name: + return (self._entry_point,) + return () + + +def _publish_entry_point(monkeypatch: pytest.MonkeyPatch, target: object) -> str: + monkeypatch.setattr( + "infrahub_sync.plugin_loader.entry_points", lambda: _EntryPoints(_EntryPoint("plugin_source", target)) + ) + return "plugin_source" + + +def _plugin_source_module() -> types.ModuleType: + """An installed plugin's adapter module, with a second adapter to name wrongly.""" + module = types.ModuleType("declared_identity_plugin") + source = """ +from diffsync import Adapter, DiffSyncModel + +from infrahub_sync import DiffSyncMixin, DiffSyncModelMixin + + +class PluginModel(DiffSyncModelMixin, DiffSyncModel): + pass + + +class _Base(DiffSyncMixin, Adapter): + def __init__(self, target, adapter, config, **kwargs): + super().__init__(**kwargs) + self.target = target + self.config = config + + def model_loader(self, model_name, model): + return None + + +class PluginAdapter(_Base): + type = "Plugin" + + +class OtherAdapter(_Base): + type = "Other" +""" + exec(compile(source, module.__name__, "exec"), module.__dict__) # noqa: S102 + for name in ("PluginModel", "_Base", "PluginAdapter", "OtherAdapter"): + getattr(module, name).__module__ = module.__name__ + return module + + +def test_the_declared_source_class_is_the_class_the_engine_constructs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The declared `:ClassName` is inside the package checksum, so the engine must run + # that class and not whichever one the entry point happens to load. + module = _plugin_source_module() + monkeypatch.setitem(sys.modules, module.__name__, module) + entry_point = _publish_entry_point(monkeypatch, module.OtherAdapter) + instance = _registered_instance(tmp_path, source_adapter=f"{entry_point}:PluginAdapter") + + engine = get_potenda_from_instance(sync_instance=instance, run_id="declared-identity") + + assert type(engine.source) is module.PluginAdapter + assert type(engine.source) is not module.OtherAdapter + plan = instance._runtime_models + assert plan is not None + assert plan.source is not None + assert issubclass(plan.source.models["BuiltinTag"], module.PluginModel) + assert cast("Any", engine.source).BuiltinTag is plan.source.models["BuiltinTag"] + + +def test_a_declared_source_class_the_plugin_does_not_provide_refuses_before_assembly( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _plugin_source_module() + monkeypatch.setitem(sys.modules, module.__name__, module) + entry_point = _publish_entry_point(monkeypatch, module.OtherAdapter) + + with pytest.raises(PluginLoadError, match="MissingAdapter"): + _registered_instance(tmp_path, source_adapter=f"{entry_point}:MissingAdapter") diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index 26ace376..854d8df8 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -32,6 +32,7 @@ from infrahub_sync.runtime_schema import worker as worker_module from infrahub_sync.utils import get_potenda_from_instance from tests.configuration.validation_packages import package_data +from tests.runtime_schema.installed_distribution import install_distribution if TYPE_CHECKING: from collections.abc import Iterator @@ -397,29 +398,34 @@ def test_a_non_infrahub_destination_refuses_before_any_schema_read(spy: _Snapsho assert spy.branches == [] -@pytest.mark.parametrize( - "source_adapter", - [ - pytest.param("tests.runtime_schema.installed_source_adapter", id="dotted-module"), - pytest.param( - "tests.runtime_schema.installed_source_adapter:InstalledSourceAdapter", id="dotted-module-and-class" - ), - pytest.param("installed_source_entry_point", id="entry-point-name"), - ], -) -def test_a_non_bundled_installed_source_with_an_infrahub_destination_may_execute( - spy: _SnapshotSpy, tmp_path: Path, source_adapter: str, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize("declare_class", [False, True], ids=["module", "module-and-class"]) +def test_an_installed_dotted_source_with_an_infrahub_destination_may_execute( + spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, declare_class: bool +) -> None: + # Admitted, not qualified. The source is laid out and claimed the way an install lays + # it out, because registered admission binds the dotted origin to a shipped file. + dotted = install_distribution( + monkeypatch, site_packages=tmp_path / "site-packages", package="workerpkg", module="adapter" + ) + content = _package_content() + content["configuration"]["source"]["adapter"] = f"{dotted}:WheelAdapter" if declare_class else dotted + + plan = _plan(parse_configuration_package(content), tmp_path) + + module = importlib.import_module(dotted) + assert plan.source is not None + assert plan.source.adapter_class is module.WheelAdapter + assert set(plan.source.models) == {"BuiltinTag", "LocationSite"} + assert issubclass(plan.source.models["BuiltinTag"], module.WheelModel) + assert spy.branches == ["main"] + + +def test_an_entry_point_source_with_an_infrahub_destination_may_execute( + spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - # Admitted, not qualified: an installed dotted or entry-point source runs, while a - # filesystem declaration never crosses registered admission at all. The dotted cases - # need this module to look like installed code, because registered resolution admits - # a dotted target only when a distribution owns its top-level package. + # An entry point is distribution metadata, so it needs no separate provenance check. from tests.runtime_schema.installed_source_adapter import InstalledSourceAdapter, InstalledSourceModel - monkeypatch.setattr( - "infrahub_sync.plugin_loader.packages_distributions", - lambda: {"tests": ["a-plugin-distribution"]}, - ) monkeypatch.setattr( "infrahub_sync.plugin_loader.entry_points", lambda: _EntryPoints( @@ -428,13 +434,12 @@ def test_a_non_bundled_installed_source_with_an_infrahub_destination_may_execute ), ) content = _package_content() - content["configuration"]["source"]["adapter"] = source_adapter + content["configuration"]["source"]["adapter"] = "installed_source_entry_point" plan = _plan(parse_configuration_package(content), tmp_path) assert plan.source is not None assert plan.source.adapter_class is InstalledSourceAdapter - assert set(plan.source.models) == {"BuiltinTag", "LocationSite"} assert issubclass(plan.source.models["BuiltinTag"], InstalledSourceModel) assert spy.branches == ["main"] From 1a41c4cae6ab2626c7f86eba5151b2ee03aaac9c Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 00:32:15 -0400 Subject: [PATCH 22/27] Validate the module registered resolution actually loaded Predicting an origin from sys.path decides whether to import, but says nothing about a name already in sys.modules: import_module returns that entry without consulting a finder, so a preloaded module bypassed the check entirely. A parent package's manipulated __path__ redirects a submodule the same way, and the bundled package was admitted by name alone, so a replaced infrahub_sync entry was trusted too. Ask the same provenance question about the module object before reading classes from it: a bundled module must come from inside the installed bundled package, located from this module's own file rather than from sys.modules, and everything else must be a file an installed distribution ships. No usable origin is a refusal. The local CLI path is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/plugin_loader.py | 43 +++++- .../test_preloaded_module_origin.py | 133 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tests/runtime_schema/test_preloaded_module_origin.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index 265fd227..02f39e62 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -40,6 +40,9 @@ class PluginLoadError(Exception): # are admitted whatever the install style reports — an editable or source checkout has no # distribution metadata mapping `infrahub_sync` to a distribution at all. BUNDLED_PACKAGE = "infrahub_sync" +# Where the bundled package really lives, taken from this module's own location rather +# than from `sys.modules`, so a replaced `infrahub_sync` entry cannot move it. +BUNDLED_ROOT = Path(__file__).resolve().parent def installed_module_origins(spec_path: str) -> set[Path]: @@ -58,6 +61,37 @@ def installed_module_origins(spec_path: str) -> set[Path]: return origins +def _normalized_module_origin(module: object) -> Path | None: + """The file a loaded module reports itself as coming from, normalized.""" + spec = getattr(module, "__spec__", None) + origin = getattr(spec, "origin", None) or getattr(module, "__file__", None) + if not isinstance(origin, str) or not origin: + return None + try: + return Path(origin).resolve() + except OSError: + return None + + +def module_origin_is_admitted(module: object, spec_path: str) -> bool: + """Whether the module actually loaded is one registered execution may read. + + Predicting an origin from ``sys.path`` does not cover a name already in + ``sys.modules`` — ``import_module`` returns that entry without consulting a finder — + nor a submodule reached through a parent package's manipulated ``__path__``. This + answers the same provenance question about the module object itself: bundled modules + must come from inside the installed bundled package, and everything else must be a + file some installed distribution ships. A module reporting no usable origin is + refused. + """ + origin = _normalized_module_origin(module) + if origin is None: + return False + if spec_path.partition(".")[0] == BUNDLED_PACKAGE: + return origin.is_relative_to(BUNDLED_ROOT) + return any(candidate.resolve() == origin for candidate in installed_module_origins(spec_path)) + + def _provides_top_level(base: Path, name: str) -> bool: """Whether this import-path entry answers a top-level name at all.""" return (base / name / "__init__.py").is_file() or (base / f"{name}.py").is_file() @@ -349,9 +383,16 @@ def _resolve_from_dotted_path( try: module = importlib.import_module(path) - return self._find_class_in_module(module, class_name, path, default_class_candidates) except (ImportError, AttributeError): return None + # The predicted origin decided whether to import at all; this decides whether the + # module that answered may be read, which a preloaded or redirected one changes. + if self.installed_only and not module_origin_is_admitted(module, path): + return None + try: + return self._find_class_in_module(module, class_name, path, default_class_candidates) + except AttributeError: + return None def _resolve_from_filesystem( self, path: str, class_name: str | None, default_class_candidates: tuple[str, ...] diff --git a/tests/runtime_schema/test_preloaded_module_origin.py b/tests/runtime_schema/test_preloaded_module_origin.py new file mode 100644 index 00000000..5931cc2e --- /dev/null +++ b/tests/runtime_schema/test_preloaded_module_origin.py @@ -0,0 +1,133 @@ +"""R1 follow-up: a preloaded sys.modules entry cannot bypass origin validation. + +Predicting an origin from ``sys.path`` says nothing about a module that is already +imported: ``import_module`` returns the ``sys.modules`` entry without consulting a finder +at all, and a parent package's ``__path__`` can redirect a submodule the same way. So the +module object registered resolution is about to read classes from is validated too. +""" + +from __future__ import annotations + +import importlib +import sys +import types +from pathlib import Path + +import pytest +from diffsync import Adapter + +from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError +from tests.runtime_schema.installed_distribution import install_distribution + +BUNDLED_MODULE = "infrahub_sync.adapters.prometheus" +INSTALLED_MODULE = "diffsync.store" + + +def _poison(name: str, origin: Path | None) -> types.ModuleType: + """A module carrying a usable Adapter that no distribution ships.""" + module = types.ModuleType(name) + + class PoisonAdapter(Adapter): + type = "Poison" + + PoisonAdapter.__module__ = name + module.PoisonAdapter = PoisonAdapter # ty: ignore[unresolved-attribute] + module.__file__ = None if origin is None else str(origin) + module.__spec__ = None + return module + + +def _install_poison(monkeypatch: pytest.MonkeyPatch, name: str, tmp_path: Path) -> types.ModuleType: + poison = _poison(name, tmp_path / "checkout" / f"{name.rpartition('.')[2]}.py") + monkeypatch.setitem(sys.modules, name, poison) + return poison + + +def test_a_preloaded_module_no_distribution_ships_refuses(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + poison = _install_poison(monkeypatch, INSTALLED_MODULE, tmp_path) + + with pytest.raises(PluginLoadError, match="installed distribution"): + PluginLoader.installed_only_loader().resolve(INSTALLED_MODULE) + assert sys.modules[INSTALLED_MODULE] is poison + + +def test_a_preloaded_module_without_an_origin_refuses(monkeypatch: pytest.MonkeyPatch) -> None: + # A module built in memory has no file at all; nothing can be admitted from it. + monkeypatch.setitem(sys.modules, INSTALLED_MODULE, _poison(INSTALLED_MODULE, None)) + + with pytest.raises(PluginLoadError): + PluginLoader.installed_only_loader().resolve(INSTALLED_MODULE) + + +def test_a_preloaded_genuine_installed_module_still_resolves(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # The check validates the origin, not the fact of being preloaded: importing the + # installed module first must not change the verdict. + dotted = install_distribution( + monkeypatch, site_packages=tmp_path / "site-packages", package="preloadedpkg", module="adapter" + ) + preloaded = importlib.import_module(dotted) + assert sys.modules[dotted] is preloaded + + resolved = PluginLoader.installed_only_loader().resolve(dotted) + + assert resolved is preloaded.WheelAdapter + + +def test_a_preloaded_installed_module_resolves_without_a_base_requirement() -> None: + # A genuinely installed module already in sys.modules still answers. + import diffsync.store + + assert sys.modules[INSTALLED_MODULE] is diffsync.store + resolved = PluginLoader.installed_only_loader().resolve( + f"{INSTALLED_MODULE}:BaseStore", default_class_candidates=("BaseStore",) + ) + + assert resolved is diffsync.store.BaseStore + + +def test_a_poisoned_bundled_submodule_refuses(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # The bundled package is admitted by name before the import; after it, the module has + # to actually come from the installed bundled package. + poison = _install_poison(monkeypatch, BUNDLED_MODULE, tmp_path) + + with pytest.raises(PluginLoadError): + PluginLoader.installed_only_loader().resolve(BUNDLED_MODULE) + assert sys.modules[BUNDLED_MODULE] is poison + + +def test_a_genuine_bundled_submodule_still_resolves() -> None: + resolved = PluginLoader.installed_only_loader().resolve("infrahub_sync.adapters.infrahub:InfrahubAdapter") + + assert resolved.__name__ == "InfrahubAdapter" + + +def test_a_manipulated_parent_package_path_cannot_redirect_resolution( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # The distribution ships `pathpkg/adapter.py`, but the already-imported parent's + # __path__ points somewhere else, so the import loads a different file entirely. + dotted = install_distribution( + monkeypatch, site_packages=tmp_path / "site-packages", package="pathpkg", module="adapter" + ) + redirected = tmp_path / "redirect" + redirected.mkdir() + (redirected / "adapter.py").write_text( + "from diffsync import Adapter\n\n\nclass WheelAdapter(Adapter):\n type = 'Redirected'\n", + encoding="utf-8", + ) + parent = types.ModuleType("pathpkg") + parent.__path__ = [str(redirected)] + parent.__file__ = str(redirected / "__init__.py") + monkeypatch.setitem(sys.modules, "pathpkg", parent) + + with pytest.raises(PluginLoadError, match="installed distribution"): + PluginLoader.installed_only_loader().resolve(dotted) + assert sys.modules[dotted].__file__ == str(redirected / "adapter.py") + + +def test_the_legacy_loader_is_unchanged_by_the_origin_check(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # Origin validation is a registered-admission rule; the local CLI path still resolves + # whatever the import system gives it. + poison = _install_poison(monkeypatch, INSTALLED_MODULE, tmp_path) + + assert PluginLoader().resolve(f"{INSTALLED_MODULE}:PoisonAdapter") is poison.PoisonAdapter From 6ac938535dc0130b23f3dd5e81c91eacc8915890 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 06:21:43 -0400 Subject: [PATCH 23/27] Admit an installed source shipped as a namespace package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The origin walk required __init__.py at every level, so an adapter installed as a PEP 420 namespace distribution was refused even though its metadata named the exact module file. That is an ordinary way to ship a plugin, and the accepted profile admits it. Walk the import path component by component instead, combining namespace portions the way PEP 420 does. A regular package or module still ends the scan the moment it is met, so an earlier checkout shadows everything behind it, and a name that resolves to no file — a namespace package is a set of directories — is still refused. The exact target file must still match distribution metadata, so an unowned module in an earlier portion cannot pass as the shipped one. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sync/plugin_loader.py | 69 ++++--- .../runtime_schema/installed_distribution.py | 55 +++++- .../test_namespace_provenance.py | 184 ++++++++++++++++++ .../test_registered_execution.py | 29 +++ 4 files changed, 306 insertions(+), 31 deletions(-) create mode 100644 tests/runtime_schema/test_namespace_provenance.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index 02f39e62..2c231388 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -92,45 +92,54 @@ def module_origin_is_admitted(module: object, spec_path: str) -> bool: return any(candidate.resolve() == origin for candidate in installed_module_origins(spec_path)) -def _provides_top_level(base: Path, name: str) -> bool: - """Whether this import-path entry answers a top-level name at all.""" - return (base / name / "__init__.py").is_file() or (base / f"{name}.py").is_file() - - -def _module_file(base: Path, parts: Sequence[str]) -> Path | None: - """The file this import-path entry would supply for a dotted name, or None.""" - current = base - for index, part in enumerate(parts): - package_init = current / part / "__init__.py" - if index == len(parts) - 1: - # Packages win over same-named modules, as the import system orders them. - if package_init.is_file(): - return package_init - module = current / f"{part}.py" - return module if module.is_file() else None - if not package_init.is_file(): - return None - current /= part - return None +def _resolve_one_part(search: Sequence[Path], name: str) -> tuple[Path | None, list[Path]]: + """Answer one dotted component against an ordered search path. + + Returns either the file a regular package or module supplies, or the namespace + portions found along the way. A regular package or module ends the scan the moment it + is met, which is what makes an earlier checkout shadow everything behind it; bare + directories are recorded as PEP 420 portions and the scan continues, which is what + lets one namespace span several installed roots. + """ + portions: list[Path] = [] + for base in search: + package_init = base / name / "__init__.py" + if package_init.is_file(): + return package_init, [] + module = base / f"{name}.py" + if module.is_file(): + return module, [] + directory = base / name + if directory.is_dir(): + portions.append(directory) + return None, portions def effective_module_origin(spec_path: str) -> Path | None: """The file an import of this dotted name would load, found without importing it. - Walks ``sys.path`` in order, the way the path finder does. The first entry that - answers the top-level name decides the answer even when it does not supply the - submodule, because that entry shadows every later one — which is exactly the case a - checkout creates over an installed distribution. Anything this cannot resolve — a zip - import, a namespace package, a custom finder — returns None and is refused. + Walks ``sys.path`` the way the path finder does, component by component, combining + namespace portions as PEP 420 does so an adapter shipped in a namespace distribution + is reachable. A name that resolves to no file — a namespace package, which is a set + of directories — returns None, as does anything this cannot resolve: a zip import or + a custom finder. Both are refused rather than guessed at. """ parts = spec_path.split(".") - for entry in sys.path: - base = Path(entry) if entry else Path.cwd() - origin = _module_file(base, parts) + search: list[Path] = [Path(entry) if entry else Path.cwd() for entry in sys.path] + for index, part in enumerate(parts): + origin, portions = _resolve_one_part(search, part) + last = index == len(parts) - 1 if origin is not None: - return origin - if _provides_top_level(base, parts[0]): + if last: + return origin + # A module has no submodules, so the dotted name cannot continue through one. + if origin.name != "__init__.py": + return None + search = [origin.parent] + continue + if last or not portions: return None + search = portions return None diff --git a/tests/runtime_schema/installed_distribution.py b/tests/runtime_schema/installed_distribution.py index f389ab7c..28e446eb 100644 --- a/tests/runtime_schema/installed_distribution.py +++ b/tests/runtime_schema/installed_distribution.py @@ -26,9 +26,21 @@ class WheelModel(DiffSyncModelMixin, DiffSyncModel): class WheelAdapter(DiffSyncMixin, Adapter): - """The adapter class registered resolution loads.""" + """The adapter class registered resolution loads. + + Takes the keyword arguments engine assembly passes, so this stands in for a real + adapter all the way through construction and not only through admission. + """ type = "Wheel" + + def __init__(self, target, adapter, config, **kwargs): + super().__init__(**kwargs) + self.target = target + self.config = config + + def model_loader(self, model_name, model): + return None ''' @@ -78,3 +90,44 @@ def install_distribution( # noqa: PLR0913 - one call describes a whole install if name == package or name.startswith(f"{package}."): monkeypatch.delitem(sys.modules, name) return f"{package}.{module}" + + +def install_namespace_distribution( + monkeypatch: pytest.MonkeyPatch, + *, + portions: dict[Path, dict[str, str]], + dotted: str, + installed_root: Path, + on_import_path: bool = True, +) -> str: + """Lay a PEP 420 namespace distribution out across one or more portions. + + `portions` maps each site-packages-shaped root to the `{module: source}` it supplies + under the dotted name's parent packages, and no ``__init__.py`` is written anywhere — + that absence is the point. Metadata is published for `installed_root`'s files only, so + a module supplied by another portion is real, importable, and unowned. + + Roots are prepended in the order given, so the LAST one listed ends up earliest on + ``sys.path``. + """ + import sys + + parents = dotted.split(".")[:-1] + owned: list[str] = [] + for root, modules in portions.items(): + directory = root.joinpath(*parents) + directory.mkdir(parents=True, exist_ok=True) + for module, source in modules.items(): + (directory / f"{module}.py").write_text(source, encoding="utf-8") + if root == installed_root: + owned.append("/".join([*parents, f"{module}.py"])) + distribution = _FakeDistribution(root=installed_root, relative_files=tuple(owned)) + monkeypatch.setattr("infrahub_sync.plugin_loader.distributions", lambda: [distribution]) + if on_import_path: + for root in portions: + monkeypatch.syspath_prepend(str(root)) + top_level = parents[0] + for name in list(sys.modules): + if name == top_level or name.startswith(f"{top_level}."): + monkeypatch.delitem(sys.modules, name) + return dotted diff --git a/tests/runtime_schema/test_namespace_provenance.py b/tests/runtime_schema/test_namespace_provenance.py new file mode 100644 index 00000000..b9f707f1 --- /dev/null +++ b/tests/runtime_schema/test_namespace_provenance.py @@ -0,0 +1,184 @@ +"""A PEP 420 namespace distribution is a normal way to ship an adapter. + +Its parent packages carry no ``__init__.py``, so the origin walk has to combine namespace +portions across ``sys.path`` the way the import system does — while still refusing a +target no distribution ships and still letting a regular package shadow what follows it. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +import pytest + +from infrahub_sync import SyncAdapter +from infrahub_sync.plugin_loader import ( + PluginLoader, + PluginLoadError, + effective_module_origin, + is_installed_distribution_module, + resolve_installed_adapter_class, + resolve_installed_model_base, +) +from tests.runtime_schema.installed_distribution import ADAPTER_SOURCE, install_namespace_distribution + +DOTTED = "vendor_ns.sync_plugins.adapter" +OTHER_SOURCE = ADAPTER_SOURCE.replace('type = "Wheel"', 'type = "Other"') + + +def _installed_only(dotted: str) -> type: + return PluginLoader.installed_only_loader().resolve(dotted) + + +# --- acceptance -------------------------------------------------------------------------- + + +def test_an_installed_namespace_module_resolves(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + installed = tmp_path / "site-packages" + dotted = install_namespace_distribution( + monkeypatch, portions={installed: {"adapter": ADAPTER_SOURCE}}, dotted=DOTTED, installed_root=installed + ) + + assert not (installed / "vendor_ns" / "__init__.py").exists() + assert effective_module_origin(dotted) == installed / "vendor_ns" / "sync_plugins" / "adapter.py" + assert is_installed_distribution_module(dotted) is True + assert _installed_only(dotted).__name__ == "WheelAdapter" + + +def test_an_installed_namespace_module_supplies_both_adapter_and_model( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + installed = tmp_path / "site-packages" + dotted = install_namespace_distribution( + monkeypatch, portions={installed: {"adapter": ADAPTER_SOURCE}}, dotted=DOTTED, installed_root=installed + ) + declared = SyncAdapter(name="plugin", adapter=dotted) + + assert resolve_installed_adapter_class(declared).__name__ == "WheelAdapter" + assert resolve_installed_model_base(declared).__name__ == "WheelModel" + + +def test_namespace_portions_combine_across_the_import_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # Two portions of the same namespace, each shipping a different module. The target + # lives in the second, which is only reachable if portions combine. + first = tmp_path / "portion-one" + installed = tmp_path / "portion-two" + dotted = install_namespace_distribution( + monkeypatch, + portions={installed: {"adapter": ADAPTER_SOURCE}, first: {"unrelated": OTHER_SOURCE}}, + dotted=DOTTED, + installed_root=installed, + ) + + assert effective_module_origin(dotted) == installed / "vendor_ns" / "sync_plugins" / "adapter.py" + assert is_installed_distribution_module(dotted) is True + assert _installed_only(dotted).__name__ == "WheelAdapter" + # The combined namespace really does span both portions. + assert importlib.import_module("vendor_ns.sync_plugins.unrelated").WheelAdapter.type == "Other" + + +# --- refusal ---------------------------------------------------------------------------- + + +def test_an_unowned_module_in_an_earlier_portion_cannot_masquerade( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # Both portions supply `adapter`; the earlier one wins the import and no distribution + # ships it, so the shipped file behind it must not launder the unowned one. + installed = tmp_path / "site-packages" + earlier = tmp_path / "checkout" + dotted = install_namespace_distribution( + monkeypatch, + portions={installed: {"adapter": ADAPTER_SOURCE}, earlier: {"adapter": OTHER_SOURCE}}, + dotted=DOTTED, + installed_root=installed, + ) + + assert effective_module_origin(dotted) == earlier / "vendor_ns" / "sync_plugins" / "adapter.py" + assert is_installed_distribution_module(dotted) is False + with pytest.raises(PluginLoadError, match="installed distribution"): + _installed_only(dotted) + assert dotted not in sys.modules + + +def test_a_regular_package_shadow_blocks_a_later_namespace_portion( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # A checkout package that claims the top-level name is a regular package, so the scan + # stops there; the installed namespace portion behind it is unreachable. + installed = tmp_path / "site-packages" + shadow = tmp_path / "checkout" + dotted = install_namespace_distribution( + monkeypatch, portions={installed: {"adapter": ADAPTER_SOURCE}}, dotted=DOTTED, installed_root=installed + ) + (shadow / "vendor_ns").mkdir(parents=True) + (shadow / "vendor_ns" / "__init__.py").write_text("", encoding="utf-8") + monkeypatch.syspath_prepend(str(shadow)) + + assert effective_module_origin(dotted) is None + assert is_installed_distribution_module(dotted) is False + with pytest.raises(PluginLoadError, match="installed distribution"): + _installed_only(dotted) + + +def test_a_namespace_package_itself_has_no_admitted_origin(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # `vendor_ns.sync_plugins` is a namespace package: it is a directory, not a file, so + # there is nothing a distribution could have shipped for it. + installed = tmp_path / "site-packages" + install_namespace_distribution( + monkeypatch, portions={installed: {"adapter": ADAPTER_SOURCE}}, dotted=DOTTED, installed_root=installed + ) + + assert effective_module_origin("vendor_ns.sync_plugins") is None + assert is_installed_distribution_module("vendor_ns.sync_plugins") is False + + +def test_a_module_cannot_carry_a_submodule(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + installed = tmp_path / "site-packages" + install_namespace_distribution( + monkeypatch, portions={installed: {"adapter": ADAPTER_SOURCE}}, dotted=DOTTED, installed_root=installed + ) + + assert effective_module_origin(f"{DOTTED}.deeper") is None + + +def test_a_preloaded_poison_namespace_module_still_refuses(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # The post-load guard covers namespace targets too. + import types + + from diffsync import Adapter + + installed = tmp_path / "site-packages" + dotted = install_namespace_distribution( + monkeypatch, portions={installed: {"adapter": ADAPTER_SOURCE}}, dotted=DOTTED, installed_root=installed + ) + poison = types.ModuleType(dotted) + + class PoisonAdapter(Adapter): + type = "Poison" + + PoisonAdapter.__module__ = dotted + poison.PoisonAdapter = PoisonAdapter # ty: ignore[unresolved-attribute] + poison.__file__ = str(tmp_path / "elsewhere" / "adapter.py") + poison.__spec__ = None + monkeypatch.setitem(sys.modules, dotted, poison) + + with pytest.raises(PluginLoadError): + _installed_only(dotted) + + +def test_the_legacy_loader_still_resolves_an_unowned_namespace_module( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + earlier = tmp_path / "checkout" + installed = tmp_path / "site-packages" + dotted = install_namespace_distribution( + monkeypatch, + portions={installed: {"adapter": ADAPTER_SOURCE}, earlier: {"adapter": OTHER_SOURCE}}, + dotted=DOTTED, + installed_root=installed, + ) + + assert PluginLoader().resolve(dotted).type == "Other" diff --git a/tests/runtime_schema/test_registered_execution.py b/tests/runtime_schema/test_registered_execution.py index b7ec3546..68235b4d 100644 --- a/tests/runtime_schema/test_registered_execution.py +++ b/tests/runtime_schema/test_registered_execution.py @@ -384,3 +384,32 @@ def test_a_declared_source_class_the_plugin_does_not_provide_refuses_before_asse with pytest.raises(PluginLoadError, match="MissingAdapter"): _registered_instance(tmp_path, source_adapter=f"{entry_point}:MissingAdapter") + + +def test_a_namespace_installed_source_reaches_engine_assembly(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # A PEP 420 namespace distribution is an ordinary way to ship an adapter, so one has + # to reach real engine assembly, not merely pass admission. + from tests.runtime_schema.installed_distribution import ( + ADAPTER_SOURCE, + install_namespace_distribution, + ) + + installed = tmp_path / "site-packages" + dotted = install_namespace_distribution( + monkeypatch, + portions={installed: {"adapter": ADAPTER_SOURCE}}, + dotted="vendor_ns.sync_plugins.adapter", + installed_root=installed, + ) + assert not (installed / "vendor_ns" / "__init__.py").exists() + instance = _registered_instance(tmp_path, source_adapter=dotted) + + engine = get_potenda_from_instance(sync_instance=instance, run_id="namespace-source") + + module = importlib.import_module(dotted) + plan = instance._runtime_models + assert plan is not None + assert plan.source is not None + assert type(engine.source) is module.WheelAdapter + assert cast("Any", engine.source).BuiltinTag is plan.source.models["BuiltinTag"] + assert issubclass(plan.source.models["BuiltinTag"], module.WheelModel) From 1f1adef4b9751ee9cde6232eb5c987212288b5c8 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 06:43:48 -0400 Subject: [PATCH 24/27] ci: exclude managed worker-path test on Python 3.10 --- tasks/tests.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tasks/tests.py b/tasks/tests.py index fd1ebf06..d8dabd3d 100644 --- a/tasks/tests.py +++ b/tasks/tests.py @@ -17,7 +17,11 @@ def tests_unit(context: Context) -> None: """Run unit tests — everything under tests/ except integration-marked tests.""" command = 'pytest -m "not integration and not preview"' if sys.version_info < (3, 11): - command += " --ignore=tests/managed --ignore=tests/conformance/test_managed_equivalence.py" + command += ( + " --ignore=tests/managed" + " --ignore=tests/conformance/test_managed_equivalence.py" + " --ignore=tests/runtime_schema/test_worker_path.py" + ) with context.cd(MAIN_DIRECTORY): context.run(command, pty=True) From d68b69a50f57b5e7916f6006f2a8c7b23568b99d Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 06:56:47 -0400 Subject: [PATCH 25/27] fix: defer managed apply schema construction --- infrahub_sync/managed/flow.py | 24 ++++++++++++++----- tests/conformance/test_managed_equivalence.py | 1 + tests/managed/test_flow_and_prefect.py | 10 ++++++++ tests/managed/test_registered_plan_apply.py | 1 + 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/infrahub_sync/managed/flow.py b/infrahub_sync/managed/flow.py index 8b3df742..3480b379 100644 --- a/infrahub_sync/managed/flow.py +++ b/infrahub_sync/managed/flow.py @@ -248,14 +248,16 @@ def _worker_execution_context( projection: ProductProjection, run_branch: str | None, stage: str, + build_models: bool = True, ) -> tuple[ProductProjection, Any, str]: """Load the durable run and resolve its registered or legacy runtime. - A registered run also builds its runtime model plan here, from one destination - schema read, before any adapter is constructed or any source is extracted. What that - plan covers follows the stage: both sides for plan and sync, the destination only for - a saved-plan apply, and nothing at all for verify, which constructs no adapter. The - legacy path keeps its generated-code resolution and builds no plan. + A registered run builds its runtime model plan here, from one destination schema read, + before any adapter is constructed or any source is extracted. A saved-plan apply defers + that build until its artifact binding is verified. What the plan covers follows the stage: + both sides for plan and sync, the destination only for a saved-plan apply, and nothing at + all for verify, which constructs no adapter. The legacy path keeps its generated-code + resolution and builds no plan. """ stored = projection.lookup_run(run_id) if stored.value is None: @@ -286,7 +288,7 @@ def _worker_execution_context( instance = resolve_runtime_instance(package, directory=config_directory) instance._configuration_binding = binding scope = STAGE_RUNTIME_MODEL_SCOPE.get(stage) - if scope is not None: + if scope is not None and build_models: instance._runtime_models = build_runtime_model_plan( package=package, instance=instance, run_branch=run_branch, scope=scope ) @@ -317,6 +319,7 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar projection=projection, run_branch=branch, stage=stage, + build_models=stage != "apply", ) if stage in ("apply", "sync") and not confirm_writes: msg = f"confirm_writes=true is required for managed stage={stage}" @@ -358,6 +361,15 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar run_id=run_id, binding=parameter_binding, ) + if parameter_binding is not None: + _, instance, sync_name = _worker_execution_context( + run_id, + parameter_binding, + config_directory=config_directory, + projection=projection, + run_branch=branch, + stage=stage, + ) applied = execute_run( instance, operation="apply", diff --git a/tests/conformance/test_managed_equivalence.py b/tests/conformance/test_managed_equivalence.py index ed52cc9a..2d46c53b 100644 --- a/tests/conformance/test_managed_equivalence.py +++ b/tests/conformance/test_managed_equivalence.py @@ -97,6 +97,7 @@ def test_managed_and_standalone_plan_product_projection_seams_match( monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) monkeypatch.setattr(managed_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: instance) + monkeypatch.setattr(managed_flow, "build_runtime_model_plan", lambda **_kwargs: object()) monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) monkeypatch.setattr(managed_flow, "_plan", lambda *_args, **_kwargs: saved) managed_flow.managed_sync_run.fn(run_id, "plan", *binding) diff --git a/tests/managed/test_flow_and_prefect.py b/tests/managed/test_flow_and_prefect.py index fcb15104..2a74fe25 100644 --- a/tests/managed/test_flow_and_prefect.py +++ b/tests/managed/test_flow_and_prefect.py @@ -74,6 +74,16 @@ def claim(projection, run_id: str) -> tuple[str, str]: monkeypatch.setattr(managed_flow, "_claim_current_execution", claim) +@pytest.fixture(autouse=True) +def _stub_runtime_model_plan(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep lifecycle tests focused on flow behavior, not schema composition.""" + + def build(*_args: object, **_kwargs: object) -> object: + return object() + + monkeypatch.setattr(managed_flow, "build_runtime_model_plan", build) + + def _saved(run_id: str) -> SavedPlan: manifest = PlanManifest( format_version=2, diff --git a/tests/managed/test_registered_plan_apply.py b/tests/managed/test_registered_plan_apply.py index c292bb49..c2ed5179 100644 --- a/tests/managed/test_registered_plan_apply.py +++ b/tests/managed/test_registered_plan_apply.py @@ -76,6 +76,7 @@ def _registered_apply( monkeypatch.setattr(managed_flow, "_run_logger", lambda: (managed_flow.logger, False)) monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) + monkeypatch.setattr(managed_flow, "build_runtime_model_plan", lambda **_kwargs: object()) def destination_forbidden(*_args: object, **_kwargs: object) -> RunResult: calls.append("execute-run") From fe8296626496604e3cbba26d37f3ad631fbb6394 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 07:47:58 -0400 Subject: [PATCH 26/27] fix(plugin-loader): validate entry-point module origins Co-Authored-By: OpenAI Codex --- infrahub_sync/plugin_loader.py | 31 +++++-- .../test_entry_point_provenance.py | 86 +++++++++++++++++++ .../test_entry_point_resolution.py | 13 ++- .../test_registered_execution.py | 15 +++- .../test_registered_source_declaration.py | 1 + tests/runtime_schema/test_worker_path.py | 16 ++-- 6 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 tests/runtime_schema/test_entry_point_provenance.py diff --git a/infrahub_sync/plugin_loader.py b/infrahub_sync/plugin_loader.py index 2c231388..40d4d1c0 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -92,6 +92,19 @@ def module_origin_is_admitted(module: object, spec_path: str) -> bool: return any(candidate.resolve() == origin for candidate in installed_module_origins(spec_path)) +def _entry_point_object_origin_is_admitted(obj: object) -> bool: + """Whether an entry point loaded a module or class from installed code.""" + if inspect.ismodule(obj): + loaded_module = obj + loaded_module_name = obj.__name__ + elif inspect.isclass(obj): + loaded_module_name = obj.__module__ + loaded_module = sys.modules.get(loaded_module_name) + else: + return False + return loaded_module is not None and module_origin_is_admitted(loaded_module, loaded_module_name) + + def _resolve_one_part(search: Sequence[Path], name: str) -> tuple[Path | None, list[Path]]: """Answer one dotted component against an ordered search path. @@ -215,8 +228,8 @@ def __init__(self, adapter_paths: Iterable[str] | None = None, *, installed_only Args: adapter_paths: Optional list of paths to search for adapters. installed_only: Whether resolution is restricted to installed code. True - disables filesystem resolution outright and admits a dotted target only - when :func:`is_installed_distribution_module` owns it. + disables filesystem resolution outright and admits dotted and entry-point + module targets only when :func:`is_installed_distribution_module` owns them. """ self.adapter_paths = list(adapter_paths) if adapter_paths else [] self.installed_only = installed_only @@ -226,8 +239,8 @@ def __init__(self, adapter_paths: Iterable[str] | None = None, *, installed_only def installed_only_loader(cls) -> PluginLoader: """Return a loader that resolves installed code and nothing else. - Entry points, bundled adapter modules, and dotted targets an installed - distribution owns: no configured adapter paths, no + Entry points with installed module targets, bundled adapter modules, and dotted + targets an installed distribution owns: no configured adapter paths, no ``INFRAHUB_SYNC_ADAPTER_PATHS``, no working directory, and no module that is importable only because a checkout is on ``sys.path``. This is the loader registered execution resolves through, so an adapter that is not installed in @@ -367,8 +380,8 @@ def resolve(self, spec: str, default_class_candidates: tuple[str, ...] = ("Adapt raise PluginLoadError(msg) msg = ( f"Could not resolve adapter class for spec '{spec}' from installed code. " - f"Tried entry point and built-in resolution, and dotted import restricted to a " - f"top-level package owned by an installed distribution." + f"Tried built-in resolution, with dotted and entry-point module imports " + f"restricted to files shipped by an installed distribution." ) raise PluginLoadError(msg) @@ -496,7 +509,11 @@ def _resolve_from_entry_point( # Get the first matching entry point ep = next(iter(plugin_entry_points)) - obj = ep.load() + obj = None + if not self.installed_only or is_installed_distribution_module(ep.module): + loaded = ep.load() + if not self.installed_only or _entry_point_object_origin_is_admitted(loaded): + obj = loaded # If it's a module, find the class if inspect.ismodule(obj): diff --git a/tests/runtime_schema/test_entry_point_provenance.py b/tests/runtime_schema/test_entry_point_provenance.py new file mode 100644 index 00000000..08db7e52 --- /dev/null +++ b/tests/runtime_schema/test_entry_point_provenance.py @@ -0,0 +1,86 @@ +"""Installed-only entry points resolve classes from installed module origins.""" + +from __future__ import annotations + +import sys +import types +from importlib.metadata import EntryPoint +from pathlib import Path + +import pytest +from diffsync import Adapter + +from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError +from tests.runtime_schema.installed_distribution import install_distribution + +ENTRY_POINT = "provenance_entry_point" +POISON_SOURCE = """ +from diffsync import Adapter + + +class PoisonAdapter(Adapter): + type = "Poison" +""" + + +class _EntryPoints: + def __init__(self, entry_point: EntryPoint) -> None: + self._entry_point = entry_point + + def select(self, *, group: str, name: str) -> tuple[EntryPoint, ...]: + if group == self._entry_point.group and name == self._entry_point.name: + return (self._entry_point,) + return () + + +def _publish(monkeypatch: pytest.MonkeyPatch, dotted: str) -> None: + entry_point = EntryPoint(name=ENTRY_POINT, value=f"{dotted}:PoisonAdapter", group="infrahub_sync.adapters") + monkeypatch.setattr("infrahub_sync.plugin_loader.entry_points", lambda: _EntryPoints(entry_point)) + + +def _poison_module(dotted: str, origin: Path) -> types.ModuleType: + module = types.ModuleType(dotted) + + class PoisonAdapter(Adapter): + type = "Poison" + + PoisonAdapter.__module__ = dotted + module.PoisonAdapter = PoisonAdapter # ty: ignore[unresolved-attribute] + module.__file__ = str(origin) + module.__spec__ = None + return module + + +def test_an_entry_point_target_shadowed_by_a_checkout_refuses_before_load( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + dotted = install_distribution( + monkeypatch, site_packages=tmp_path / "site-packages", package="entryshadow", module="adapter" + ) + shadow = tmp_path / "checkout" + (shadow / "entryshadow").mkdir(parents=True) + (shadow / "entryshadow" / "__init__.py").write_text("", encoding="utf-8") + (shadow / "entryshadow" / "adapter.py").write_text(POISON_SOURCE, encoding="utf-8") + monkeypatch.syspath_prepend(str(shadow)) + _publish(monkeypatch, dotted) + + with pytest.raises(PluginLoadError, match="installed distribution"): + PluginLoader.installed_only_loader().resolve(ENTRY_POINT) + assert dotted not in sys.modules + assert PluginLoader().resolve(ENTRY_POINT).__name__ == "PoisonAdapter" + + +def test_an_entry_point_target_answered_by_a_preloaded_checkout_module_refuses( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + dotted = install_distribution( + monkeypatch, site_packages=tmp_path / "site-packages", package="entrypreload", module="adapter" + ) + poison = _poison_module(dotted, tmp_path / "checkout" / "adapter.py") + monkeypatch.setitem(sys.modules, dotted, poison) + _publish(monkeypatch, dotted) + + with pytest.raises(PluginLoadError, match="installed distribution"): + PluginLoader.installed_only_loader().resolve(ENTRY_POINT) + assert sys.modules[dotted] is poison + assert PluginLoader().resolve(ENTRY_POINT) is poison.PoisonAdapter diff --git a/tests/runtime_schema/test_entry_point_resolution.py b/tests/runtime_schema/test_entry_point_resolution.py index 0f24d0f4..c70d06fb 100644 --- a/tests/runtime_schema/test_entry_point_resolution.py +++ b/tests/runtime_schema/test_entry_point_resolution.py @@ -50,9 +50,10 @@ class PluginModel(DiffSyncModel): class _EntryPoint: - def __init__(self, name: str, target: object) -> None: + def __init__(self, name: str, target: object, module: str) -> None: self.name = name self._target = target + self.module = module def load(self) -> object: return self._target @@ -72,7 +73,15 @@ def _publish(monkeypatch: pytest.MonkeyPatch, module: types.ModuleType, *, value """Publish `value` under the plugin entry-point group, with its module importable.""" monkeypatch.setitem(sys.modules, module.__name__, module) monkeypatch.setattr( - "infrahub_sync.plugin_loader.entry_points", lambda: _EntryPoints(_EntryPoint(ENTRY_POINT, value)) + "infrahub_sync.plugin_loader.entry_points", + lambda: _EntryPoints(_EntryPoint(ENTRY_POINT, value, module.__name__)), + ) + monkeypatch.setattr( + "infrahub_sync.plugin_loader.is_installed_distribution_module", lambda name: name == module.__name__ + ) + monkeypatch.setattr( + "infrahub_sync.plugin_loader.module_origin_is_admitted", + lambda loaded, name: loaded is module and name == module.__name__, ) diff --git a/tests/runtime_schema/test_registered_execution.py b/tests/runtime_schema/test_registered_execution.py index 68235b4d..6d23c5b8 100644 --- a/tests/runtime_schema/test_registered_execution.py +++ b/tests/runtime_schema/test_registered_execution.py @@ -291,9 +291,10 @@ def test_the_constructed_destination_receives_the_effective_branch( class _EntryPoint: - def __init__(self, name: str, target: object) -> None: + def __init__(self, name: str, target: object, module: str) -> None: self.name = name self._target = target + self.module = module def load(self) -> object: return self._target @@ -312,8 +313,18 @@ def select(self, *, group: str, name: str) -> tuple[_EntryPoint, ...]: def _publish_entry_point(monkeypatch: pytest.MonkeyPatch, target: object) -> str: + module_name = cast("type[Any]", target).__module__ + module = sys.modules[module_name] monkeypatch.setattr( - "infrahub_sync.plugin_loader.entry_points", lambda: _EntryPoints(_EntryPoint("plugin_source", target)) + "infrahub_sync.plugin_loader.entry_points", + lambda: _EntryPoints(_EntryPoint("plugin_source", target, module_name)), + ) + monkeypatch.setattr( + "infrahub_sync.plugin_loader.is_installed_distribution_module", lambda name: name == module_name + ) + monkeypatch.setattr( + "infrahub_sync.plugin_loader.module_origin_is_admitted", + lambda loaded, name: loaded is module and name == module_name, ) return "plugin_source" diff --git a/tests/runtime_schema/test_registered_source_declaration.py b/tests/runtime_schema/test_registered_source_declaration.py index a5287335..d7333e91 100644 --- a/tests/runtime_schema/test_registered_source_declaration.py +++ b/tests/runtime_schema/test_registered_source_declaration.py @@ -102,6 +102,7 @@ class _FakeEntryPoint: def __init__(self, name: str, value: type) -> None: self.name = name self._value = value + self.module = value.__module__ def load(self) -> type: return self._value diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index 854d8df8..b2b81d86 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -423,15 +423,19 @@ def test_an_installed_dotted_source_with_an_infrahub_destination_may_execute( def test_an_entry_point_source_with_an_infrahub_destination_may_execute( spy: _SnapshotSpy, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - # An entry point is distribution metadata, so it needs no separate provenance check. from tests.runtime_schema.installed_source_adapter import InstalledSourceAdapter, InstalledSourceModel + module = importlib.import_module("tests.runtime_schema.installed_source_adapter") + monkeypatch.setattr( + "infrahub_sync.plugin_loader.is_installed_distribution_module", lambda name: name == module.__name__ + ) + monkeypatch.setattr( + "infrahub_sync.plugin_loader.module_origin_is_admitted", + lambda loaded, name: loaded is module and name == module.__name__, + ) monkeypatch.setattr( "infrahub_sync.plugin_loader.entry_points", - lambda: _EntryPoints( - "installed_source_entry_point", - importlib.import_module("tests.runtime_schema.installed_source_adapter"), - ), + lambda: _EntryPoints("installed_source_entry_point", module), ) content = _package_content() content["configuration"]["source"]["adapter"] = "installed_source_entry_point" @@ -458,7 +462,7 @@ def __init__(self, name: str, target: ModuleType) -> None: def select(self, *, group: str, name: str) -> tuple[object, ...]: if group != "infrahub_sync.adapters" or name != self._name: return () - return (SimpleNamespace(name=self._name, load=lambda: self._target),) + return (SimpleNamespace(name=self._name, module=self._target.__name__, load=lambda: self._target),) def test_a_failed_schema_read_becomes_a_typed_failure_carrying_only_its_reason( From 7c7ca998af1a9b87f74af25311c6276eed2492f3 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Mon, 31 Aug 2026 07:57:34 -0400 Subject: [PATCH 27/27] fix(schema): reject malformed SDK snapshot values Co-Authored-By: OpenAI Codex --- infrahub_sync/configuration/capabilities.py | 49 ++++++++++++--- .../runtime_schema/test_accessor_snapshot.py | 59 +++++++++++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/infrahub_sync/configuration/capabilities.py b/infrahub_sync/configuration/capabilities.py index 818086f3..dcd0af6b 100644 --- a/infrahub_sync/configuration/capabilities.py +++ b/infrahub_sync/configuration/capabilities.py @@ -309,16 +309,14 @@ def _build_schema_snapshot(schema: object) -> dict[str, Any]: if not isinstance(kind, str): raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") snapshot[kind] = { - "human_friendly_id": list(getattr(node, "human_friendly_id", None) or ()), - "uniqueness_constraints": [ - list(constraint) for constraint in getattr(node, "uniqueness_constraints", None) or () - ], + "human_friendly_id": _optional_string_path(getattr(node, "human_friendly_id", None)), + "uniqueness_constraints": _optional_string_paths(getattr(node, "uniqueness_constraints", None)), "attributes": { attribute.name: { "kind": _member_text(attribute.kind), - "optional": bool(attribute.optional), + "optional": _exact_bool(attribute.optional), "default_value": _json_native_default(attribute.default_value), - "unique": bool(attribute.unique), + "unique": _exact_bool(attribute.unique), } for attribute in getattr(node, "attributes", ()) or () }, @@ -326,7 +324,7 @@ def _build_schema_snapshot(schema: object) -> dict[str, Any]: relationship.name: { "peer": relationship.peer, "cardinality": _member_text(relationship.cardinality), - "optional": bool(relationship.optional), + "optional": _exact_bool(relationship.optional), "kind": _member_text(relationship.kind), } for relationship in getattr(node, "relationships", ()) or () @@ -336,6 +334,43 @@ def _build_schema_snapshot(schema: object) -> dict[str, Any]: return snapshot +def _optional_string_path(value: object) -> list[str]: + """Copy an optional SDK component path without coercing malformed containers.""" + if value is None: + return [] + return _string_path(value) + + +def _optional_string_paths(value: object) -> list[list[str]]: + """Copy optional SDK component paths without coercing malformed containers.""" + if value is None: + return [] + if isinstance(value, (str, bytes, bytearray)) or not isinstance(value, Sequence): + raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") + return [_string_path(path) for path in value] + + +def _string_path(value: object) -> list[str]: + """Copy one non-string SDK sequence containing only string components.""" + if isinstance(value, (str, bytes, bytearray)) or not isinstance(value, Sequence): + raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") + components: list[str] = [] + for component in value: + if not isinstance(component, str): + raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") + components.append(component) + return components + + +def _exact_bool(value: object) -> bool: + """Return an SDK flag only when it is an exact boolean.""" + if value is True: + return True + if value is False: + return False + raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected") + + def _member_text(value: object) -> object: """Return the value of an SDK string enum, leaving anything else to the shape check.""" return value.value if isinstance(value, Enum) else value diff --git a/tests/runtime_schema/test_accessor_snapshot.py b/tests/runtime_schema/test_accessor_snapshot.py index e55492b1..4ef411df 100644 --- a/tests/runtime_schema/test_accessor_snapshot.py +++ b/tests/runtime_schema/test_accessor_snapshot.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Any import pytest @@ -81,6 +82,64 @@ def test_a_non_string_identity_path_is_refused_at_the_adapter_boundary() -> None capabilities_module._build_schema_snapshot({"InfraDevice": _NonStringPathNode()}) +@pytest.mark.parametrize( + ("field", "value"), + [ + pytest.param("human_friendly_id", "name__value", id="human-friendly-id"), + pytest.param("uniqueness_constraints", "name__value", id="constraint-collection"), + pytest.param("uniqueness_constraints", ["name__value"], id="constraint-path"), + ], +) +def test_a_scalar_string_identity_path_container_is_refused_at_the_adapter_boundary(field: str, value: object) -> None: + node = SimpleNamespace( + human_friendly_id=(), + uniqueness_constraints=(), + attributes=(), + relationships=(), + ) + setattr(node, field, value) + + with pytest.raises(DestinationSchemaReadError) as caught: + capabilities_module._build_schema_snapshot({"InfraDevice": node}) + + assert caught.value.reason == "rejected" + + +@pytest.mark.parametrize( + ("member_group", "member"), + [ + pytest.param( + "attributes", + SimpleNamespace(name="name", kind="Text", optional="false", default_value=None, unique=False), + id="attribute-optional", + ), + pytest.param( + "attributes", + SimpleNamespace(name="name", kind="Text", optional=False, default_value=None, unique=1), + id="attribute-unique", + ), + pytest.param( + "relationships", + SimpleNamespace(name="site", peer="LocationSite", cardinality="one", optional="false", kind="Attribute"), + id="relationship-optional", + ), + ], +) +def test_a_non_boolean_member_flag_is_refused_at_the_adapter_boundary(member_group: str, member: object) -> None: + node = SimpleNamespace( + human_friendly_id=(), + uniqueness_constraints=(), + attributes=(), + relationships=(), + ) + setattr(node, member_group, (member,)) + + with pytest.raises(DestinationSchemaReadError) as caught: + capabilities_module._build_schema_snapshot({"InfraDevice": node}) + + assert caught.value.reason == "rejected" + + class _NonFiniteDefaultAttribute: name = "asn" kind = "Number"