diff --git a/docs/docs/reference/durable-product-records.mdx b/docs/docs/reference/durable-product-records.mdx index 6918ea36..7a5c4b85 100644 --- a/docs/docs/reference/durable-product-records.mdx +++ b/docs/docs/reference/durable-product-records.mdx @@ -83,18 +83,27 @@ 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` | +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..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 @@ -149,6 +150,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/capabilities.py b/infrahub_sync/configuration/capabilities.py index cfe2f749..dcd0af6b 100644 --- a/infrahub_sync/configuration/capabilities.py +++ b/infrahub_sync/configuration/capabilities.py @@ -2,9 +2,11 @@ from __future__ import annotations +import math 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 +17,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 +296,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 +309,24 @@ 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": _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": _exact_bool(attribute.optional), + "default_value": _json_native_default(attribute.default_value), + "unique": _exact_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": _exact_bool(relationship.optional), + "kind": _member_text(relationship.kind), + } for relationship in getattr(node, "relationships", ()) or () }, } @@ -310,23 +334,93 @@ 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 + + +def _json_native_default(value: object) -> Any: + """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): + return _json_native_default(value.value) + if isinstance(value, (list, tuple)): + return [_json_native_default(item) for item in value] + 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") + + 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( - isinstance(name, str) - and isinstance(relationship["peer"], str) - and isinstance(relationship["cardinality"], 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/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/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..2c2049ae 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 @@ -37,10 +40,15 @@ 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 +from .runtime import effective_destination_branch from .validation import _finding_message if TYPE_CHECKING: @@ -48,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" @@ -69,9 +78,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 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, ...] @@ -79,19 +90,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: @@ -226,6 +231,22 @@ 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: + # 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/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/infrahub_sync/managed/flow.py b/infrahub_sync/managed/flow.py index 25d7774c..3480b379 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 STAGE_RUNTIME_MODEL_SCOPE, build_runtime_model_plan from .liveness import LivenessPolicy from .models import PlanResource @@ -245,8 +246,19 @@ def _worker_execution_context( *, config_directory: str, 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.""" + """Load the durable run and resolve its registered or legacy runtime. + + 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: msg = f"API-created Sync run {run_id!r} is unavailable" @@ -275,6 +287,11 @@ def _worker_execution_context( raise ValueError(_REGISTERED_CHECKSUM_MISMATCH) instance = resolve_runtime_instance(package, directory=config_directory) instance._configuration_binding = binding + scope = STAGE_RUNTIME_MODEL_SCOPE.get(stage) + 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 + ) return projection, instance, package.configuration.name @@ -286,6 +303,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], @@ -295,7 +313,13 @@ 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, + 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}" @@ -337,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", @@ -494,11 +527,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/plugin_loader.py b/infrahub_sync/plugin_loader.py index 008de140..40d4d1c0 100644 --- a/infrahub_sync/plugin_loader.py +++ b/infrahub_sync/plugin_loader.py @@ -20,20 +20,187 @@ import re import sys from enum import Enum -from importlib.metadata import entry_points +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 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" +# 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]: + """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 _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 _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. + + 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`` 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(".") + 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: + 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 + + +def is_installed_distribution_module(spec_path: str) -> bool: + """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 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 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. + """ + if spec_path.partition(".")[0] == BUNDLED_PACKAGE: + return True + 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: + """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.""" @@ -54,16 +221,33 @@ 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, *, installed_only: bool = False) -> None: """ Initialize a new PluginLoader. 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 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 self._cache: dict[str, tuple[type[Any], Plugintype]] = {} + @classmethod + def installed_only_loader(cls) -> PluginLoader: + """Return a loader that resolves installed code and nothing else. + + 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 + the worker's environment cannot enter a registered run. + """ + return cls(adapter_paths=None, installed_only=True) + @classmethod def from_env_and_args(cls, adapter_paths: Iterable[str] | None = None) -> PluginLoader: """ @@ -158,19 +342,20 @@ 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) - 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 not self.installed_only: + 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: @@ -187,9 +372,16 @@ def resolve(self, spec: str, default_class_candidates: tuple[str, ...] = ("Adapt return cls # If we get here, we couldn't resolve the class + 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}'. " - f"Tried dotted path, filesystem, entry point, and built-in resolution." + f"Could not resolve adapter class for spec '{spec}' from installed code. " + f"Tried built-in resolution, with dotted and entry-point module imports " + f"restricted to files shipped by an installed distribution." ) raise PluginLoadError(msg) @@ -213,9 +405,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, ...] @@ -310,20 +509,58 @@ 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): - return self._find_class_in_module(obj, class_name, name, default_class_candidates) - # If it's already a class, return it + 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 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. + + 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) + 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__) + 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, ...] ) -> type[Any] | None: @@ -420,13 +657,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 = [ @@ -435,11 +676,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: @@ -479,3 +716,33 @@ def _find_class_by_name_candidates( return cls return None + + +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_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. + + Raises: + PluginLoadError: no installed class answers the declared adapter. + """ + return PluginLoader.installed_only_loader().resolve(adapter.adapter or adapter.name) + + +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, resolved through the same installed-only loader + as the adapter class. + + Raises: + PluginLoadError: no installed class answers the declared adapter. + """ + spec = adapter.adapter.split(":")[0] if adapter.adapter else adapter.name + return PluginLoader.installed_only_loader().resolve(spec, default_class_candidates=("Model",)) diff --git a/infrahub_sync/product_store/configs.py b/infrahub_sync/product_store/configs.py index 1093ea3c..130d658a 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. """ config_id: str diff --git a/infrahub_sync/runtime_schema/__init__.py b/infrahub_sync/runtime_schema/__init__.py new file mode 100644 index 00000000..93daab8e --- /dev/null +++ b/infrahub_sync/runtime_schema/__init__.py @@ -0,0 +1,61 @@ +"""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, + RuntimeModelScopeError, + RuntimeSchemaError, + UnsupportedDestinationProfileError, + UnsupportedSchemaSemanticsError, +) +from .models import ATTRIBUTE_TYPE_DOMAIN, build_runtime_models, mapped_attribute_kinds +from .projection import ( + canonical_consumed_schema_projection, + 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, +) + +__all__ = [ + "ATTRIBUTE_TYPE_DOMAIN", + "CARDINALITIES", + "STAGE_RUNTIME_MODEL_SCOPE", + "DestinationSchemaSnapshot", + "DestinationSchemaUnavailableError", + "MissingMappedKindError", + "NormalizedAttribute", + "NormalizedKind", + "NormalizedRelationship", + "RuntimeModelPlan", + "RuntimeModelScope", + "RuntimeModelScopeError", + "RuntimeSchemaError", + "RuntimeSideModels", + "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/domain.py b/infrahub_sync/runtime_schema/domain.py new file mode 100644 index 00000000..9a659737 --- /dev/null +++ b/infrahub_sync/runtime_schema/domain.py @@ -0,0 +1,217 @@ +"""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 + +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 + +CARDINALITIES = frozenset({"one", "many"}) + + +@dataclass(frozen=True, slots=True) +class NormalizedAttribute: + """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 + optional: bool + 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: + """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. + + 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, ...] + 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", MappingProxyType(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) -> 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): + _refuse(detail) + for key in value: + if not isinstance(key, str): + _refuse(detail) + return cast("Mapping[str, Any]", value) + + +def _require_bool(value: object, *, detail: str) -> bool: + if not isinstance(value, bool): + _refuse(detail) + return value + + +def _require_str(value: object, *, detail: str) -> str: + if not isinstance(value, str): + _refuse(detail) + 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. + + 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)): + return tuple(_require_json_default(item, detail=detail) for item in value) + if isinstance(value, Mapping): + return MappingProxyType( + { + _require_str(key, detail=detail): _require_json_default(item, detail=detail) + for key, item in value.items() + } + ) + _refuse(detail) + + +def _component_paths(value: object, *, kind: str) -> tuple[str, ...]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + _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: + _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: + _refuse(f"{detail} is missing {sorted(missing)!r}") + cardinality = _require_str(member["cardinality"], detail=detail) + if cardinality not in CARDINALITIES: + _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: + _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)): + _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( + 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)), + 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..48757fd4 --- /dev/null +++ b/infrahub_sync/runtime_schema/errors.py @@ -0,0 +1,35 @@ +"""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.""" + + +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/models.py b/infrahub_sync/runtime_schema/models.py new file mode 100644 index 00000000..f6757e2b --- /dev/null +++ b/infrahub_sync/runtime_schema/models.py @@ -0,0 +1,158 @@ +"""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 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 diffsync import DiffSyncModel + 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 + # 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]: + """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/infrahub_sync/runtime_schema/projection.py b/infrahub_sync/runtime_schema/projection.py new file mode 100644 index 00000000..926a1e49 --- /dev/null +++ b/infrahub_sync/runtime_schema/projection.py @@ -0,0 +1,104 @@ +"""The one compatibility property: a canonical projection of consumed schema semantics. + +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 +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. + +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 + +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/infrahub_sync/runtime_schema/worker.py b/infrahub_sync/runtime_schema/worker.py new file mode 100644 index 00000000..72182f51 --- /dev/null +++ b/infrahub_sync/runtime_schema/worker.py @@ -0,0 +1,183 @@ +"""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 types import MappingProxyType +from typing import TYPE_CHECKING, Any, Literal + +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 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 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 + destination: RuntimeSideModels + source: RuntimeSideModels | None + + +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, + 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. + 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) + + 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), + destination=side(instance.destination), + source=side(instance.source) if scope == "both" else None, + ) + + +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 2e813464..69826cf0 100644 --- a/infrahub_sync/utils.py +++ b/infrahub_sync/utils.py @@ -13,12 +13,14 @@ 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 from infrahub_sync.plan.verify import destination_binding_failure from infrahub_sync.plugin_loader import PluginLoader, PluginLoadError from infrahub_sync.potenda import Potenda +from infrahub_sync.runtime_schema import RuntimeModelScopeError, bind_runtime_models logger = logging.getLogger(__name__) @@ -30,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( @@ -104,28 +107,17 @@ 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 - # 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: + # 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 loader.resolve(adapter.adapter or adapter.name) + 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]: @@ -174,6 +166,36 @@ 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: + 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: + 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, @@ -188,17 +210,8 @@ 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) - - 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) + runtime_models = sync_instance._runtime_models + source, destination = _adapter_classes(sync_instance, runtime_models) source_store = LocalStore() destination_store = LocalStore() @@ -226,6 +239,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 and runtime_models.source is not None: + bind_runtime_models(src, runtime_models.source.models) dest_kwargs = { "config": sync_instance, @@ -234,13 +249,15 @@ 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) 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). @@ -353,7 +370,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) @@ -365,12 +387,14 @@ 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: 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/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) 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/configuration/test_schema_validation.py b/tests/configuration/test_schema_validation.py index 1a83e846..08e887d8 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,47 @@ 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": {}, + }, +} + + +# 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")}, + }, } @@ -261,7 +291,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 ----------------------------------------------------------- @@ -413,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", } ) @@ -461,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) @@ -790,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/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") 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/__init__.py b/tests/runtime_schema/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/runtime_schema/installed_distribution.py b/tests/runtime_schema/installed_distribution.py new file mode 100644 index 00000000..28e446eb --- /dev/null +++ b/tests/runtime_schema/installed_distribution.py @@ -0,0 +1,133 @@ +"""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. + + 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 +''' + + +@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}" + + +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/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_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_accessor_snapshot.py b/tests/runtime_schema/test_accessor_snapshot.py new file mode 100644 index 00000000..4ef411df --- /dev/null +++ b/tests/runtime_schema/test_accessor_snapshot.py @@ -0,0 +1,162 @@ +"""The bundled Infrahub accessor delivers the properties the runtime path consumes.""" + +from __future__ import annotations + +from types import SimpleNamespace +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.configuration.capabilities import DestinationSchemaReadError +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",) + + +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()}) + + +@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" + 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_concurrent_isolation.py b/tests/runtime_schema/test_concurrent_isolation.py new file mode 100644 index 00000000..e6b56826 --- /dev/null +++ b/tests/runtime_schema/test_concurrent_isolation.py @@ -0,0 +1,240 @@ +"""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 InfrahubAdapter, InfrahubModel +from infrahub_sync.runtime_schema import ( + ATTRIBUTE_TYPE_DOMAIN, + bind_runtime_models, + 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 + + +# --- 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_domain.py b/tests/runtime_schema/test_domain.py new file mode 100644 index 00000000..dfbc9fdf --- /dev/null +++ b/tests/runtime_schema/test_domain.py @@ -0,0 +1,189 @@ +"""The closed normalized destination-schema domain the runtime model path consumes.""" + +from __future__ import annotations + +from typing import Any, cast + +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.kind == "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( + "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", + [ + 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}) + + +# --- 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 diff --git a/tests/runtime_schema/test_dotted_provenance.py b/tests/runtime_schema/test_dotted_provenance.py new file mode 100644 index 00000000..e36e2b7f --- /dev/null +++ b/tests/runtime_schema/test_dotted_provenance.py @@ -0,0 +1,208 @@ +"""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 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 + +import sys +from pathlib import Path + +import pytest + +from infrahub_sync import SyncAdapter +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 _no_distributions(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("infrahub_sync.plugin_loader.distributions", list) + + +# --- the property, over real installed metadata ------------------------------------------ + + +@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 + + +def test_an_installed_distribution_reports_the_exact_file_it_ships() -> None: + origins = installed_module_origins("diffsync.enum") + + assert origins + assert all(origin.name == "enum.py" and origin.parent.name == "diffsync" for origin in origins) + + +# --- acceptance: a module laid out and claimed the way an install lays it out ------------- + + +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 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 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 ------------------------------- + + +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 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_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 is_installed_distribution_module(dotted) is False + + +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): + PluginLoader.installed_only_loader().resolve(CHECKOUT_MODULE) + + assert CHECKOUT_MODULE not in sys.modules + + +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 + + +def test_a_model_base_from_an_uninstalled_dotted_module_refuses(monkeypatch: pytest.MonkeyPatch) -> None: + _no_distributions(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. + assert PluginLoader().resolve(CHECKOUT_MODULE).__name__ == "InstalledSourceAdapter" + + +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"}]} + ] + 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_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" 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 new file mode 100644 index 00000000..c70d06fb --- /dev/null +++ b/tests/runtime_schema/test_entry_point_resolution.py @@ -0,0 +1,273 @@ +"""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, module: str) -> None: + self.name = name + self._target = target + self.module = module + + 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, 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__, + ) + + +@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 + + +# --- 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: + 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) diff --git a/tests/runtime_schema/test_installed_resolution.py b/tests/runtime_schema/test_installed_resolution.py new file mode 100644 index 00000000..f877b089 --- /dev/null +++ b/tests/runtime_schema/test_installed_resolution.py @@ -0,0 +1,96 @@ +"""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.plugin_loader import ( + PluginLoadError, + resolve_installed_adapter_class, + resolve_installed_model_base, +) +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", + 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) + + resolved = resolve_installed_adapter_class(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: + _write_generated_wrapper(tmp_path) + instance = _instance(tmp_path) + + resolved = import_adapter(sync_instance=instance, adapter=instance.destination) + + 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 _qualified(resolve_installed_model_base(instance.destination)) == ( + "infrahub_sync.adapters.infrahub.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 _qualified(resolve_installed_model_base(instance.source)) == ( + "infrahub_sync.adapters.infrahub.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.source) diff --git a/tests/runtime_schema/test_model_builder.py b/tests/runtime_schema/test_model_builder.py new file mode 100644 index 00000000..d42d99ab --- /dev/null +++ b/tests/runtime_schema/test_model_builder.py @@ -0,0 +1,457 @@ +"""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.generator import ATTRIBUTE_KIND_MAP +from infrahub_sync.runtime_schema import ( + ATTRIBUTE_TYPE_DOMAIN, + UnsupportedSchemaSemanticsError, + build_runtime_models, + mapped_attribute_kinds, + 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, DiffSyncModel) 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) + + +_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", + [ + 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")], +) +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 + snapshot = normalize_destination_schema(capabilities_module._build_schema_snapshot(_load_sdk_schema(snapshot_name))) + + captured = mapped_attribute_kinds(snapshot, instance) + + 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_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_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 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) diff --git a/tests/runtime_schema/test_registered_execution.py b/tests/runtime_schema/test_registered_execution.py new file mode 100644 index 00000000..6d23c5b8 --- /dev/null +++ b/tests/runtime_schema/test_registered_execution.py @@ -0,0 +1,426 @@ +"""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.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 +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, + 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) + 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] + + +# --- R2: the declared class is the class the engine runs --------------------------------- + + +class _EntryPoint: + 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 + + +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: + 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, 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" + + +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") + + +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) diff --git a/tests/runtime_schema/test_registered_resolution.py b/tests/runtime_schema/test_registered_resolution.py new file mode 100644 index 00000000..5c9c9e0e --- /dev/null +++ b/tests/runtime_schema/test_registered_resolution.py @@ -0,0 +1,164 @@ +"""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, +) +from infrahub_sync.utils import import_adapter + +_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" + + +# --- 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 new file mode 100644 index 00000000..d7333e91 --- /dev/null +++ b/tests/runtime_schema/test_registered_source_declaration.py @@ -0,0 +1,143 @@ +"""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 + self.module = value.__module__ + + 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_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 new file mode 100644 index 00000000..b2b81d86 --- /dev/null +++ b/tests/runtime_schema/test_worker_path.py @@ -0,0 +1,647 @@ +"""AR1/AR3/AR4/AR5/AR9: registered composition builds and binds runtime models.""" + +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 + +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, + 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 +from tests.runtime_schema.installed_distribution import install_distribution + +if TYPE_CHECKING: + from collections.abc import Iterator + + from infrahub_sync import SyncAdapter, SyncInstance + 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 _instance(package: ConfigurationPackage, tmp_path: Path) -> SyncInstance: + from infrahub_sync.configuration.runtime import resolve_runtime_instance + + 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 ----------------------- + + +def test_the_plan_carries_fresh_model_classes_for_both_sides(spy: _SnapshotSpy, tmp_path: Path) -> None: + plan = _plan(_package(), tmp_path) + + 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"] + + +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, + stage="plan", + ) + + 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, + stage="plan", + ) + + assert instance._runtime_models is None + 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"] + + +# --- 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 _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: + calls = _recording_resolution(monkeypatch) + + 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) + + _plan(_package(), tmp_path, scope="both") + + assert calls == [ + ("adapter_class", "infrahub"), + ("model_base", "infrahub"), + ("adapter_class", "netbox"), + ("model_base", "netbox"), + ] + 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 ------------------------------------------------- + + +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 == [] + + +@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: + 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", module), + ) + content = _package_content() + 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 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, module=self._target.__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: + 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"] + + +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.""" + + 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), + ) + )