378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""The v1 release contract, enforced.
|
|
|
|
A release that misdescribes itself is a release nobody can support. These tests hold the version to
|
|
one source of truth, hold the compatibility ranges to explicit answers, and hold production
|
|
configuration to rules a development environment is allowed to break.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
|
|
from modelforge_api import __version__
|
|
from modelforge_api.domain.release import (
|
|
CURRENT_AGENT_PROTOCOL_VERSION,
|
|
MINIMUM_POSTGRES_MAJOR,
|
|
MINIMUM_UPGRADE_SOURCE,
|
|
PRODUCT_VERSION,
|
|
RELEASE_CHANNEL,
|
|
SUPPORTED_AGENT_PROTOCOL_VERSIONS,
|
|
SUPPORTED_SCHEMA_REVISIONS,
|
|
SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS,
|
|
TARGET_SCHEMA_REVISION,
|
|
Compatibility,
|
|
SemanticVersion,
|
|
agent_protocol_compatibility,
|
|
build_identity,
|
|
schema_compatibility,
|
|
upgrade_required,
|
|
)
|
|
from modelforge_api.services.startup_validation import (
|
|
StartupFailureCode,
|
|
StartupValidationError,
|
|
enforce_startup,
|
|
validate_settings,
|
|
)
|
|
from modelforge_api.settings import Settings
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
# --------------------------------------------------------------------------- one version
|
|
|
|
|
|
def test_the_version_file_is_the_single_source_of_truth() -> None:
|
|
declared = (ROOT / "VERSION").read_text("utf-8").strip()
|
|
assert declared == PRODUCT_VERSION
|
|
assert declared == __version__
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"manifest",
|
|
["backend/pyproject.toml", "node-agent/pyproject.toml", "runtime-worker/pyproject.toml"],
|
|
)
|
|
def test_every_python_manifest_matches_the_version_file(manifest: str) -> None:
|
|
"""Four independent copies of the version is three chances to publish a wrong one."""
|
|
|
|
data = tomllib.loads((ROOT / manifest).read_text("utf-8"))
|
|
assert data["project"]["version"] == PRODUCT_VERSION, (
|
|
f"{manifest} declares {data['project']['version']!r}, VERSION says {PRODUCT_VERSION!r}"
|
|
)
|
|
|
|
|
|
def test_the_console_manifest_matches_the_version_file() -> None:
|
|
data = json.loads((ROOT / "frontend" / "package.json").read_text("utf-8"))
|
|
assert data["version"] == PRODUCT_VERSION
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"module",
|
|
[
|
|
"node-agent/src/modelforge_node_agent/__init__.py",
|
|
"runtime-worker/src/modelforge_runtime_worker/__init__.py",
|
|
],
|
|
)
|
|
def test_every_component_module_matches_the_version_file(module: str) -> None:
|
|
text = (ROOT / module).read_text("utf-8")
|
|
match = re.search(r'__version__ = "([^"]+)"', text)
|
|
assert match is not None, f"{module} declares no __version__"
|
|
assert match.group(1) == PRODUCT_VERSION
|
|
|
|
|
|
def test_the_release_version_is_semantic_and_not_a_prerelease() -> None:
|
|
parsed = SemanticVersion.parse(PRODUCT_VERSION)
|
|
assert not parsed.is_prerelease
|
|
assert parsed.major >= 1, "a v1 release cannot carry a 0.x major"
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["1.0", "1.0.0.0", "v1.0.0", "one.0.0", ""])
|
|
def test_a_malformed_version_is_refused(bad: str) -> None:
|
|
with pytest.raises(ValueError):
|
|
SemanticVersion.parse(bad)
|
|
|
|
|
|
# --------------------------------------------------------------------------- compatibility
|
|
|
|
|
|
def test_the_target_schema_revision_is_supported() -> None:
|
|
assert TARGET_SCHEMA_REVISION == "20260830_0024"
|
|
assert TARGET_SCHEMA_REVISION in SUPPORTED_SCHEMA_REVISIONS
|
|
assert schema_compatibility(TARGET_SCHEMA_REVISION) is Compatibility.COMPATIBLE
|
|
assert "20260827_0021" in SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS
|
|
assert "20260828_0022" in SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS
|
|
assert "20260827_0021" not in SUPPORTED_SCHEMA_REVISIONS
|
|
|
|
|
|
def test_the_rc_schema_has_one_explicit_direct_upgrade_contract() -> None:
|
|
# The version bump is a later release tranche. This schema tranche must already describe every
|
|
# accepted source honestly so an intermediate protected merge cannot migrate the wrong target.
|
|
assert PRODUCT_VERSION == "1.2.2"
|
|
assert MINIMUM_UPGRADE_SOURCE == "v1.0.0"
|
|
assert SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS == (
|
|
"20260827_0021",
|
|
"20260828_0022",
|
|
"20260830_0023",
|
|
"20260830_0024",
|
|
)
|
|
|
|
|
|
def test_v1_1_documents_the_destructive_downgrade_boundary_truthfully() -> None:
|
|
migration = (
|
|
ROOT / "backend" / "alembic" / "versions" / "20260828_0022_node_decommission.py"
|
|
).read_text("utf-8")
|
|
upgrade_runbook = (ROOT / "docs" / "UPGRADE.md").read_text("utf-8")
|
|
upgrade_tool = (ROOT / "scripts" / "upgrade.py").read_text("utf-8")
|
|
assert "def downgrade()" in migration
|
|
assert "tombstone-free" in upgrade_runbook
|
|
assert "not the production rollback promise" in upgrade_runbook
|
|
assert "rehearsal-only" in upgrade_tool
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("revision", "expected"),
|
|
[
|
|
("20260830_0024", Compatibility.COMPATIBLE),
|
|
("20260830_0023", Compatibility.TOO_OLD),
|
|
("20260828_0022", Compatibility.TOO_OLD),
|
|
("20260827_0021", Compatibility.TOO_OLD),
|
|
("20260101_0001", Compatibility.TOO_OLD),
|
|
("20990101_0099", Compatibility.TOO_NEW),
|
|
(None, Compatibility.UNKNOWN),
|
|
],
|
|
)
|
|
def test_schema_compatibility_is_explicit(revision: str | None, expected: Compatibility) -> None:
|
|
assert schema_compatibility(revision) is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("protocol", "expected"),
|
|
[
|
|
(1, Compatibility.COMPATIBLE),
|
|
(0, Compatibility.TOO_OLD),
|
|
(2, Compatibility.TOO_NEW),
|
|
(None, Compatibility.UNKNOWN),
|
|
],
|
|
)
|
|
def test_agent_protocol_compatibility_is_explicit(
|
|
protocol: int | None, expected: Compatibility
|
|
) -> None:
|
|
assert agent_protocol_compatibility(protocol) is expected
|
|
|
|
|
|
def test_only_a_compatible_answer_requires_no_operator_action() -> None:
|
|
assert upgrade_required(Compatibility.COMPATIBLE) is None
|
|
for other in (Compatibility.TOO_OLD, Compatibility.TOO_NEW, Compatibility.UNKNOWN):
|
|
message = upgrade_required(other)
|
|
assert message and message.strip(), f"{other} must tell the operator what to do"
|
|
|
|
|
|
def test_the_control_plane_speaks_a_protocol_it_supports() -> None:
|
|
assert CURRENT_AGENT_PROTOCOL_VERSION in SUPPORTED_AGENT_PROTOCOL_VERSIONS
|
|
|
|
|
|
def test_the_declared_protocol_matches_the_wire_constant() -> None:
|
|
from modelforge_api.domain.agent_protocol import AGENT_PROTOCOL_VERSION
|
|
|
|
assert AGENT_PROTOCOL_VERSION == CURRENT_AGENT_PROTOCOL_VERSION
|
|
|
|
|
|
# --------------------------------------------------------------------------- build identity
|
|
|
|
|
|
def test_build_identity_reports_absent_values_as_null_rather_than_guessing() -> None:
|
|
identity = build_identity()
|
|
assert identity.version == PRODUCT_VERSION
|
|
assert identity.source_commit is None
|
|
assert identity.built_at is None
|
|
assert identity.image_digest is None
|
|
assert identity.channel == RELEASE_CHANNEL
|
|
assert identity.schema_revision == TARGET_SCHEMA_REVISION
|
|
|
|
|
|
@pytest.mark.parametrize("placeholder", ["", " ", "unknown", "UNKNOWN", "none", "null"])
|
|
def test_an_unsubstituted_build_argument_is_not_reported_as_a_fact(placeholder: str) -> None:
|
|
"""A Dockerfile ARG that was never passed must not become a claimed commit."""
|
|
|
|
identity = build_identity(source_commit=placeholder, image_digest=placeholder)
|
|
assert identity.source_commit is None
|
|
assert identity.image_digest is None
|
|
|
|
|
|
def test_build_identity_carries_a_real_commit_when_given_one() -> None:
|
|
identity = build_identity(source_commit="657ad91", built_at="2026-08-27T12:00:00Z")
|
|
assert identity.source_commit == "657ad91"
|
|
assert identity.built_at == "2026-08-27T12:00:00Z"
|
|
assert "source_commit" in identity.as_dict()
|
|
|
|
|
|
# --------------------------------------------------------------------------- configuration
|
|
|
|
|
|
def _production(**overrides: object) -> Settings:
|
|
base: dict[str, object] = {
|
|
"env": "production",
|
|
"operator_api_key": SecretStr("k" * 48),
|
|
"backup_encryption_key": SecretStr("x" * 44),
|
|
"database_url": "postgresql+psycopg://modelforge_runtime:aVeryLongProductionSecret@db:5432/mf",
|
|
"redis_url": "redis://cache:6379/0",
|
|
"cors_origins": "https://console.example.test",
|
|
"allow_remote_code": False,
|
|
}
|
|
base.update(overrides)
|
|
return Settings(**base) # type: ignore[arg-type]
|
|
|
|
|
|
def _codes(settings: Settings) -> set[str]:
|
|
return {str(problem.code) for problem in validate_settings(settings).problems}
|
|
|
|
|
|
def test_production_without_an_operator_key_is_refused() -> None:
|
|
settings = _production(operator_api_key=None)
|
|
assert StartupFailureCode.MISSING_REQUIRED_SETTING in _codes(settings)
|
|
|
|
|
|
def test_production_with_a_short_operator_key_is_refused() -> None:
|
|
assert StartupFailureCode.INSECURE_PRODUCTION_SETTING in _codes(
|
|
_production(operator_api_key=SecretStr("short"))
|
|
)
|
|
|
|
|
|
def test_production_with_a_development_database_password_is_refused() -> None:
|
|
settings = _production(
|
|
database_url="postgresql+psycopg://modelforge:modelforge@db:5432/modelforge"
|
|
)
|
|
assert StartupFailureCode.INSECURE_PRODUCTION_SETTING in _codes(settings)
|
|
|
|
|
|
def test_production_never_permits_remote_code_execution() -> None:
|
|
assert StartupFailureCode.INSECURE_PRODUCTION_SETTING in _codes(
|
|
_production(allow_remote_code=True)
|
|
)
|
|
|
|
|
|
def test_production_without_a_backup_encryption_key_is_refused() -> None:
|
|
assert StartupFailureCode.MISSING_REQUIRED_SETTING in _codes(
|
|
_production(backup_encryption_key=None)
|
|
)
|
|
|
|
|
|
def test_production_refuses_a_wildcard_cors_origin() -> None:
|
|
assert StartupFailureCode.INSECURE_PRODUCTION_SETTING in _codes(_production(cors_origins="*"))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url", ["", "not-a-url", "mysql://db/mf", "postgresql+psycopg:///mf"]
|
|
)
|
|
def test_an_invalid_database_url_is_refused(url: str) -> None:
|
|
assert StartupFailureCode.INVALID_URL in _codes(_production(database_url=url))
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["", "http://cache:6379", "memcached://cache"])
|
|
def test_an_invalid_redis_url_is_refused(url: str) -> None:
|
|
assert StartupFailureCode.INVALID_URL in _codes(_production(redis_url=url))
|
|
|
|
|
|
def test_a_missing_storage_root_is_refused(tmp_path: Path) -> None:
|
|
settings = _production(artifact_root=str(tmp_path / "absent"))
|
|
problems = validate_settings(settings).problems
|
|
assert any(
|
|
problem.code is StartupFailureCode.INVALID_STORAGE_ROOT
|
|
and problem.setting == "MODELFORGE_ARTIFACT_ROOT"
|
|
for problem in problems
|
|
)
|
|
|
|
|
|
def test_a_storage_root_that_is_a_file_is_refused(tmp_path: Path) -> None:
|
|
target = tmp_path / "not-a-directory"
|
|
target.write_text("", encoding="utf-8")
|
|
settings = _production(artifact_root=str(target))
|
|
assert StartupFailureCode.INVALID_STORAGE_ROOT in _codes(settings)
|
|
|
|
|
|
def test_a_writable_storage_root_is_accepted(tmp_path: Path) -> None:
|
|
settings = _production(
|
|
artifact_root=str(tmp_path),
|
|
quarantine_root=str(tmp_path),
|
|
backup_root=tmp_path,
|
|
)
|
|
roots = {
|
|
problem.setting
|
|
for problem in validate_settings(settings).problems
|
|
if problem.code is StartupFailureCode.INVALID_STORAGE_ROOT
|
|
}
|
|
assert roots == set()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"overrides",
|
|
[
|
|
{"scheduler_safety_reserve_percentage": 0.5},
|
|
{"node_stale_after_seconds": 90, "node_offline_after_seconds": 90},
|
|
{"gateway_queue_timeout_seconds": 45, "gateway_request_timeout_seconds": 45},
|
|
],
|
|
)
|
|
def test_an_impossible_policy_is_refused(overrides: dict[str, object]) -> None:
|
|
assert StartupFailureCode.IMPOSSIBLE_POLICY in _codes(_production(**overrides))
|
|
|
|
|
|
def test_a_sound_production_configuration_has_no_problems(tmp_path: Path) -> None:
|
|
settings = _production(
|
|
artifact_root=str(tmp_path), quarantine_root=str(tmp_path), backup_root=tmp_path
|
|
)
|
|
report = validate_settings(settings)
|
|
assert report.ok, [str(problem) for problem in report.problems]
|
|
|
|
|
|
def test_production_refuses_to_start_while_development_defaults_survive(tmp_path: Path) -> None:
|
|
settings = _production(
|
|
operator_api_key=None,
|
|
artifact_root=str(tmp_path),
|
|
quarantine_root=str(tmp_path),
|
|
backup_root=tmp_path,
|
|
)
|
|
with pytest.raises(StartupValidationError) as raised:
|
|
enforce_startup(settings)
|
|
assert "MODELFORGE_OPERATOR_API_KEY" in str(raised.value)
|
|
|
|
|
|
def test_development_reports_the_same_problems_without_refusing_to_start(tmp_path: Path) -> None:
|
|
"""A developer with no backup key should still be able to run the API — and be told."""
|
|
|
|
settings = Settings(
|
|
env="development",
|
|
operator_api_key=None,
|
|
artifact_root=str(tmp_path),
|
|
quarantine_root=str(tmp_path),
|
|
backup_root=tmp_path,
|
|
)
|
|
report = enforce_startup(settings)
|
|
assert report.ok or report.problems is not None # never raises outside production
|
|
|
|
|
|
def test_the_minimum_postgres_major_is_stated() -> None:
|
|
assert MINIMUM_POSTGRES_MAJOR >= 16
|
|
|
|
|
|
def test_the_node_agent_pins_the_control_plane_at_the_product_version() -> None:
|
|
"""They ship as one product, so a stale pin makes the agent image unbuildable at release time.
|
|
|
|
Found exactly that way: bumping the product version to 1.0.0 left this at 0.1.0 and the Node
|
|
Agent image failed to resolve its dependencies during the fresh-install rehearsal.
|
|
"""
|
|
|
|
data = tomllib.loads((ROOT / "node-agent" / "pyproject.toml").read_text("utf-8"))
|
|
pins = [
|
|
dependency
|
|
for dependency in data["project"]["dependencies"]
|
|
if dependency.startswith("modelforge-api")
|
|
]
|
|
assert pins == [f"modelforge-api=={PRODUCT_VERSION}"], (
|
|
f"the Node Agent pins {pins}, but the product version is {PRODUCT_VERSION}"
|
|
)
|