"""The v1 release contract. One authoritative product version, and the compatibility ranges that version promises. Before M17 the version existed four times — backend, Node Agent, Runtime Worker and the console each declared ``0.1.0`` independently — which is three opportunities for a release to describe itself wrongly. The version lives in the repository's ``VERSION`` file. Every packaged manifest is checked against it rather than trusted to agree, because a manifest that has drifted looks exactly like one that has not. Compatibility is stated, not implied. An application refusing to start against a schema it does not support is a far better outcome than one that starts and writes rows the next version cannot read. """ from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum from pathlib import Path # --------------------------------------------------------------------------- version #: Schema revisions this application version can run against, oldest first. The application refuses #: to serve against anything outside this list rather than guessing. SUPPORTED_SCHEMA_REVISIONS: tuple[str, ...] = ("20260830_0024",) #: The revision a clean installation and a completed upgrade must both arrive at. TARGET_SCHEMA_REVISION = SUPPORTED_SCHEMA_REVISIONS[-1] #: Schema revisions from which the upgrade tool may migrate to the target. These are deliberately #: separate from runtime compatibility: the node-decommission code reads columns that do not exist #: at 0021, so serving on 0021 would be unsafe even though migrating from it is supported. SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS: tuple[str, ...] = ( "20260827_0021", "20260828_0022", "20260830_0023", TARGET_SCHEMA_REVISION, ) #: The oldest release an in-place upgrade to this version is supported from. MINIMUM_UPGRADE_SOURCE = "v1.0.0" #: Agent protocol versions this control plane accepts. SUPPORTED_AGENT_PROTOCOL_VERSIONS: tuple[int, ...] = (1,) #: The protocol version this control plane itself speaks. CURRENT_AGENT_PROTOCOL_VERSION = SUPPORTED_AGENT_PROTOCOL_VERSIONS[-1] #: Release channels. v1 ships one; the enum exists so adding another is a typed change. RELEASE_CHANNEL = "stable" #: Minimum PostgreSQL major version. The schema uses partial unique indexes and generated columns #: that older majors either lack or plan differently. MINIMUM_POSTGRES_MAJOR = 16 def _read_version_file() -> str: """Read the repository's VERSION file, falling back to the packaged distribution metadata.""" here = Path(__file__).resolve() for parent in here.parents: candidate = parent / "VERSION" if candidate.is_file(): return candidate.read_text("utf-8").strip() # An installed wheel has no VERSION file beside it; the distribution metadata is authoritative # there, and the packaging test proves the two agree at build time. from importlib.metadata import PackageNotFoundError, version try: return version("modelforge-api") except PackageNotFoundError: # pragma: no cover - only in a broken install return "0.0.0" PRODUCT_VERSION = _read_version_file() PRODUCT_NAME = "ITWorx ModelForge" # --------------------------------------------------------------------------- semver @dataclass(frozen=True, slots=True) class SemanticVersion: major: int minor: int patch: int prerelease: str | None = None @classmethod def parse(cls, value: str) -> SemanticVersion: core, _, prerelease = value.partition("-") parts = core.split(".") if len(parts) != 3 or not all(part.isdigit() for part in parts): raise ValueError(f"{value!r} is not a MAJOR.MINOR.PATCH version") major, minor, patch = (int(part) for part in parts) return cls(major, minor, patch, prerelease or None) def __str__(self) -> str: core = f"{self.major}.{self.minor}.{self.patch}" return f"{core}-{self.prerelease}" if self.prerelease else core @property def is_prerelease(self) -> bool: return self.prerelease is not None # --------------------------------------------------------------------------- compatibility class Compatibility(StrEnum): """The four answers a compatibility question can have. There is no fifth, and no silent pass.""" COMPATIBLE = "COMPATIBLE" TOO_OLD = "TOO_OLD" TOO_NEW = "TOO_NEW" UNKNOWN = "UNKNOWN" def schema_compatibility(revision: str | None) -> Compatibility: """Is this application version able to run against that schema revision?""" if revision is None: return Compatibility.UNKNOWN if revision in SUPPORTED_SCHEMA_REVISIONS: return Compatibility.COMPATIBLE # Revisions are date-ordered identifiers, so a straight comparison against the oldest and newest # supported revision tells old from new without a migration graph walk. if revision < SUPPORTED_SCHEMA_REVISIONS[0]: return Compatibility.TOO_OLD if revision > SUPPORTED_SCHEMA_REVISIONS[-1]: return Compatibility.TOO_NEW return Compatibility.UNKNOWN def agent_protocol_compatibility(protocol_version: int | None) -> Compatibility: """Can an agent speaking that protocol version talk to this control plane?""" if protocol_version is None: return Compatibility.UNKNOWN if protocol_version in SUPPORTED_AGENT_PROTOCOL_VERSIONS: return Compatibility.COMPATIBLE if protocol_version < SUPPORTED_AGENT_PROTOCOL_VERSIONS[0]: return Compatibility.TOO_OLD return Compatibility.TOO_NEW def upgrade_required(compatibility: Compatibility) -> str | None: """What the operator has to do, in one sentence, or None when nothing is required.""" match compatibility: case Compatibility.COMPATIBLE: return None case Compatibility.TOO_OLD: return "upgrade the component to a release that speaks the current protocol" case Compatibility.TOO_NEW: return "upgrade the control plane, which is older than the component reporting to it" case Compatibility.UNKNOWN: return "the version could not be determined and is refused rather than assumed" # --------------------------------------------------------------------------- build identity @dataclass(frozen=True, slots=True) class BuildIdentity: """What a running binary can say about where it came from. Everything here is either compiled in at build time or read from the environment the image was built with. Nothing is inferred at runtime, because a build identity that a running process can talk itself into is worth nothing during an incident. """ version: str source_commit: str | None built_at: str | None image_digest: str | None channel: str schema_revision: str agent_protocol_version: int def as_dict(self) -> dict[str, object]: return { "version": self.version, "source_commit": self.source_commit, "built_at": self.built_at, "image_digest": self.image_digest, "channel": self.channel, "schema_revision": self.schema_revision, "agent_protocol_version": self.agent_protocol_version, } def _clean(value: str | None) -> str | None: """Treat an unsubstituted build argument as absent rather than reporting it as a fact.""" if value is None: return None stripped = value.strip() if not stripped or stripped.lower() in {"unknown", "none", "null"}: return None return stripped def build_identity( *, source_commit: str | None = None, built_at: str | None = None, image_digest: str | None = None, ) -> BuildIdentity: return BuildIdentity( version=PRODUCT_VERSION, source_commit=_clean(source_commit), built_at=_clean(built_at), image_digest=_clean(image_digest), channel=RELEASE_CHANNEL, schema_revision=TARGET_SCHEMA_REVISION, agent_protocol_version=CURRENT_AGENT_PROTOCOL_VERSION, ) def utc_now_iso() -> str: return datetime.now(UTC).isoformat()