feat: add source config ownership policy
This commit is contained in:
@@ -14,6 +14,8 @@ class WorkflowSourceRegistryProvider(Protocol):
|
||||
|
||||
def config_source_ids(self) -> Set[str]: ...
|
||||
|
||||
def config_source_ownership(self) -> Mapping[str, str]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WorkflowSourceRegistryMutationProvider(Protocol):
|
||||
@@ -58,9 +60,12 @@ class WorkflowSourceRegistryApi:
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
ownership = self._provider.config_source_ownership()
|
||||
entries = sorted(
|
||||
(
|
||||
_entry_summary(_payload(item), self._provider.config_source_ids())
|
||||
_entry_summary(
|
||||
_payload(item), self._provider.config_source_ids(), ownership
|
||||
)
|
||||
for item in self._provider.list_registry_entries()
|
||||
),
|
||||
key=lambda item: str(item.get("id", "")),
|
||||
@@ -77,12 +82,15 @@ class WorkflowSourceRegistryApi:
|
||||
*,
|
||||
source_id: str,
|
||||
) -> dict[str, Any]:
|
||||
ownership = self._provider.config_source_ownership()
|
||||
for item in self._provider.list_registry_entries():
|
||||
entry = _payload(item)
|
||||
if entry.get("id") == source_id:
|
||||
return {
|
||||
"entry": entry,
|
||||
"shadowed_by_config": self._is_shadowed(source_id),
|
||||
"config_ownership": ownership.get(source_id),
|
||||
"mutable": ownership.get(source_id) != "locked",
|
||||
}
|
||||
raise KeyError(f"unknown registry source {source_id!r}")
|
||||
|
||||
@@ -174,11 +182,14 @@ def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def _entry_summary(entry: dict[str, Any], shadowed_ids: Set[str]) -> dict[str, Any]:
|
||||
def _entry_summary(
|
||||
entry: dict[str, Any], shadowed_ids: Set[str], ownership: Mapping[str, str]
|
||||
) -> dict[str, Any]:
|
||||
transport = entry.get("transport")
|
||||
transport_kind = transport.get("kind") if isinstance(transport, Mapping) else None
|
||||
entry_id = entry["id"]
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"id": entry_id,
|
||||
"kind": entry["kind"],
|
||||
"enabled": entry["enabled"],
|
||||
"provider": entry.get("provider"),
|
||||
@@ -186,5 +197,7 @@ def _entry_summary(entry: dict[str, Any], shadowed_ids: Set[str]) -> dict[str, A
|
||||
"profile": entry.get("profile"),
|
||||
"transport_kind": transport_kind,
|
||||
"auth_ref": entry.get("auth_ref"),
|
||||
"shadowed_by_config": entry["id"] in shadowed_ids,
|
||||
"shadowed_by_config": entry_id in shadowed_ids,
|
||||
"config_ownership": ownership.get(entry_id),
|
||||
"mutable": ownership.get(entry_id) != "locked",
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ from dataclasses import dataclass, field
|
||||
from ...connections import ConnectionRegistry, parse_connection_id
|
||||
from ...models import BrokerConfig, ConnectionConfig
|
||||
from ...shared.names import RESERVED_CONNECTION_IDS
|
||||
from ...source_registry import SourceRegistryStore, registry_entry_to_connection_config
|
||||
from ...source_registry import (
|
||||
SourceRegistryFile,
|
||||
SourceRegistryStore,
|
||||
connection_config_to_registry_entry,
|
||||
registry_entry_to_connection_config,
|
||||
)
|
||||
from .events import BrokerEventRecorder
|
||||
from .source_catalog import SourceCatalogService
|
||||
|
||||
@@ -61,25 +66,68 @@ class ConnectionService:
|
||||
source_registry_store: SourceRegistryStore | None = None,
|
||||
) -> None:
|
||||
"""Reconcile registry/source state after the public server reloads config."""
|
||||
# Config-defined connections win over registry entries with the same id;
|
||||
# registry entries fill ids not present in config.
|
||||
connections = list(config.connections)
|
||||
config_ids = {connection.id for connection in connections}
|
||||
config_by_id = {connection.id: connection for connection in connections}
|
||||
registry_entries = {}
|
||||
registry_changed = False
|
||||
|
||||
if source_registry_store is not None:
|
||||
registry = source_registry_store.load_registry()
|
||||
for entry in registry.sources:
|
||||
if entry.id in config_ids:
|
||||
self.events.record_kind(
|
||||
"source_registry_ignored_config_shadow",
|
||||
connection_id=entry.id,
|
||||
payload={
|
||||
"server": entry.provider,
|
||||
"account": entry.account,
|
||||
"reason": "config_connection_takes_precedence",
|
||||
},
|
||||
registry_entries = registry.source_map()
|
||||
|
||||
for connection in connections:
|
||||
if connection.source_config_ownership != "seed":
|
||||
continue
|
||||
if connection.id in registry_entries:
|
||||
continue
|
||||
seeded = connection_config_to_registry_entry(connection)
|
||||
registry_entries[seeded.id] = seeded
|
||||
registry_changed = True
|
||||
self.events.record_kind(
|
||||
"source_registry_seeded_from_config",
|
||||
connection_id=seeded.id,
|
||||
payload={"server": seeded.provider, "account": seeded.account},
|
||||
)
|
||||
|
||||
if registry_changed:
|
||||
source_registry_store.save_registry(
|
||||
SourceRegistryFile(sources=list(registry_entries.values()))
|
||||
)
|
||||
|
||||
merged_connections: list[ConnectionConfig] = []
|
||||
for connection in connections:
|
||||
registry_entry = registry_entries.get(connection.id)
|
||||
if (
|
||||
connection.source_config_ownership == "seed"
|
||||
and registry_entry is not None
|
||||
):
|
||||
merged_connections.append(
|
||||
registry_entry_to_connection_config(registry_entry)
|
||||
)
|
||||
continue
|
||||
connections.append(registry_entry_to_connection_config(entry))
|
||||
merged_connections.append(connection)
|
||||
|
||||
merged_ids = {connection.id for connection in merged_connections}
|
||||
for entry in registry_entries.values():
|
||||
config_connection = config_by_id.get(entry.id)
|
||||
if config_connection is not None:
|
||||
if config_connection.source_config_ownership == "locked":
|
||||
self.events.record_kind(
|
||||
"source_registry_ignored_config_shadow",
|
||||
connection_id=entry.id,
|
||||
payload={
|
||||
"server": entry.provider,
|
||||
"account": entry.account,
|
||||
"reason": "locked_config_connection_takes_precedence",
|
||||
},
|
||||
)
|
||||
continue
|
||||
if entry.id not in merged_ids:
|
||||
merged_connections.append(
|
||||
registry_entry_to_connection_config(entry)
|
||||
)
|
||||
|
||||
connections = merged_connections
|
||||
|
||||
source_catalog = self._source_catalog()
|
||||
next_ids = {connection.id for connection in connections}
|
||||
|
||||
@@ -33,8 +33,20 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
|
||||
def config_source_ids(self) -> set[str]:
|
||||
return {connection.id for connection in self.config_connections}
|
||||
|
||||
def config_source_ownership(self) -> dict[str, str]:
|
||||
return {
|
||||
connection.id: connection.source_config_ownership
|
||||
for connection in self.config_connections
|
||||
}
|
||||
|
||||
# -- private helpers ----------------------------------------------------
|
||||
|
||||
def _config_connection(self, source_id: str) -> ConnectionConfig | None:
|
||||
for connection in self.config_connections:
|
||||
if connection.id == source_id:
|
||||
return connection
|
||||
return None
|
||||
|
||||
def _load(self) -> SourceRegistryFile:
|
||||
return self.source_registry_store.load_registry()
|
||||
|
||||
@@ -58,9 +70,13 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
|
||||
|
||||
def add_registry_entry(self, entry: Mapping[str, Any]) -> McpSourceRegistryEntry:
|
||||
source_id = str(entry["id"])
|
||||
if source_id in self.config_source_ids():
|
||||
config_connection = self._config_connection(source_id)
|
||||
if (
|
||||
config_connection is not None
|
||||
and config_connection.source_config_ownership == "locked"
|
||||
):
|
||||
raise ValueError(
|
||||
f"cannot add {source_id!r}: id is shadowed by a config connection"
|
||||
f"cannot add {source_id!r}: id is locked by a config connection"
|
||||
)
|
||||
validated = McpSourceRegistryEntry.model_validate(dict(entry))
|
||||
registry = self._load()
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
|
||||
|
||||
from ..models import BrokerConfig, ConnectionConfig
|
||||
from ..models import BrokerConfig, ConnectionConfig, SourceConfigOwnership
|
||||
|
||||
|
||||
class StdioConnectionMetadata(BaseModel):
|
||||
@@ -43,6 +43,7 @@ class ConnectionConfigFile(BaseModel):
|
||||
account: str
|
||||
enabled: bool = True
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
source_config_ownership: SourceConfigOwnership = "locked"
|
||||
|
||||
@field_validator("metadata", mode="before")
|
||||
@classmethod
|
||||
@@ -65,6 +66,7 @@ class ConnectionConfigFile(BaseModel):
|
||||
account=self.account,
|
||||
enabled=self.enabled,
|
||||
metadata=self.metadata,
|
||||
source_config_ownership=self.source_config_ownership,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry
|
||||
|
||||
@@ -10,6 +10,9 @@ from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceE
|
||||
from wf_api.models import RawWorkflowPlan # noqa: F401
|
||||
|
||||
|
||||
SourceConfigOwnership = Literal["locked", "seed"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConnectionConfig:
|
||||
id: str
|
||||
@@ -17,6 +20,7 @@ class ConnectionConfig:
|
||||
account: str
|
||||
enabled: bool = True
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
source_config_ownership: SourceConfigOwnership = "locked"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -127,6 +127,39 @@ def registry_entry_to_connection_config(
|
||||
)
|
||||
|
||||
|
||||
def connection_config_to_registry_entry(
|
||||
connection: ConnectionConfig,
|
||||
) -> McpSourceRegistryEntry:
|
||||
"""Materialize a seed config connection into persisted registry state.
|
||||
|
||||
Seed config is bootstrap-only. The registry entry must carry enough source
|
||||
identity to become the future desired-state owner after first startup.
|
||||
"""
|
||||
transport = connection.metadata.get("transport")
|
||||
if not isinstance(transport, dict):
|
||||
raise ValueError(
|
||||
f"seed connection {connection.id!r} requires metadata.transport"
|
||||
)
|
||||
profile = connection.metadata.get("profile")
|
||||
auth_ref = connection.metadata.get("auth_ref")
|
||||
return McpSourceRegistryEntry.model_validate(
|
||||
{
|
||||
"id": connection.id,
|
||||
"enabled": connection.enabled,
|
||||
"provider": connection.server,
|
||||
"account": connection.account,
|
||||
"profile": profile if isinstance(profile, str) else None,
|
||||
"transport": transport,
|
||||
"auth_ref": auth_ref if isinstance(auth_ref, str) else None,
|
||||
"metadata": {
|
||||
key: value
|
||||
for key, value in connection.metadata.items()
|
||||
if key not in {"transport", "profile", "auth_ref", "source_registry"}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FileSourceRegistryStore",
|
||||
"HttpSourceTransport",
|
||||
@@ -135,5 +168,6 @@ __all__ = [
|
||||
"SourceRegistryStore",
|
||||
"SourceTransport",
|
||||
"StdioSourceTransport",
|
||||
"connection_config_to_registry_entry",
|
||||
"registry_entry_to_connection_config",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user