Files
ModelForge/backend/src/modelforge_api/domain/release.py
T

224 lines
8.0 KiB
Python

"""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()