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
|
||||
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
|
||||
|
||||
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