From 86cdcdd82ab2353a14958e9a778b35b129f933f5 Mon Sep 17 00:00:00 2001 From: "m2 (AI Agent)" Date: Thu, 2 Jul 2026 02:41:59 +0200 Subject: [PATCH] T020: ApplyAdapter protocol + InstallState --- cli/src/m2_market/apply/__init__.py | 35 ++++++++ cli/src/m2_market/apply/base.py | 128 ++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 cli/src/m2_market/apply/__init__.py create mode 100644 cli/src/m2_market/apply/base.py diff --git a/cli/src/m2_market/apply/__init__.py b/cli/src/m2_market/apply/__init__.py new file mode 100644 index 0000000..5de03c4 --- /dev/null +++ b/cli/src/m2_market/apply/__init__.py @@ -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() diff --git a/cli/src/m2_market/apply/base.py b/cli/src/m2_market/apply/base.py new file mode 100644 index 0000000..1d9a3db --- /dev/null +++ b/cli/src/m2_market/apply/base.py @@ -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: ...