224 lines
8.0 KiB
Python
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()
|