Define connector runtime preview contract
This commit is contained in:
@@ -1491,6 +1491,22 @@ The first implementation is a platform access gate. It does not replace
|
|||||||
database backups, process supervision, migration checks, or external load
|
database backups, process supervision, migration checks, or external load
|
||||||
balancer maintenance pages.
|
balancer maintenance pages.
|
||||||
|
|
||||||
|
## Connector Runtime Contract
|
||||||
|
|
||||||
|
Core defines provider-neutral connector preview and diagnostic primitives in
|
||||||
|
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
|
||||||
|
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
|
||||||
|
health; the consuming domain module owns mappings, validation, reconciliation,
|
||||||
|
and mutations of its records.
|
||||||
|
|
||||||
|
Every dry run is bounded and identifies the source revision, source fingerprint,
|
||||||
|
immutable input hash, effects, and redacted diagnostics. Its summary must match
|
||||||
|
the returned effect list exactly. An apply token is usable only when the preview
|
||||||
|
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
|
||||||
|
URLs never contain credentials; only credential-envelope references cross the
|
||||||
|
contract. Provider-specific details belong in sanitized provenance rather than
|
||||||
|
in a shared domain schema.
|
||||||
|
|
||||||
## Build And Verification
|
## Build And Verification
|
||||||
|
|
||||||
Backend verification from core:
|
Backend verification from core:
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
|
||||||
|
|
||||||
|
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||||
|
ConnectorDiagnosticStage = Literal[
|
||||||
|
"configuration",
|
||||||
|
"authentication",
|
||||||
|
"discovery",
|
||||||
|
"read",
|
||||||
|
"mapping",
|
||||||
|
"planning",
|
||||||
|
"apply",
|
||||||
|
"reconciliation",
|
||||||
|
]
|
||||||
|
ConnectorEffectKind = Literal[
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"delete",
|
||||||
|
"conflict",
|
||||||
|
"unchanged",
|
||||||
|
"ignored",
|
||||||
|
]
|
||||||
|
ConnectorOutcomeState = Literal[
|
||||||
|
"preview",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"outcome_unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorContractError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEndpoint:
|
||||||
|
"""Sanitized endpoint identity. Credentials never belong in this value."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
credential_ref: str | None = None
|
||||||
|
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
|
||||||
|
options: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized = self.url.strip()
|
||||||
|
parsed = urlsplit(normalized)
|
||||||
|
if not parsed.scheme or not parsed.hostname:
|
||||||
|
raise ConnectorContractError("Connector endpoints require an absolute URL.")
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Connector endpoint URLs must not contain credentials."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "url", normalized)
|
||||||
|
if self.credential_ref is not None:
|
||||||
|
credential_ref = self.credential_ref.strip()
|
||||||
|
if not credential_ref:
|
||||||
|
raise ConnectorContractError("Credential references cannot be blank.")
|
||||||
|
object.__setattr__(self, "credential_ref", credential_ref)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDiagnostic:
|
||||||
|
severity: ConnectorDiagnosticSeverity
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
stage: ConnectorDiagnosticStage
|
||||||
|
retryable: bool = False
|
||||||
|
source_ref: str | None = None
|
||||||
|
object_ref: str | None = None
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.code.strip() or len(self.code) > 120:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Connector diagnostic codes must contain 1 to 120 characters."
|
||||||
|
)
|
||||||
|
if not self.message.strip():
|
||||||
|
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEffectPreview:
|
||||||
|
effect: ConnectorEffectKind
|
||||||
|
source_object_ref: str
|
||||||
|
target_object_ref: str | None = None
|
||||||
|
changed_fields: tuple[str, ...] = ()
|
||||||
|
sample: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
reason_code: str | None = None
|
||||||
|
outcome: ConnectorOutcomeState = "preview"
|
||||||
|
revision: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.source_object_ref.strip():
|
||||||
|
raise ConnectorContractError("Preview effects require a source object reference.")
|
||||||
|
if self.outcome != "preview":
|
||||||
|
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEffectSummary:
|
||||||
|
creates: int = 0
|
||||||
|
updates: int = 0
|
||||||
|
deletes: int = 0
|
||||||
|
conflicts: int = 0
|
||||||
|
unchanged: int = 0
|
||||||
|
ignored: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return (
|
||||||
|
self.creates
|
||||||
|
+ self.updates
|
||||||
|
+ self.deletes
|
||||||
|
+ self.conflicts
|
||||||
|
+ self.unchanged
|
||||||
|
+ self.ignored
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDryRunRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_ref: str
|
||||||
|
force_full: bool = False
|
||||||
|
max_items: int = 1_000
|
||||||
|
expected_source_revision: str | None = None
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.tenant_id.strip() or not self.source_ref.strip():
|
||||||
|
raise ConnectorContractError("Dry runs require tenant and source references.")
|
||||||
|
if not 1 <= self.max_items <= 10_000:
|
||||||
|
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDryRunResult:
|
||||||
|
contract_version: str
|
||||||
|
source_ref: str
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
input_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
summary: ConnectorEffectSummary
|
||||||
|
effects: tuple[ConnectorEffectPreview, ...] = ()
|
||||||
|
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
|
||||||
|
truncated: bool = False
|
||||||
|
stale: bool = False
|
||||||
|
apply_token: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
f"Unsupported connector contract version: {self.contract_version!r}."
|
||||||
|
)
|
||||||
|
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
|
||||||
|
if not str(getattr(self, name)).strip():
|
||||||
|
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
|
||||||
|
if self.summary.total != len(self.effects):
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Dry-run summary counts must match the returned effect list."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_apply(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.apply_token is not None
|
||||||
|
and not self.truncated
|
||||||
|
and not self.stale
|
||||||
|
and self.summary.conflicts == 0
|
||||||
|
and not any(item.severity == "error" for item in self.diagnostics)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_connector_effects(
|
||||||
|
effects: tuple[ConnectorEffectPreview, ...],
|
||||||
|
) -> ConnectorEffectSummary:
|
||||||
|
counts = Counter(item.effect for item in effects)
|
||||||
|
return ConnectorEffectSummary(
|
||||||
|
creates=counts["create"],
|
||||||
|
updates=counts["update"],
|
||||||
|
deletes=counts["delete"],
|
||||||
|
conflicts=counts["conflict"],
|
||||||
|
unchanged=counts["unchanged"],
|
||||||
|
ignored=counts["ignored"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ConnectorDryRunProvider(Protocol):
|
||||||
|
def preview(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ConnectorDryRunRequest,
|
||||||
|
) -> ConnectorDryRunResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
|
||||||
|
"ConnectorContractError",
|
||||||
|
"ConnectorDiagnostic",
|
||||||
|
"ConnectorDiagnosticSeverity",
|
||||||
|
"ConnectorDiagnosticStage",
|
||||||
|
"ConnectorDryRunProvider",
|
||||||
|
"ConnectorDryRunRequest",
|
||||||
|
"ConnectorDryRunResult",
|
||||||
|
"ConnectorEffectKind",
|
||||||
|
"ConnectorEffectPreview",
|
||||||
|
"ConnectorEffectSummary",
|
||||||
|
"ConnectorEndpoint",
|
||||||
|
"ConnectorOutcomeState",
|
||||||
|
"summarize_connector_effects",
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from govoplan_core.core.connector_runtime import (
|
||||||
|
CONNECTOR_RUNTIME_CONTRACT_VERSION,
|
||||||
|
ConnectorContractError,
|
||||||
|
ConnectorDryRunResult,
|
||||||
|
ConnectorEffectPreview,
|
||||||
|
ConnectorEndpoint,
|
||||||
|
summarize_connector_effects,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorRuntimeContractTests(unittest.TestCase):
|
||||||
|
def test_endpoint_rejects_embedded_credentials(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ConnectorContractError, "must not contain credentials"):
|
||||||
|
ConnectorEndpoint(url="ldaps://user:secret@directory.example.test")
|
||||||
|
|
||||||
|
def test_dry_run_is_bounded_consistent_and_apply_gated(self) -> None:
|
||||||
|
effects = (
|
||||||
|
ConnectorEffectPreview(
|
||||||
|
effect="create",
|
||||||
|
source_object_ref="ldap:uid=one",
|
||||||
|
sample={"display_name": "Example Person"},
|
||||||
|
),
|
||||||
|
ConnectorEffectPreview(
|
||||||
|
effect="unchanged",
|
||||||
|
source_object_ref="ldap:uid=two",
|
||||||
|
target_object_ref="contact-2",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = ConnectorDryRunResult(
|
||||||
|
contract_version=CONNECTOR_RUNTIME_CONTRACT_VERSION,
|
||||||
|
source_ref="source-1",
|
||||||
|
source_revision="revision-1",
|
||||||
|
source_fingerprint="a" * 64,
|
||||||
|
input_hash="b" * 64,
|
||||||
|
generated_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||||
|
summary=summarize_connector_effects(effects),
|
||||||
|
effects=effects,
|
||||||
|
apply_token="plan-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, result.summary.creates)
|
||||||
|
self.assertEqual(1, result.summary.unchanged)
|
||||||
|
self.assertTrue(result.can_apply)
|
||||||
|
|
||||||
|
def test_summary_must_describe_exact_returned_effects(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ConnectorContractError, "summary counts"):
|
||||||
|
ConnectorDryRunResult(
|
||||||
|
contract_version=CONNECTOR_RUNTIME_CONTRACT_VERSION,
|
||||||
|
source_ref="source-1",
|
||||||
|
source_revision="revision-1",
|
||||||
|
source_fingerprint="a" * 64,
|
||||||
|
input_hash="b" * 64,
|
||||||
|
generated_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||||
|
summary=summarize_connector_effects(()),
|
||||||
|
effects=(
|
||||||
|
ConnectorEffectPreview(
|
||||||
|
effect="create",
|
||||||
|
source_object_ref="source:one",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user