T020: ApplyAdapter protocol + InstallState
This commit is contained in:
parent
c607bc04b7
commit
86cdcdd82a
2 changed files with 163 additions and 0 deletions
35
cli/src/m2_market/apply/__init__.py
Normal file
35
cli/src/m2_market/apply/__init__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Apply adapter factory (contracts/apply-adapter.md §Selection)."""
|
||||
|
||||
from .base import ApplyAdapter, ApplyResult, Changeset, ChangesetItem, InstallState, RailsNotLanded
|
||||
|
||||
__all__ = [
|
||||
"ApplyAdapter",
|
||||
"ApplyResult",
|
||||
"Changeset",
|
||||
"ChangesetItem",
|
||||
"InstallState",
|
||||
"RailsNotLanded",
|
||||
"get_adapter",
|
||||
]
|
||||
|
||||
_ADAPTERS = {
|
||||
"local": ("m2_market.apply.local", "LocalApplyAdapter"),
|
||||
"m2core-sync": ("m2_market.apply.m2core_sync", "M2CoreSyncApplyAdapter"),
|
||||
}
|
||||
|
||||
|
||||
def get_adapter(name: str) -> ApplyAdapter:
|
||||
"""Look up an ApplyAdapter implementation by name, importing it lazily.
|
||||
|
||||
Raises KeyError for unknown adapter names.
|
||||
"""
|
||||
try:
|
||||
module_name, class_name = _ADAPTERS[name]
|
||||
except KeyError:
|
||||
raise KeyError(f"unknown apply adapter: {name!r} (known: {sorted(_ADAPTERS)})") from None
|
||||
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
adapter_cls = getattr(module, class_name)
|
||||
return adapter_cls()
|
||||
128
cli/src/m2_market/apply/base.py
Normal file
128
cli/src/m2_market/apply/base.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Apply Adapter protocol + InstallState (contracts/apply-adapter.md, frozen v1).
|
||||
|
||||
The only interface through which anything installs a Solution bundle onto a machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal, Protocol
|
||||
|
||||
ChangesetItemKind = Literal["placement", "config_merge", "check"]
|
||||
InstallStatus = Literal["applied", "failed", "rolled_back"]
|
||||
|
||||
DEFAULT_STATE_PATH = Path.home() / ".m2-market" / "state.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangesetItem:
|
||||
kind: ChangesetItemKind
|
||||
src: str
|
||||
dest: str
|
||||
backup: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Changeset:
|
||||
listing_id: str
|
||||
version: str
|
||||
items: list[ChangesetItem]
|
||||
changeset_hash: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyResult:
|
||||
ok: bool
|
||||
applied: list[ChangesetItem] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class RailsNotLanded(Exception):
|
||||
"""Raised by adapters (e.g. m2core-sync) whose fedlearn rails haven't landed yet."""
|
||||
|
||||
|
||||
class InstallState:
|
||||
"""Per-machine install ledger at ~/.m2-market/state.json (data-model.md §InstallState).
|
||||
|
||||
Path is overridable via the M2_MARKET_STATE env var. Writes are atomic
|
||||
(tmp file + rename) so a crash mid-write never corrupts the state file.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path | None = None):
|
||||
self.path = path or self._default_path()
|
||||
self.installs: dict[str, dict] = {}
|
||||
if self.path.exists():
|
||||
self._load()
|
||||
|
||||
@staticmethod
|
||||
def _default_path() -> Path:
|
||||
override = os.environ.get("M2_MARKET_STATE")
|
||||
return Path(override) if override else DEFAULT_STATE_PATH
|
||||
|
||||
def _load(self) -> None:
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.installs = data.get("installs", {})
|
||||
|
||||
def load(self) -> None:
|
||||
"""Re-read state from disk, discarding any in-memory changes."""
|
||||
if self.path.exists():
|
||||
self._load()
|
||||
else:
|
||||
self.installs = {}
|
||||
|
||||
def save(self) -> None:
|
||||
"""Atomically write current state to disk (tmp file + rename)."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"installs": self.installs}
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_path, self.path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
raise
|
||||
|
||||
def record_install(
|
||||
self,
|
||||
listing_id: str,
|
||||
version: str,
|
||||
grant_id: str,
|
||||
changeset_hash: str,
|
||||
status: InstallStatus,
|
||||
) -> None:
|
||||
self.installs[listing_id] = {
|
||||
"version": version,
|
||||
"grant_id": grant_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"changeset_hash": changeset_hash,
|
||||
"status": status,
|
||||
}
|
||||
self.save()
|
||||
|
||||
def get(self, listing_id: str) -> dict | None:
|
||||
return self.installs.get(listing_id)
|
||||
|
||||
|
||||
class ApplyAdapter(Protocol):
|
||||
"""The seam through which any adapter installs a Solution bundle (idempotent, reversible)."""
|
||||
|
||||
name: str
|
||||
|
||||
def plan(self, bundle: Path, state: InstallState) -> Changeset: ...
|
||||
|
||||
def apply(self, changeset: Changeset) -> ApplyResult: ...
|
||||
|
||||
def verify(self, bundle: Path, state: InstallState) -> bool: ...
|
||||
|
||||
def rollback(self, changeset: Changeset) -> None: ...
|
||||
Loading…
Reference in a new issue