"""Local, versioned recipe manifest. A recipe is *data*: a named, versioned set of execution parameters handed to the model backend. It carries no model- or vendor-specific code path — a recipe is only ever valid once its own real forward has succeeded on this node (see :mod:`meshnet_node.capability`). The manifest ships with the node release. ``schema_version`` describes the file layout this reader understands; ``catalogue_version`` identifies the recipe set itself so a tracker can reason about which catalogue a node validated against. """ from __future__ import annotations import json from dataclasses import dataclass, field from importlib.resources import files from pathlib import Path from typing import Any, Mapping # Layout of recipes.json understood by this reader. Bump when the file shape changes. RECIPE_SCHEMA_VERSION = 1 DEFAULT_RECIPE_ID = "baseline" _MANIFEST_RESOURCE = "recipes.json" class RecipeManifestError(ValueError): """Raised when a recipe manifest is missing, malformed, or unsupported. The message is operator-facing: it names the source and the fix, and never echoes raw file content back (a manifest may sit next to secrets in a misconfigured deployment). """ @dataclass(frozen=True) class Recipe: """One named, versioned execution recipe.""" id: str version: str backend_id: str description: str = "" params: Mapping[str, Any] = field(default_factory=dict) def to_dict(self) -> dict: return { "id": self.id, "version": self.version, "backend_id": self.backend_id, "description": self.description, "params": dict(self.params), } @dataclass(frozen=True) class RecipeManifest: """A parsed, validated recipe catalogue.""" schema_version: int catalogue_version: str recipes: tuple[Recipe, ...] source: str = "" def get(self, recipe_id: str) -> Recipe | None: for recipe in self.recipes: if recipe.id == recipe_id: return recipe return None def require(self, recipe_id: str) -> Recipe: """Return the named recipe, or raise listing what this catalogue offers.""" recipe = self.get(recipe_id) if recipe is None: available = ", ".join(r.id for r in self.recipes) or "(none)" raise RecipeManifestError( f"unknown recipe {recipe_id!r} in {self.source}; " f"available recipes: {available}" ) return recipe @property def ids(self) -> tuple[str, ...]: return tuple(r.id for r in self.recipes) def to_dict(self) -> dict: return { "schema_version": self.schema_version, "catalogue_version": self.catalogue_version, "recipes": [r.to_dict() for r in self.recipes], } def _require_mapping(value: Any, what: str, source: str) -> Mapping[str, Any]: if not isinstance(value, Mapping): raise RecipeManifestError( f"{what} in {source} must be a JSON object, got {type(value).__name__}" ) return value def _require_text(value: Any, what: str, source: str) -> str: if not isinstance(value, str) or not value.strip(): raise RecipeManifestError( f"{what} in {source} must be a non-empty string" ) return value def _parse_recipe(raw: Any, index: int, source: str) -> Recipe: entry = _require_mapping(raw, f"recipes[{index}]", source) recipe_id = _require_text(entry.get("id"), f"recipes[{index}].id", source) version = _require_text( entry.get("version"), f"recipes[{recipe_id}].version", source ) backend_id = _require_text( entry.get("backend_id"), f"recipes[{recipe_id}].backend_id", source ) params = entry.get("params", {}) if params is None: params = {} _require_mapping(params, f"recipes[{recipe_id}].params", source) description = entry.get("description", "") if not isinstance(description, str): raise RecipeManifestError( f"recipes[{recipe_id}].description in {source} must be a string" ) return Recipe( id=recipe_id, version=version, backend_id=backend_id, description=description, params=dict(params), ) def parse_recipe_manifest(data: Any, source: str = "") -> RecipeManifest: """Validate an already-decoded manifest document.""" doc = _require_mapping(data, "manifest root", source) if "schema_version" not in doc: raise RecipeManifestError( f"{source} is missing 'schema_version'; " f"this node reads recipe schema version {RECIPE_SCHEMA_VERSION}" ) schema_version = doc["schema_version"] if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise RecipeManifestError( f"'schema_version' in {source} must be an integer, " f"got {type(schema_version).__name__}" ) if schema_version != RECIPE_SCHEMA_VERSION: raise RecipeManifestError( f"{source} declares recipe schema version {schema_version}, " f"but this node reads version {RECIPE_SCHEMA_VERSION}; " "upgrade the node or use a manifest for the supported version" ) catalogue_version = _require_text( doc.get("catalogue_version"), "'catalogue_version'", source ) raw_recipes = doc.get("recipes") if not isinstance(raw_recipes, list) or not raw_recipes: raise RecipeManifestError( f"'recipes' in {source} must be a non-empty JSON array" ) recipes: list[Recipe] = [] seen: set[str] = set() for index, raw in enumerate(raw_recipes): recipe = _parse_recipe(raw, index, source) if recipe.id in seen: raise RecipeManifestError( f"duplicate recipe id {recipe.id!r} in {source}; recipe ids must be unique" ) seen.add(recipe.id) recipes.append(recipe) return RecipeManifest( schema_version=schema_version, catalogue_version=catalogue_version, recipes=tuple(recipes), source=source, ) def load_recipe_manifest(path: Path | None = None) -> RecipeManifest: """Load the packaged manifest, or one at ``path``. No network access and no remote catalogue: P0 recipes ship with the node. """ if path is None: source = f"packaged {_MANIFEST_RESOURCE}" try: raw = files("meshnet_node").joinpath(_MANIFEST_RESOURCE).read_text( encoding="utf-8" ) except (OSError, FileNotFoundError, ModuleNotFoundError) as exc: raise RecipeManifestError( f"{source} is missing from this node installation " f"({type(exc).__name__}); reinstall the node package" ) from exc else: source = str(path) try: raw = path.read_text(encoding="utf-8") except OSError as exc: raise RecipeManifestError( f"cannot read recipe manifest {source}: {exc.strerror or exc}" ) from exc try: data = json.loads(raw) except json.JSONDecodeError as exc: raise RecipeManifestError( f"{source} is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}" ) from exc return parse_recipe_manifest(data, source=source)