merge: apply-base (US1 wave 1)
This commit is contained in:
commit
7209b032d8
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