diff --git a/.dockerignore b/.dockerignore index a22e22e..0d5cde7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,43 @@ .git .env +**/.env +**/.env.* +**/*.key +**/*.pem +**/*.crt +**/*.cer +**/*.der +**/*.p12 +**/*.pfx +**/*.p7b +**/*.p8 +**/*.jks +**/*.keystore +**/id_rsa* +**/id_ed25519* +**/id_ecdsa* +**/id_dsa* +**/private_key* +**/private-key* +**/secrets/** +**/.secrets/** +**/credentials/** +**/secrets.yaml +**/secrets.yml +**/secrets.json +**/credentials.json +**/service-account*.json +**/.npmrc +**/.pypirc +**/.netrc +**/.git-credentials +**/.htpasswd +**/pip.conf +**/pip.ini +**/.ssh/** +**/.aws/** +**/.config/gcloud/** +**/.state/** **/__pycache__ **/.pytest_cache **/.mypy_cache @@ -10,4 +48,35 @@ frontend/dist frontend/tsconfig.tsbuildinfo backend/tests node-agent/tests +runtime-worker/tests docs + +# Runtime state and downloaded model files must never enter a build context. Keep these paths +# anchored so config/models/initial-candidates.yaml remains available to source packaging. +/models/ +/model-artifacts/ +/artifacts/ +/data/ +/.data/ +/.state/ +/state/ +/hf-cache/ +/huggingface/ +/.cache/huggingface/ +/.quarantine/ +/quarantine/ +/downloads/ +/container-state/ +/postgres-data/ +/redis-data/ +/runtime-traces/ +**/*.safetensors +**/*.gguf +**/*.ckpt +**/*.onnx +**/*.pth +**/*.pt +**/*.bin +**/*.h5 +**/*.pb +**/*.model diff --git a/.env.example b/.env.example index 34c752e..41b1dc1 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# ITWorx ModelForge 1.2.1 — configuration example +# ITWorx ModelForge 1.3.0-rc.1 — configuration example # # Generated by scripts/generate_configuration_docs.py. Do not edit by hand. # Copy to .env and fill in the values marked REQUIRED. See docs/CONFIGURATION.md. @@ -36,12 +36,24 @@ MODELFORGE_REDIS_URL=redis://localhost:6379/0 # Comma-separated exact origins allowed to call the API from a browser. A wildcard is refused in production because requests are credentialed. MODELFORGE_CORS_ORIGINS=http://localhost:3000 -# Operator API key guarding every admin route. Generate at least 32 random characters; ModelForge never mints one for you. (REQUIRED in production) +# Optional JSON array of named human identities with role, credential ID, SHA-256 key digest and timezone-aware expiry. Provision with scripts/operator_identity.py. Replace this deployment setting and recreate the API to rotate or revoke access. +MODELFORGE_OPERATOR_IDENTITIES= + +# Optional salted scrypt verifier generated by scripts/operator_password.py. Enables password login with 15-minute sessions; requires the separate strong operator API key. Changing either value invalidates password sessions. +MODELFORGE_OPERATOR_PASSWORD_HASH= + +# Legacy bootstrap administrator key. Generate at least 32 random characters. Production requires this key or an unexpired named administrator in operator_identities. MODELFORGE_OPERATOR_API_KEY= -# Optional Hugging Face token, used only for acquiring gated repositories. It is never passed to a runtime and never leaves the control plane. +# Optional deployment Hugging Face token for control-plane metadata. Compose may also provision it independently to node-agent downloads; API secret activation does not update nodes. MODELFORGE_HF_TOKEN= +# External base64 32-byte AES-256 key for the managed Hugging Face control-plane slot. Keep independent of backup and operator keys; retain it externally for historical restore. +MODELFORGE_SECRET_SLOT_ROOT_KEY= + +# External key identifier bound to encrypted HF slot versions. Replacing a key does not rewrap history. +MODELFORGE_SECRET_SLOT_ROOT_KEY_ID=hf-control-plane-1 + # Base64 AES-256 key for backup encryption. Without it no backup can be produced, and without the same key no backup can be restored — store it outside this deployment. (REQUIRED in production) MODELFORGE_BACKUP_ENCRYPTION_KEY= @@ -183,6 +195,39 @@ MODELFORGE_OBSERVABILITY_MONITOR_ENABLED=false # Interval between observability evaluation passes. MODELFORGE_OBSERVABILITY_POLL_INTERVAL_SECONDS=60 +# Queue and dispatch the deployment-managed operational alert webhook. +MODELFORGE_ALERT_NOTIFICATIONS_ENABLED=false + +# Deployment-managed HTTPS notification destination. Its exact hostname must also appear in alert_webhook_allowed_hosts; the URL is never returned by the API. +MODELFORGE_ALERT_WEBHOOK_URL= + +# Comma-separated exact destination host allowlist for the alert webhook. Wildcards and redirects are not accepted. +MODELFORGE_ALERT_WEBHOOK_ALLOWED_HOSTS= + +# Minimum alert severity sent to the single webhook route: INFO, WARNING or CRITICAL. +MODELFORGE_ALERT_WEBHOOK_MIN_SEVERITY=WARNING + +# Total timeout for one webhook delivery. +MODELFORGE_ALERT_NOTIFICATION_TIMEOUT_SECONDS=5.0 + +# Maximum webhook attempts before a delivery fails permanently. +MODELFORGE_ALERT_NOTIFICATION_MAX_ATTEMPTS=8 + +# Maximum outbox rows claimed by one notification dispatch pass. +MODELFORGE_ALERT_NOTIFICATION_BATCH_SIZE=20 + +# Lease duration before an interrupted delivery can be reclaimed. +MODELFORGE_ALERT_NOTIFICATION_LEASE_SECONDS=60 + +# Base delay for bounded exponential webhook retries. +MODELFORGE_ALERT_NOTIFICATION_RETRY_BASE_SECONDS=15 + +# In production, check explicitly followed model references for changed revisions. Metadata only; never downloads, tests or promotes a model. +MODELFORGE_MODEL_UPDATE_MONITOR_ENABLED=true + +# Minimum interval between automatic checks of each followed model reference; default one day. Each API process checks at most one due reference per minute. +MODELFORGE_MODEL_UPDATE_CHECK_INTERVAL_SECONDS=86400 + # Reconcile interrupted backups and restores at startup. MODELFORGE_RECOVERY_RECONCILIATION_ENABLED=false diff --git a/.gitea/tests/test_production_deploy_policy.py b/.gitea/tests/test_production_deploy_policy.py index faac2a7..8f5e0da 100644 --- a/.gitea/tests/test_production_deploy_policy.py +++ b/.gitea/tests/test_production_deploy_policy.py @@ -14,7 +14,6 @@ from pathlib import Path REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -DEPLOY_WORKFLOW = REPOSITORY_ROOT / ".gitea" / "workflows" / "unraid-deploy.yml" VALIDATION_WORKFLOW = REPOSITORY_ROOT / ".gitea" / "workflows" / "managed-validation.yml" @@ -62,110 +61,15 @@ def _run_script(step: str) -> str: class ProductionDeployPolicyTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.deploy = DEPLOY_WORKFLOW.read_text(encoding="utf-8") cls.validation = VALIDATION_WORKFLOW.read_text(encoding="utf-8") - def test_deployment_has_no_automatic_trigger(self) -> None: - trigger = _indented_block(self.deploy, "on:") - trigger_keys = re.findall(r"^ ([a-z_]+):", trigger, flags=re.MULTILINE) - self.assertEqual(trigger_keys, ["workflow_dispatch"]) - self.assertNotRegex(trigger, r"(?m)^\s+push:") - - def test_dispatch_inputs_are_required_and_fail_safe(self) -> None: - inputs = _indented_block(self.deploy, " inputs:") - input_names = re.findall(r"^ ([a-z_]+):", inputs, flags=re.MULTILINE) - self.assertEqual(input_names, ["release_tag", "release_commit", "action"]) - - for input_name in ("release_tag", "release_commit"): - block = _indented_block(self.deploy, f" {input_name}:") - self.assertRegex(block, r"(?m)^ required: true$") - self.assertRegex(block, r"(?m)^ type: string$") - - action = _indented_block(self.deploy, " action:") - self.assertRegex(action, r"(?m)^ required: true$") - self.assertRegex(action, r"(?m)^ default: VERIFY_ONLY$") - self.assertRegex(action, r"(?m)^ type: choice$") - options = re.findall(r"^ - (\S+)$", action, flags=re.MULTILINE) - self.assertEqual(options, ["VERIFY_ONLY", "DEPLOY_STABLE_TO_PRODUCTION"]) - - def test_inputs_are_validated_before_checkout(self) -> None: - validation = _named_step(self.deploy, "Validate immutable dispatch inputs") - checkout = _named_step(self.deploy, "Check out the exact release commit with full history") - self.assertLess(self.deploy.index(validation), self.deploy.index(checkout)) - self.assertIn('"refs/heads/master"', validation) - self.assertIn( - r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$", - validation, + def test_legacy_production_delivery_workflow_is_retired(self) -> None: + self.assertFalse( + (REPOSITORY_ROOT / ".gitea" / "workflows" / "unraid-deploy.yml").exists() ) - self.assertIn(r"^[0-9a-f]{40}$", validation) - self.assertIn("VERIFY_ONLY|DEPLOY_STABLE_TO_PRODUCTION", validation) - tag_pattern = re.search(r'RELEASE_TAG\}" =~ (\^.*\$) \]\]', validation) - commit_pattern = re.search(r'RELEASE_COMMIT\}" =~ (\^.*\$) \]\]', validation) - self.assertIsNotNone(tag_pattern) - self.assertIsNotNone(commit_pattern) - assert tag_pattern is not None and commit_pattern is not None - for valid_tag in ("v0.0.0", "v1.2.1", "v10.20.30"): - self.assertRegex(valid_tag, tag_pattern.group(1)) - for invalid_tag in ("1.2.1", "v01.2.1", "v1.2.1-rc.1", "v1.2.1+build", "master"): - self.assertNotRegex(invalid_tag, tag_pattern.group(1)) - self.assertRegex("0" * 40, commit_pattern.group(1)) - for invalid_commit in ("0" * 39, "A" * 40, "g" * 40, "master"): - self.assertNotRegex(invalid_commit, commit_pattern.group(1)) - - def test_checkout_is_pinned_to_the_validated_commit_with_full_history(self) -> None: - checkout = _named_step(self.deploy, "Check out the exact release commit with full history") - self.assertRegex(checkout, r"actions/checkout@[0-9a-f]{40}") - self.assertIn("ref: ${{ inputs.release_commit }}", checkout) - self.assertIn("fetch-depth: 0", checkout) - self.assertIn("persist-credentials: true", checkout) - - def test_release_provenance_is_verified_and_exported(self) -> None: - provenance = _named_step(self.deploy, "Verify stable release provenance") - required_fragments = ( - "git fetch --force --no-recurse-submodules origin", - 'git cat-file -t "${tag_ref}"', - 'git rev-parse "${tag_ref}^{tag}"', - 'git rev-parse "${tag_ref}^{commit}"', - "git rev-parse HEAD", - "< VERSION", - "git merge-base --is-ancestor", - "refs/remotes/origin/master", - "DEPLOY_COMMIT=%s", - '>> "${GITHUB_ENV}"', - ) - for fragment in required_fragments: - with self.subTest(fragment=fragment): - self.assertIn(fragment, provenance) - self.assertNotIn("GITHUB_SHA", self.deploy) - - def test_all_production_shell_blocks_parse(self) -> None: - names = ( - "Validate immutable dispatch inputs", - "Verify stable release provenance", - "Verification-only result", - "Deploy verified stable release to production", - ) - for name in names: - script = _run_script(_named_step(self.deploy, name)) - result = subprocess.run( - ["bash", "-n"], - input=script.encode("utf-8"), - capture_output=True, - check=False, - ) - with self.subTest(step=name): - self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", "replace")) - - def test_deployment_requires_the_explicit_action_and_verified_commit(self) -> None: - deploy = _named_step(self.deploy, "Deploy verified stable release to production") - self.assertIn("inputs.action == 'DEPLOY_STABLE_TO_PRODUCTION'", deploy) - self.assertIn('test -n "${DEPLOY_COMMIT:-}"', deploy) - self.assertIn('"${GITHUB_REPOSITORY}" "${DEPLOY_COMMIT}"', deploy) - self.assertEqual(self.deploy.count("/opt/gitea-deploy/deploy.py deploy"), 1) - - verify_only = _named_step(self.deploy, "Verification-only result") - self.assertIn("inputs.action == 'VERIFY_ONLY'", verify_only) + def test_managed_validation_uses_the_shared_validation_pool(self) -> None: + self.assertRegex(self.validation, r"(?m)^\s*runs-on:\s*linux-validation\s*$") def test_managed_validation_runs_policy_before_profile_work(self) -> None: policy_step = _named_step(self.validation, "Production delivery policy") @@ -176,39 +80,83 @@ class ProductionDeployPolicyTests(unittest.TestCase): self.validation.index(" - name: Validate the requested profile"), ) - def test_security_profile_runs_fail_closed_scanners(self) -> None: - step = _named_step(self.validation, "Security — secrets and vulnerable dependencies") - self.assertIn("env.PROFILE == 'security'", step) - self.assertIn("env.PROFILE == 'full'", step) - script = _run_script(step) - self.assertIn("gitleaks git .", script) - self.assertIn("pip_audit --strict --skip-editable", script) - self.assertIn("npm audit --audit-level=high --omit=dev", script) - self.assertNotRegex(script, r"(?m)(?:\|\|\s*true|continue-on-error)") + def test_full_validation_uses_a_disposable_managed_postgres_boundary(self) -> None: + job = _indented_block(self.validation, " full:") + boundary = _named_step(self.validation, "Managed PostgreSQL boundary") + cleanup = _named_step(self.validation, "Remove managed PostgreSQL boundary") - toolchain = _named_step(self.validation, "Toolchain") - toolchain_script = _run_script(toolchain) - self.assertIn("command -v gitleaks", toolchain_script) - self.assertIn("exit 1", toolchain_script) + for variable in ("VALIDATION_COMPOSE_PROJECT", "MODELFORGE_POSTGRES_PORT"): + self.assertRegex(job, rf"(?m)^ {variable}: .+$") + self.assertIn("modelforge-validation-${{ gitea.run_id }}", job) + self.assertIn('MODELFORGE_POSTGRES_PORT: "0"', job) + self.assertIn("env.PROFILE == 'full'", boundary) + self.assertIn("export MODELFORGE_MIGRATION_DATABASE_URL=", boundary) + self.assertIn("export MODELFORGE_RUNTIME_DATABASE_URL=", boundary) + self.assertIn("up --detach --wait postgres", boundary) + self.assertIn('validation_network="${VALIDATION_COMPOSE_PROJECT}_default"', boundary) + self.assertIn('job_container="$(hostname)"', boundary) + self.assertIn('docker network connect "${validation_network}" "${job_container}"', boundary) + self.assertIn('socket.create_connection(("postgres", 5432)', boundary) + self.assertNotIn("port postgres 5432", boundary) + for variable in ( + "MODELFORGE_TEST_POSTGRES_URL", + "MODELFORGE_TEST_POSTGRES_OWNER_URL", + "MODELFORGE_TEST_POSTGRES_RUNTIME_URL", + ): + self.assertIn(f'echo "{variable}=', boundary) + self.assertIn('>> "${GITHUB_ENV}"', boundary) + self.assertNotIn("SKIPPED", boundary) + self.assertIn("always() && env.PROFILE == 'full'", cleanup) + self.assertIn("docker network disconnect", cleanup) + self.assertIn('"$(hostname)"', cleanup) + self.assertIn("down --volumes --remove-orphans", cleanup) + self.assertIn("export MODELFORGE_MIGRATION_DATABASE_URL=", cleanup) + self.assertIn("export MODELFORGE_RUNTIME_DATABASE_URL=", cleanup) - def test_compose_validation_renders_the_production_projection(self) -> None: - step = _named_step(self.validation, "Compose projections") - script = _run_script(step) - self.assertIn( - "for overlay in backup dr gpu node-agent node-recovery production runtime-worker", - script, + def test_full_validation_traverses_the_live_schema_and_tests_the_built_console(self) -> None: + backend = _named_step(self.validation, "Backend — install, lint, typecheck, test") + console = _named_step(self.validation, "Console — install frozen, typecheck, test, build") + boundary = _named_step(self.validation, "Managed PostgreSQL boundary") + + self.assertIn('MODELFORGE_MIGRATION_DATABASE_URL="${MODELFORGE_TEST_POSTGRES_OWNER_URL}"', backend) + upgrade_commands = re.findall( + r'"\$\{BACKEND_VENV\}/bin/python" -m alembic ([^\n]+)', + backend, ) + self.assertEqual( + upgrade_commands, + ["upgrade head", "downgrade 20260828_0022", "upgrade head"], + ) + self.assertLess(self.validation.index(console), self.validation.index(boundary)) + self.assertLess(self.validation.index(boundary), self.validation.index(backend)) + + def test_managed_postgres_shell_blocks_parse(self) -> None: + for name in ( + "Managed PostgreSQL boundary", + "Backend — install, lint, typecheck, test", + "Remove managed PostgreSQL boundary", + ): + script = _run_script(_named_step(self.validation, name)) + result = subprocess.run( + ["bash", "-n"], + input=script.encode("utf-8"), + capture_output=True, + check=False, + ) + with self.subTest(step=name): + self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", "replace")) + + def test_compose_projection_uses_explicit_non_secret_validation_inputs(self) -> None: + projection = _named_step(self.validation, "Compose projections") for variable in ( "MODELFORGE_POSTGRES_ADMIN_PASSWORD", - "MODELFORGE_MIGRATION_DATABASE_URL", + "MODELFORGE_MIGRATION_DB_PASSWORD", + "MODELFORGE_RUNTIME_DB_PASSWORD", "MODELFORGE_RUNTIME_DATABASE_URL", - "MODELFORGE_OPERATOR_API_KEY", - "MODELFORGE_BACKUP_ENCRYPTION_KEY", - "MODELFORGE_CORS_ORIGINS", - "VITE_API_BASE_URL", + "MODELFORGE_MIGRATION_DATABASE_URL", ): - with self.subTest(variable=variable): - self.assertIn(f"export {variable}=", script) + self.assertIn(f"export {variable}=", projection) + self.assertIn("docker compose --env-file .env.example", projection) if __name__ == "__main__": diff --git a/.gitea/tests/test_public_source_export.py b/.gitea/tests/test_public_source_export.py index 39b3e0f..2afbcd2 100644 --- a/.gitea/tests/test_public_source_export.py +++ b/.gitea/tests/test_public_source_export.py @@ -2,12 +2,13 @@ from __future__ import annotations +import hashlib +import json import subprocess import tempfile import unittest from pathlib import Path - REPOSITORY_ROOT = Path(__file__).resolve().parents[2] EXPORTER = REPOSITORY_ROOT / "scripts" / "export-public-source.mjs" VALIDATOR = REPOSITORY_ROOT / "scripts" / "validate-public-source.mjs" @@ -28,8 +29,36 @@ class PublicSourceExportTests(unittest.TestCase): "SECURITY.md": "# Security\n", "CONTRIBUTING.md": "# Contributing\n", "docker-compose.yml": "services: {}\n", + "docker-compose.validation-tls.yml": "services: {}\n", + "deploy/validation/Caddyfile": "https://modelforge.example.test:8443 {}\n", + "backend/Dockerfile": ( + 'LABEL org.opencontainers.image.source="https://git.example.com/' + 'Jens/ITWorx-ModelForge"\n' + ), + "frontend/Dockerfile": ( + 'LABEL org.opencontainers.image.source="https://git.example.com/' + 'Jens/ITWorx-ModelForge"\n' + ), + "node-agent/Dockerfile": ( + 'LABEL org.opencontainers.image.source="https://git.example.com/' + 'Jens/ITWorx-ModelForge"\n' + ), + "runtime-worker/Dockerfile": ( + 'LABEL org.opencontainers.image.source="https://git.example.com/' + 'Jens/ITWorx-ModelForge"\n' + ), "VERSION": "1.2.1\n", "backend/pyproject.toml": "[project]\nname = 'fixture-api'\n", + "clients/README.md": "# Client usage\n", + "clients/python/modelforge_client.py": "# Python client\n", + "clients/typescript/modelforge-client.mjs": "// JavaScript client\n", + "clients/typescript/modelforge-client.d.mts": "// TypeScript declarations\n", + "scripts/m16_chaos.py": "# Chaos scenarios\n", + "scripts/m16_invariants.py": "# Invariant checks\n", + "scripts/m16_soak.py": "# Soak checks\n", + "scripts/operator_identity.py": "# Operator identities\n", + "scripts/rc_isolation_preflight.py": "# RC isolation\n", + "scripts/rc_stack_retention.py": "# RC retention\n", "frontend/package.json": '{"name":"fixture-web"}\n', "node-agent/pyproject.toml": "[project]\nname = 'fixture-agent'\n", "runtime-worker/pyproject.toml": "[project]\nname = 'fixture-worker'\n", @@ -104,6 +133,59 @@ class PublicSourceExportTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) validation = self._validate(output) self.assertEqual(validation.returncode, 0, validation.stdout + validation.stderr) + self.assertTrue((output / "clients/python/modelforge_client.py").is_file()) + self.assertTrue((output / "clients/typescript/modelforge-client.mjs").is_file()) + manifest = json.loads((output / "PUBLIC_SOURCE_MANIFEST.json").read_text()) + self.assertEqual( + manifest["sourceRepository"], "https://git.example.com/example/modelforge" + ) + bound_paths = {entry["path"] for entry in manifest["files"]} + for relative_path in ( + "backend/Dockerfile", + "frontend/Dockerfile", + "node-agent/Dockerfile", + "runtime-worker/Dockerfile", + ): + self.assertIn(relative_path, bound_paths) + self.assertIn( + 'org.opencontainers.image.source="https://git.example.com/example/modelforge"', + (output / relative_path).read_text(), + ) + + dockerfile_path = output / "backend/Dockerfile" + original_dockerfile = dockerfile_path.read_bytes() + dockerfile_entry = next( + entry for entry in manifest["files"] if entry["path"] == "backend/Dockerfile" + ) + forged_dockerfile = original_dockerfile.replace( + b"https://git.example.com/example/modelforge", + b"https://attacker.invalid/repository", + ) + dockerfile_path.write_bytes(forged_dockerfile) + dockerfile_entry["bytes"] = len(forged_dockerfile) + dockerfile_entry["sha256"] = hashlib.sha256(forged_dockerfile).hexdigest() + (output / "PUBLIC_SOURCE_MANIFEST.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + identity_tampering = self._validate(output) + self.assertNotEqual(identity_tampering.returncode, 0) + self.assertIn("source label does not match", identity_tampering.stderr) + + dockerfile_path.write_bytes(original_dockerfile) + original_manifest = json.loads( + (output / "PUBLIC_SOURCE_MANIFEST.json").read_text(encoding="utf-8") + ) + original_manifest["files"] = [ + { + "path": entry["path"], + "bytes": len((output / entry["path"]).read_bytes()), + "sha256": hashlib.sha256((output / entry["path"]).read_bytes()).hexdigest(), + } + for entry in manifest["files"] + ] + (output / "PUBLIC_SOURCE_MANIFEST.json").write_text( + json.dumps(original_manifest), encoding="utf-8" + ) original_readme = (output / "README.md").read_bytes() (output / "README.md").write_text("tampered\n", encoding="utf-8") @@ -127,6 +209,41 @@ class PublicSourceExportTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn("commit the exact source tree", result.stderr) + def test_private_owner_inventory_is_excluded_from_public_source(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self._repository(root) + private_files = { + "scripts/portfolio_gitea_snapshot.py": "ExampleRAG owner inventory\n", + "scripts/portfolio_gitea_repository_ids.json": '{"private-repo": 12}\n', + "backend/tests/test_portfolio_gitea_snapshot.py": "192." + "168.1.40\n", + "docs/operations/PORTFOLIO_GITEA_SNAPSHOT.md": "Private owner audit\n", + } + for relative_path, contents in private_files.items(): + destination = repository / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(contents, encoding="utf-8") + public_script = repository / "scripts/public.py" + public_script.write_text("print('public')\n", encoding="utf-8") + with (repository / "public-source.allowlist").open("a", encoding="utf-8") as allowlist: + allowlist.write("scripts/**\nbackend/**\ndocs/operations/**\n") + subprocess.run(["git", "add", "."], cwd=repository, check=True) + subprocess.run( + [ + "git", "-c", "user.name=ModelForge test", "-c", "user.email=test@example.invalid", + "commit", "-qm", "private inventory fixture", + ], + cwd=repository, + check=True, + ) + output = root / "public" + result = self._export(repository, output) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self._validate(output).returncode, 0) + self.assertTrue((output / "scripts/public.py").is_file()) + for relative_path in private_files: + self.assertFalse((output / relative_path).exists()) + def test_sanitized_destination_collisions_fail_before_writing(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/.gitea/tests/test_rc_acceptance_policy.py b/.gitea/tests/test_rc_acceptance_policy.py index 650388a..1aca4ff 100644 --- a/.gitea/tests/test_rc_acceptance_policy.py +++ b/.gitea/tests/test_rc_acceptance_policy.py @@ -2,13 +2,15 @@ from __future__ import annotations -import subprocess import importlib.util import json +import os +import signal +import subprocess import tempfile import unittest from pathlib import Path - +from unittest import mock ROOT = Path(__file__).resolve().parents[2] WORKFLOW = ROOT / ".gitea" / "workflows" / "public-candidate-acceptance.yml" @@ -39,9 +41,23 @@ class RcAcceptancePolicyTests(unittest.TestCase): def test_exact_source_and_docker_are_required(self) -> None: self.assertIn(r"^[0-9a-f]{40}$", self.workflow) self.assertIn("ref: ${{ inputs.source_commit }}", self.workflow) + self.assertIn("--canonical-source-commit", self.workflow) + self.assertIn('"--canonical-source-commit", canonical_commit', self.acceptance) + self.assertIn('"source_commit": canonical_commit', self.acceptance) self.assertIn("docker version", self.workflow) self.assertNotIn("SKIPPED: no docker", self.workflow) + def test_optional_candidate_freeze_retains_only_a_verified_transport_bundle(self) -> None: + self.assertIn("retain_image_bundle:", self.workflow) + self.assertIn("RETAIN_IMAGE_BUNDLE: ${{ inputs.retain_image_bundle }}", self.workflow) + self.assertIn("--retain-image-bundle", self.workflow) + self.assertIn("--image-bundle", self.acceptance) + self.assertIn("acceptance-evidence/release/*.tar", self.workflow) + self.assertIn("acceptance-evidence/release/*.tar.gz", self.workflow) + self.assertIn("atexit.register(cleanup_candidate_images", self.acceptance) + self.assertIn('canonical_commit="${{ inputs.source_commit }}"', self.workflow) + self.assertNotIn("candidate_commit=", self.workflow) + def test_security_tools_are_downloaded_with_exact_checksums(self) -> None: self.assertIn("GITLEAKS_VERSION: 8.30.1", self.workflow) self.assertIn( @@ -78,18 +94,38 @@ class RcAcceptancePolicyTests(unittest.TestCase): self.assertIn("apt-get purge --yes linux-libc-dev", self.runtime_dockerfile) self.assertNotIn("apt-get autoremove", self.runtime_dockerfile) self.assertIn("pip check", self.runtime_dockerfile) + self.assertIn("python -m pip uninstall --yes pip setuptools wheel", self.runtime_dockerfile) for dependency in ( + "filelock==3.20.3", + "h2==4.4.1", + "idna==3.15", + "lief==0.17.2", "Pillow==12.3.0", + "Pygments==2.20.0", "protobuf==5.29.6", + "requests==2.33.0", "sentencepiece==0.2.2", "sentence-transformers==6.0.1", "transformers==5.16.1", "urllib3==2.7.0", ): self.assertIn(dependency, self.runtime_pyproject) + self.assertNotIn("'filelock==3.20.3'", self.runtime_dockerfile) + + def test_runtime_worker_has_only_group_read_access_to_the_agent_credential(self) -> None: + self.assertIn( + "groupadd --system --gid 101 modelforge-agent", self.runtime_dockerfile + ) + self.assertIn( + "--groups modelforge-agent --home /app modelforge-runtime", + self.runtime_dockerfile, + ) def test_compose_project_is_isolated_and_always_removed(self) -> None: - self.assertIn('project = f"modelforge-rc-', self.acceptance) + self.assertIn('project = f"modelforge-validation-', self.acceptance) + self.assertIn('"MODELFORGE_SOURCE_REFERENCE": "validation-acceptance"', self.acceptance) + self.assertIn('project="modelforge-validation-${suffix}"', self.workflow) + self.assertNotIn('project = f"modelforge-rc-', self.acceptance) self.assertIn('"MODELFORGE_API_BIND": "127.0.0.1"', self.acceptance) self.assertIn('"MODELFORGE_API_PUBLISHED_PORT": "0"', self.acceptance) self.assertIn('"production_changed": False', self.acceptance) @@ -100,6 +136,123 @@ class RcAcceptancePolicyTests(unittest.TestCase): self.assertNotIn("docker-compose.runtime-worker.yml", self.acceptance) self.assertNotIn("docker-compose.node-agent.yml", self.acceptance) + def test_acceptance_override_disables_restarts_for_every_service(self) -> None: + spec = importlib.util.spec_from_file_location("rc_server_acceptance", ACCEPTANCE) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + write_override = getattr(module, "write_acceptance_compose_override", None) + self.assertIsNotNone( + write_override, + "acceptance must expose the same override writer used by the RC run", + ) + + with tempfile.TemporaryDirectory() as directory: + override = Path(directory) / "acceptance-compose.override.yml" + write_override(override) + env = os.environ.copy() + env.update( + { + "MODELFORGE_VERSION": "9.9.9-test", + "MODELFORGE_COMMIT": "a" * 40, + "MODELFORGE_BUILT_AT": "2099-01-01T00:00:00+00:00", + "MODELFORGE_POSTGRES_DB": "modelforge_validation_test", + "MODELFORGE_POSTGRES_ADMIN_PASSWORD": "admin-test-password", + "MODELFORGE_MIGRATION_DB_PASSWORD": "owner-test-password", + "MODELFORGE_RUNTIME_DB_PASSWORD": "runtime-test-password", + "MODELFORGE_MIGRATION_DATABASE_URL": ( + "postgresql+psycopg://modelforge:owner-test-password@" + "postgres:5432/modelforge_validation_test" + ), + "MODELFORGE_RUNTIME_DATABASE_URL": ( + "postgresql+psycopg://modelforge_runtime:runtime-test-password@" + "postgres:5432/modelforge_validation_test" + ), + "MODELFORGE_BACKUP_ENCRYPTION_KEY": "acceptance-test-key", + "MODELFORGE_CORS_ORIGINS": "https://modelforge.example.test", + "VITE_API_BASE_URL": "https://modelforge.example.test", + } + ) + result = subprocess.run( + [ + "docker", + "compose", + "-f", + str(ROOT / "docker-compose.yml"), + "-f", + str(ROOT / "docker-compose.production.yml"), + "-f", + str(override), + "config", + "--format", + "json", + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + services = json.loads(result.stdout)["services"] + self.assertTrue(services) + self.assertEqual( + {name: service.get("restart") for name, service in services.items()}, + {name: "no" for name in services}, + ) + + def test_compose_cleanup_runs_when_acceptance_receives_sigterm(self) -> None: + spec = importlib.util.spec_from_file_location("rc_server_acceptance_cleanup", ACCEPTANCE) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + managed_cleanup = getattr(module, "managed_compose_cleanup", None) + self.assertIsNotNone( + managed_cleanup, + "acceptance must guard its RC project with interruption-safe cleanup", + ) + + compose = ("docker", "compose", "-p", "modelforge-validation-test") + env = {"COMPOSE_PROJECT_NAME": "modelforge-validation-test"} + commands: list[tuple[tuple[str, ...], dict[str, object]]] = [] + registered: list[object] = [] + unregistered: list[object] = [] + handlers: dict[int, object] = {} + + def fake_run(*args: str, **kwargs: object) -> str: + commands.append((args, kwargs)) + return "" + + def fake_signal(signum: int, handler: object) -> object: + previous = handlers.get(signum, signal.SIG_DFL) + handlers[signum] = handler + return previous + + with ( + mock.patch.object(module, "run", side_effect=fake_run), + mock.patch.object(module.atexit, "register", side_effect=registered.append), + mock.patch.object(module.atexit, "unregister", side_effect=unregistered.append), + mock.patch.object(module.signal, "signal", side_effect=fake_signal), + self.assertRaises(SystemExit) as raised, + managed_cleanup(compose, env), + ): + handler = handlers[signal.SIGTERM] + assert callable(handler) + handler(signal.SIGTERM, None) + + self.assertEqual(raised.exception.code, 128 + signal.SIGTERM) + self.assertEqual( + commands, + [ + ( + compose + ("down", "--volumes", "--remove-orphans"), + {"env": env, "check": False}, + ) + ], + ) + self.assertEqual(len(registered), 1) + self.assertEqual(unregistered, registered) + def test_clean_install_bootstraps_roles_across_a_remote_docker_daemon(self) -> None: self.assertIn("def provision_database_roles(", self.acceptance) self.assertIn('run("docker", "cp", str(bootstrap)', self.acceptance) @@ -152,7 +305,7 @@ class RcAcceptancePolicyTests(unittest.TestCase): self.assertNotIn("docker system prune", cleanup) def test_python_entrypoints_compile(self) -> None: - for path in (ACCEPTANCE, TRIVY_VALIDATOR): + for path in (ACCEPTANCE, TRIVY_VALIDATOR, ROOT / "scripts" / "release_build.py"): result = subprocess.run( ["python", "-m", "py_compile", str(path)], capture_output=True, diff --git a/.gitea/tests/test_rc_isolation_preflight.py b/.gitea/tests/test_rc_isolation_preflight.py new file mode 100644 index 0000000..775082a --- /dev/null +++ b/.gitea/tests/test_rc_isolation_preflight.py @@ -0,0 +1,215 @@ +"""Unit tests for the fail-closed P12 RC isolation preflight.""" + +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("rc_isolation_preflight", ROOT / "scripts" / "rc_isolation_preflight.py") +assert SPEC is not None and SPEC.loader is not None +preflight = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(preflight) + + +PROJECT = "modelforge-validation-p12a" + + +def compose_plan(root: Path) -> dict[str, object]: + return { + "services": { + "api": { + "networks": ["default"], + "volumes": [ + {"type": "volume", "source": "postgres-data", "target": "/data"}, + {"type": "bind", "source": str(root / "config"), "target": "/app/config", "read_only": True}, + ], + "ports": [{"host_ip": "127.0.0.1", "published": "0", "target": 8000}], + }, + "node-agent": { + "networks": ["default"], + "volumes": [{"type": "volume", "source": "node-state", "target": "/data/state"}], + }, + }, + "volumes": { + "postgres-data": {"name": f"{PROJECT}_postgres-data"}, + "node-state": {"name": f"{PROJECT}_node-state"}, + }, + "networks": {"default": {"name": f"{PROJECT}_default"}}, + } + + +class RcIsolationPreflightTests(unittest.TestCase): + def plan_root(self, *, with_manifest: bool = True) -> tempfile.TemporaryDirectory[str]: + temporary = tempfile.TemporaryDirectory() + config = Path(temporary.name, "config") + config.mkdir() + if with_manifest: + manifest = config / "models" / "initial-candidates.yaml" + manifest.parent.mkdir() + manifest.write_text("candidates: []\n", encoding="utf-8") + self.addCleanup(temporary.cleanup) + return temporary + + def validate( + self, + plan: dict[str, object], + root: Path, + *, + ports: set[int] | None = None, + names: set[str] | None = None, + ) -> dict[str, object]: + return preflight.validate_compose( + plan, + project=PROJECT, + candidate_root=root, + protected_ports=ports or set(), + protected_resource_names=names or set(), + ) + + def test_valid_validation_compose_plan_is_accepted(self) -> None: + temporary = self.plan_root() + root = Path(temporary.name) + result = self.validate( + compose_plan(root), root, ports={18000, 13000}, names={"modelforge-postgres-1"} + ) + self.assertEqual(result["project"], PROJECT) + self.assertEqual(result["published_ports"], []) + + def test_project_validation_rejects_production_and_legacy_rc_names(self) -> None: + for project in ( + "modelforge", + "modelforge-rc-p12a", + "modelforge-validation-", + "modelforge-validation-prod-", + ): + with self.subTest(project=project), self.assertRaises(preflight.IsolationError): + preflight.validate_project(project) + + def test_missing_bind_source_is_rejected(self) -> None: + temporary = self.plan_root(with_manifest=False) + root = Path(temporary.name) + plan = compose_plan(root) + (root / "config").rmdir() + with self.assertRaisesRegex(preflight.IsolationError, "missing bind source"): + self.validate(plan, root) + + def test_config_manifest_must_exist_under_exact_bind_source(self) -> None: + temporary = self.plan_root(with_manifest=False) + root = Path(temporary.name) + plan = compose_plan(root) + # A manifest elsewhere in the candidate tree cannot satisfy this bind's contract. + manifest = root / "models" / "initial-candidates.yaml" + manifest.parent.mkdir() + manifest.write_text("candidates: []\n", encoding="utf-8") + with self.assertRaisesRegex(preflight.IsolationError, "missing models/initial-candidates.yaml under its source"): + self.validate(plan, root) + + def test_rendered_compose_network_mapping_is_accepted(self) -> None: + temporary = self.plan_root() + root = Path(temporary.name) + plan = compose_plan(root) + plan["services"]["api"]["networks"] = {"default": None} # type: ignore[index] + self.assertEqual(self.validate(plan, root)["declared_networks"], ["default"]) + + def test_unsafe_compose_plans_are_rejected(self) -> None: + cases = [ + (lambda plan: plan["services"]["api"].update(network_mode="host"), "host network"), + (lambda plan: plan["services"]["api"].update(container_name="modelforge-api-1"), "global container name"), + (lambda plan: plan["services"]["api"]["ports"].__setitem__(0, {"host_ip": "0.0.0.0", "published": "19000", "target": 8000}), "non-loopback"), + (lambda plan: plan["services"]["api"]["ports"].__setitem__(0, {"host_ip": "127.0.0.1", "published": "18000", "target": 8000}), "protected host port"), + (lambda plan: plan["services"]["api"]["volumes"].__setitem__(1, {"type": "bind", "source": "/mnt/user/appdata/modelforge", "target": "/data", "read_only": True}), "escapes the candidate root"), + (lambda plan: plan["services"]["api"]["volumes"].__setitem__(1, {"type": "bind", "source": "config", "target": "/app/config", "read_only": False}), "writable bind mount"), + (lambda plan: plan["volumes"]["postgres-data"].update(external=True), "is external"), + (lambda plan: plan["networks"]["default"].update(name="modelforge_default"), "non-project name"), + ] + for mutate, message in cases: + with self.subTest(message=message): + temporary = self.plan_root() + root = Path(temporary.name) + plan = compose_plan(root) + mutate(plan) + with self.assertRaisesRegex(preflight.IsolationError, message): + self.validate(plan, root, ports={18000}) + + def test_protected_resource_reference_is_rejected(self) -> None: + temporary = self.plan_root() + root = Path(temporary.name) + plan = compose_plan(root) + plan["services"]["api"]["environment"] = {"UNSAFE": "modelforge-postgres-1"} # type: ignore[index] + with self.assertRaisesRegex(preflight.IsolationError, "protected resources"): + self.validate(plan, root, names={"modelforge-postgres-1"}) + + def test_only_isolated_tls_service_can_publish_test_port(self) -> None: + temporary = self.plan_root() + root = Path(temporary.name) + source = root / "source" / "deploy" / "validation" + source.mkdir(parents=True) + (source / "Caddyfile").write_text("https://modelforge.example.com:8443 {}\n", encoding="utf-8") + certificates = root / "tls" + certificates.mkdir() + (certificates / "fullchain.pem").write_text("certificate", encoding="utf-8") + (certificates / "privkey.pem").write_text("key", encoding="utf-8") + plan = compose_plan(root) + plan["networks"]["ingress"] = {"name": f"{PROJECT}_ingress"} + plan["services"]["tls"] = { + "image": "caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d", + "networks": ["ingress"], "read_only": True, "cap_drop": ["ALL"], + "security_opt": ["no-new-privileges:true"], + "volumes": [ + {"type": "bind", "source": str(source / "Caddyfile"), "target": "/etc/caddy/Caddyfile", "read_only": True}, + {"type": "bind", "source": str(certificates / "fullchain.pem"), "target": "/run/tls/fullchain.pem", "read_only": True}, + {"type": "bind", "source": str(certificates / "privkey.pem"), "target": "/run/tls/privkey.pem", "read_only": True}, + ], + "ports": [{"host_ip": "0.0.0.0", "published": "18444", "target": 8443, "protocol": "tcp"}], + } + with self.assertRaisesRegex(preflight.IsolationError, "non-loopback"): + self.validate(plan, root) + self.assertEqual(preflight.validate_compose( + plan, project=PROJECT, candidate_root=root, + protected_ports={443, 8443}, protected_resource_names=set(), public_tls_port=18444, + )["published_ports"], [18444]) + plan["services"]["api"]["ports"][0] = {"host_ip": "0.0.0.0", "published": "18444", "target": 8000} + with self.assertRaisesRegex(preflight.IsolationError, "non-loopback"): + preflight.validate_compose( + plan, project=PROJECT, candidate_root=root, + protected_ports=set(), protected_resource_names=set(), public_tls_port=18444, + ) + + def test_observed_resources_require_exact_labels_and_project_names(self) -> None: + observed = { + "containers": [{"name": f"{PROJECT}-api-1", "labels": {"com.docker.compose.project": PROJECT}}], + "volumes": [{"name": f"{PROJECT}_postgres-data", "labels": {"com.docker.compose.project": PROJECT}}], + "networks": [{"name": f"{PROJECT}_default", "labels": {"com.docker.compose.project": PROJECT}}], + } + self.assertEqual(preflight.validate_observed_resources(observed, project=PROJECT)["containers"], [f"{PROJECT}-api-1"]) + observed["volumes"][0]["labels"] = {} + with self.assertRaisesRegex(preflight.IsolationError, "no exact project label"): + preflight.validate_observed_resources(observed, project=PROJECT) + + def test_docker_resource_collection_uses_exact_inspection(self) -> None: + label = {"com.docker.compose.project": PROJECT} + responses = { + ("container", "ls", "--all", "--quiet", "--filter", f"label=com.docker.compose.project={PROJECT}"): "container-id\n", + ("volume", "ls", "--quiet", "--filter", f"label=com.docker.compose.project={PROJECT}"): "volume-id\n", + ("network", "ls", "--quiet", "--filter", f"label=com.docker.compose.project={PROJECT}"): "network-id\n", + ("container", "inspect", "container-id"): '[{"Name":"/' + PROJECT + '-api-1","Config":{"Labels":{"com.docker.compose.project":"' + PROJECT + '"}}}]', + ("volume", "inspect", "volume-id"): '[{"Name":"' + PROJECT + '_data","Labels":{"com.docker.compose.project":"' + PROJECT + '"}}]', + ("network", "inspect", "network-id"): '[{"Name":"' + PROJECT + '_default","Labels":{"com.docker.compose.project":"' + PROJECT + '"}}]', + } + + def run(command, **_kwargs): + self.assertEqual(command[0], "docker") + return type("Result", (), {"returncode": 0, "stdout": responses[tuple(command[1:])]})() + + with patch.object(preflight.subprocess, "run", run): + observed = preflight.collect_observed_resources(PROJECT) + self.assertEqual(observed["containers"][0]["labels"], label) + self.assertEqual( + preflight.validate_observed_resources(observed, project=PROJECT)["networks"], + [f"{PROJECT}_default"], + ) diff --git a/.gitea/tests/test_rc_stack_retention.py b/.gitea/tests/test_rc_stack_retention.py new file mode 100644 index 0000000..684886e --- /dev/null +++ b/.gitea/tests/test_rc_stack_retention.py @@ -0,0 +1,316 @@ +"""Focused tests for RC stack inventory and stop-only retention guard.""" + +from __future__ import annotations + +import importlib.util +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "rc_stack_retention", ROOT / "scripts" / "rc_stack_retention.py" +) +assert SPEC is not None and SPEC.loader is not None +retention = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(retention) + + +PROJECT = "modelforge-validation-123271" +NOW = datetime(2026, 9, 23, 10, 0, tzinfo=UTC) +CREATED = "2026-09-20T10:00:00Z" + + +class MockDocker: + def __init__(self, containers: dict[str, dict[str, object]]) -> None: + self.containers = containers + self.calls: list[tuple[str, ...]] = [] + + def run(self, command: tuple[str, ...], **_kwargs: object) -> SimpleNamespace: + self.assert_docker(command) + args = tuple(command[1:]) + self.calls.append(args) + if args[:4] == ( + "container", "ls", "--all", "--quiet" + ): + return SimpleNamespace( + returncode=0, + stdout="\n".join(self.containers) + "\n", + stderr="", + ) + if args[:2] == ("container", "inspect"): + identifier = args[2] + return SimpleNamespace( + returncode=0, + stdout=json.dumps([self.containers[identifier]]), + stderr="", + ) + if args[:1] == ("stop",): + identifiers = args[3:] + for identifier in identifiers: + state = self.containers[identifier]["State"] + assert isinstance(state, dict) + state["Status"] = "exited" + return SimpleNamespace(returncode=0, stdout="\n".join(identifiers), stderr="") + raise AssertionError(f"unexpected Docker command: {args}") + + @staticmethod + def assert_docker(command: tuple[str, ...]) -> None: + if command[0] != "docker": + raise AssertionError(f"unexpected executable: {command[0]}") + + +def docker_container( + identifier: str, + project: str, + service: str, + *, + status: str = "exited", + source: str | None = None, + created: str = CREATED, + oneoff: str = "False", +) -> dict[str, object]: + mounts: list[dict[str, str]] = [] + if source is not None: + mounts.append({"Type": "bind", "Source": source, "Destination": "/app/config"}) + return { + "Id": identifier, + "Name": f"/{project}-{service}-1", + "Created": created, + "Config": { + "Labels": { + retention.PROJECT_LABEL: project, + retention.SERVICE_LABEL: service, + retention.ONEOFF_LABEL: oneoff, + } + }, + "State": {"Status": status, "Running": status == "running"}, + "Mounts": mounts, + } + + +class RcStackRetentionTests(unittest.TestCase): + def fixture(self, *, api_status: str = "exited", service: str = "postgres", oneoff: str = "False") -> tuple[MockDocker, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + config = Path(temporary.name) / "config" + config.mkdir() + containers = { + "validation-api-id": docker_container("validation-api-id", PROJECT, "api", source=str(config)), + "validation-db-id": docker_container( + "validation-db-id", PROJECT, service, status="running", oneoff=oneoff + ), + "prod-api-id": docker_container("prod-api-id", "modelforge", "api", status="running"), + "other-api-id": docker_container( + "other-api-id", "staging", "api", status="running" + ), + } + if api_status != "exited": + containers["validation-api-id"]["State"] = {"Status": api_status, "Running": True} + return MockDocker(containers), temporary + + @staticmethod + def write_evidence(path: Path, project: str = PROJECT) -> None: + path.write_text( + json.dumps( + { + "schema_version": 1, + "project": project, + "retained": True, + "evidence_reference": "acceptance-run-2026-09-20", + } + ), + encoding="utf-8", + ) + + def test_inventory_scopes_validation_projects_and_flags_manifest_and_age(self) -> None: + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + with patch.object(retention.subprocess, "run", docker.run): + projects = retention.collect_projects(now=NOW, stale_after_hours=24) + + self.assertEqual([row["project"] for row in projects], [PROJECT]) + self.assertTrue(projects[0]["stale"]) + self.assertEqual(len(projects[0]["missing_manifests"]), 1) + self.assertEqual(projects[0]["missing_bind_sources"], []) + self.assertNotIn("volumes", projects[0]) + self.assertNotIn("images", projects[0]) + self.assertTrue(all(call[:1] != ("stop",) for call in docker.calls)) + self.assertTrue(all(call[:1] != ("rm",) for call in docker.calls)) + + def test_inventory_ignores_non_validation_partial_compose_labels(self) -> None: + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + docker.containers["unrelated-id"] = { + "Id": "unrelated-id", + "Name": "/projectbrain-worker-1", + "Config": {"Labels": {retention.PROJECT_LABEL: "projectbrain"}}, + "State": {"Status": "exited"}, + } + + with patch.object(retention.subprocess, "run", docker.run): + projects = retention.collect_projects(now=NOW, stale_after_hours=24) + + self.assertEqual([row["project"] for row in projects], [PROJECT]) + + def test_inventory_fails_closed_for_validation_container_missing_service_label(self) -> None: + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + labels = docker.containers["validation-api-id"]["Config"]["Labels"] + assert isinstance(labels, dict) + del labels[retention.SERVICE_LABEL] + + with patch.object(retention.subprocess, "run", docker.run), self.assertRaisesRegex( + retention.RetentionError, "Compose service label is missing" + ): + retention.collect_projects(now=NOW, stale_after_hours=24) + + def test_default_cli_is_read_only(self) -> None: + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + output = io.StringIO() + with patch.object(retention.subprocess, "run", docker.run), redirect_stdout(output): + status = retention.main([]) + + result = json.loads(output.getvalue()) + self.assertEqual(status, 0) + self.assertEqual(result["mode"], "read-only") + self.assertTrue(all(call[:1] != ("stop",) for call in docker.calls)) + + def test_docker_errors_and_ambiguous_inspection_fail_closed(self) -> None: + failed = SimpleNamespace(returncode=1, stdout="", stderr="daemon unavailable") + with patch.object(retention.subprocess, "run", return_value=failed), self.assertRaisesRegex( + retention.RetentionError, "Docker command failed" + ): + retention.collect_projects(now=NOW) + + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + docker.containers["validation-api-id"]["Id"] = "different-id" + with patch.object(retention.subprocess, "run", docker.run), self.assertRaisesRegex( + retention.RetentionError, "different container" + ): + retention.collect_projects(now=NOW) + + def test_project_validation_rejects_production_and_ambiguous_names(self) -> None: + for project in ( + "modelforge", + "modelforge-rc-prod-", + "modelforge-validation-", + "modelforge-validation-prod-", + ): + with self.subTest(project=project), self.assertRaises(retention.RetentionError): + retention.validate_project(project) + + def test_stop_requires_explicit_exact_project_confirmation_and_evidence(self) -> None: + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + empty_record = Path(temporary.name) / "empty.json" + empty_record.write_text("", encoding="utf-8") + with patch.object(retention.subprocess, "run", docker.run): + with self.assertRaisesRegex(retention.RetentionError, "exact project name"): + retention.stop_project( + PROJECT, + confirm_stop=None, + confirm_no_active_jobs=True, + evidence_record=Path("evidence.json"), + stale_after_hours=24, + now=NOW, + ) + with self.assertRaisesRegex(retention.RetentionError, "nonempty regular file"): + retention.stop_project( + PROJECT, + confirm_stop=PROJECT, + confirm_no_active_jobs=True, + evidence_record=empty_record, + stale_after_hours=24, + now=NOW, + ) + mismatch = Path(temporary.name) / "mismatch.json" + self.write_evidence(mismatch, project="modelforge-validation-someone-else") + with patch.object(retention.subprocess, "run", docker.run), self.assertRaisesRegex( + retention.RetentionError, "identify this project" + ): + retention.stop_project( + PROJECT, + confirm_stop=PROJECT, + confirm_no_active_jobs=True, + evidence_record=mismatch, + stale_after_hours=24, + now=NOW, + ) + self.assertEqual(docker.calls, []) + + def test_stop_only_targets_inspected_project_container_ids(self) -> None: + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + evidence = Path(temporary.name) / "evidence.json" + self.write_evidence(evidence) + with patch.object(retention.subprocess, "run", docker.run): + result = retention.stop_project( + PROJECT, + confirm_stop=PROJECT, + confirm_no_active_jobs=True, + evidence_record=evidence, + stale_after_hours=24, + now=NOW, + ) + + self.assertEqual(result["stopped_container_ids"], ["validation-db-id"]) + self.assertFalse(result["volumes_or_images_removed"]) + stop_calls = [call for call in docker.calls if call[:1] == ("stop",)] + self.assertEqual(stop_calls, [("stop", "--time", "30", "validation-db-id")]) + self.assertTrue(all("prod-api-id" not in call for call in stop_calls)) + self.assertTrue(all(call[:1] not in {("rm",), ("rmi",), ("volume",)} for call in docker.calls)) + + def test_stop_fails_if_api_or_oneoff_job_is_running(self) -> None: + for options, message in ( + ({"api_status": "running"}, "API is running"), + ({"api_status": "restarting"}, "API is running"), + ({"oneoff": "True"}, "one-off job container"), + ({"service": "migrate"}, "service activity is unknown"), + ): + with self.subTest(message=message): + docker, temporary = self.fixture(**options) + self.addCleanup(temporary.cleanup) + evidence = Path(temporary.name) / "evidence.json" + self.write_evidence(evidence) + with patch.object(retention.subprocess, "run", docker.run), self.assertRaisesRegex( + retention.RetentionError, message + ): + retention.stop_project( + PROJECT, + confirm_stop=PROJECT, + confirm_no_active_jobs=True, + evidence_record=evidence, + stale_after_hours=24, + now=NOW, + ) + self.assertTrue(all(call[:1] != ("stop",) for call in docker.calls)) + + def test_stop_requires_job_attestation(self) -> None: + docker, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + evidence = Path(temporary.name) / "evidence.json" + self.write_evidence(evidence) + with patch.object(retention.subprocess, "run", docker.run), self.assertRaisesRegex( + retention.RetentionError, "--confirm-no-active-jobs" + ): + retention.stop_project( + PROJECT, + confirm_stop=PROJECT, + confirm_no_active_jobs=False, + evidence_record=evidence, + stale_after_hours=24, + now=NOW, + ) + self.assertEqual(docker.calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitea/tests/test_security_origin_policy.py b/.gitea/tests/test_security_origin_policy.py new file mode 100644 index 0000000..02f0e84 --- /dev/null +++ b/.gitea/tests/test_security_origin_policy.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import unittest +from pathlib import Path +from urllib.parse import urlsplit + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".gitea" / "workflows" / "managed-validation.yml" +CANONICAL_ORIGIN = "https://git.example.com" + + +def _origin_case_script() -> str: + text = WORKFLOW.read_text(encoding="utf-8") + match = re.search( + r'(?ms)^ case "\$\{GITHUB_SERVER_URL%/\}" in\n' + r'.*?^ esac$', + text, + ) + if match is None: + raise AssertionError("managed validation has no GITHUB_SERVER_URL case policy") + # Remove workflow indentation before executing only the bounded origin policy. + return "\n".join(line[10:] for line in match.group(0).splitlines()) + + +def _evaluate_origin(origin: str) -> subprocess.CompletedProcess[str]: + script = ( + "set -euo pipefail\n" + + _origin_case_script() + + "\nprintf '%s\\n' \"${SOURCE_SERVER_URL}\"\n" + ) + env = os.environ.copy() + env["GITHUB_SERVER_URL"] = origin + bash = shutil.which("bash") or "bash" + if os.name == "nt": + # System32/bash.exe starts WSL, which does not inherit arbitrary Windows environment + # variables. Git Bash runs natively and tests the exact environment used by this fixture. + git = shutil.which("git") + git_bash = Path(git).resolve().parents[1] / "bin" / "bash.exe" if git else None + if git_bash is None or not git_bash.is_file(): + raise RuntimeError("Git Bash is required for Windows origin policy tests") + bash = str(git_bash) + return subprocess.run( + [bash, "--noprofile", "--norc", "-c", script], + cwd=ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def _safe_origin(value: str) -> str: + parsed = urlsplit(value) + hostname = parsed.hostname or "(missing-host)" + port = f":{parsed.port}" if parsed.port is not None else "" + return f"{parsed.scheme or '(missing-scheme)'}://{hostname}{port}" + + +class SecurityOriginPolicyTests(unittest.TestCase): + def test_known_runner_and_public_origins_canonicalize_to_https(self) -> None: + accepted = ( + "https://git.example.com", + "https://git.example.com/", + "http://git.example.com", + "http://git.example.com/", + "http://192.0.2.10:3000", + "http://192.0.2.10:3000/", + "http://gitea:3000", + "http://gitea:3000/", + "http://Gitea:3000", + "http://Gitea:3000/", + "http://gitea-runner-internal:3000", + "http://gitea-runner-internal:3000/", + ) + for origin in accepted: + with self.subTest(origin=origin): + result = _evaluate_origin(origin) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), CANONICAL_ORIGIN) + + def test_unknown_http_and_https_origins_fail_closed(self) -> None: + for origin in ( + "http://untrusted.invalid", + "https://untrusted.invalid", + "http://192.0.2.151:3000", + "http://gitea:3001", + ): + with self.subTest(origin=origin): + result = _evaluate_origin(origin) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Untrusted Gitea server URL", result.stderr) + + def test_live_runner_origin_is_explicitly_covered_when_present(self) -> None: + origin = os.environ.get("GITHUB_SERVER_URL") + if not origin: + self.skipTest("GITHUB_SERVER_URL is only expected inside the managed runner") + result = _evaluate_origin(origin) + self.assertEqual( + result.returncode, + 0, + f"managed runner origin is not covered: {_safe_origin(origin)}; {result.stderr.strip()}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitea/tests/test_security_validation_policy.py b/.gitea/tests/test_security_validation_policy.py new file mode 100644 index 0000000..c9008b9 --- /dev/null +++ b/.gitea/tests/test_security_validation_policy.py @@ -0,0 +1,2529 @@ +"""Policy and synthetic evaluator tests for the fail-closed managed security gate.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import re +import shutil +import stat +import subprocess +import tempfile +import unittest +from datetime import UTC, date, datetime +from pathlib import Path +from types import SimpleNamespace +from typing import Any, ClassVar +from unittest import mock + +import tomllib + +from scripts import security_gate as gate + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_PATH = REPOSITORY_ROOT / ".gitea" / "workflows" / "managed-validation.yml" +GITLEAKS_CONFIG = REPOSITORY_ROOT / ".gitleaks.toml" +PIP_AUDIT_LOCK = REPOSITORY_ROOT / "config" / "security" / "pip-audit-toolchain.lock" +ACCEPTED_RISKS = REPOSITORY_ROOT / "config" / "security" / "accepted-risks.json" +GITLEAKS_FALSE_POSITIVES = ( + REPOSITORY_ROOT / "config" / "security" / "gitleaks-false-positives.json" +) +PRODUCT_VERSION = (REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8").strip() + + +def _indented_block(text: str, header: str) -> str: + lines = text.splitlines() + try: + start = lines.index(header) + except ValueError as exc: + raise AssertionError(f"Missing YAML header: {header!r}") from exc + indent = len(header) - len(header.lstrip()) + end = len(lines) + for index in range(start + 1, len(lines)): + line = lines[index] + if not line.strip() or line.lstrip().startswith("#"): + continue + current_indent = len(line) - len(line.lstrip()) + if current_indent <= indent: + end = index + break + return "\n".join(lines[start:end]) + + +def _named_step(text: str, name: str) -> str: + return _indented_block(text, f" - name: {name}") + + +def _run_script(step: str) -> str: + lines = step.splitlines() + if " run: |" not in lines: + inline = next((line for line in lines if line.startswith(" run: ")), None) + if inline is None: + raise AssertionError("Named step does not contain a run command") + return inline.removeprefix(" run: ") + "\n" + start = lines.index(" run: |") + 1 + script_lines = lines[start:] + if any(line and not line.startswith(" ") for line in script_lines): + raise AssertionError("Unexpected indentation in literal run block") + return "\n".join(line[10:] if line else "" for line in script_lines) + "\n" + + +def _timeout_minutes(block: str) -> int: + match = re.search(r"(?m)^\s+timeout-minutes: ([0-9]+)$", block) + if match is None: + raise AssertionError("YAML block has no explicit timeout-minutes") + return int(match.group(1)) + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +def _git(repository: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repository), *arguments], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + + +def _copy_source_gate_inputs(destination: Path) -> None: + relative_paths = [ + *(manifest for manifest, _ in gate.SOURCE_COMPONENTS.values()), + gate.BUILD_TOOLCHAIN_LOCK, + *gate.IMAGE_DOCKERFILES.values(), + ] + for relative_path in relative_paths: + source = REPOSITORY_ROOT / relative_path + target = destination / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + + +class SecurityWorkflowPolicyTests(unittest.TestCase): + workflow: ClassVar[str] + security_job: ClassVar[str] + + @classmethod + def setUpClass(cls) -> None: + cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + cls.security_job = _indented_block(cls.workflow, " security:") + + def test_all_checkouts_are_full_history_without_persisted_credentials(self) -> None: + checkouts = [ + _named_step(self.workflow, "Check out validation source with full history"), + _named_step(self.workflow, "Check out security source with full history"), + ] + self.assertEqual(self.workflow.count("uses: actions/checkout@"), 2) + for checkout in checkouts: + with self.subTest(checkout=checkout.splitlines()[0]): + self.assertRegex(checkout, r"actions/checkout@[0-9a-f]{40}") + self.assertIn("fetch-depth: 0", checkout) + self.assertIn("persist-credentials: false", checkout) + security_checkout = _named_step( + self.workflow, "Check out security source with full history" + ) + self.assertIn("fetch-tags: true", security_checkout) + history = _run_script(_named_step(self.workflow, "Prove complete non-shallow history")) + self.assertIn("+refs/heads/*:refs/remotes/origin/*", history) + self.assertIn("+refs/tags/*:refs/tags/*", history) + self.assertIn("git fetch --force --prune", history) + self.assertIn("git-history.json", history) + self.assertIn('git remote set-url origin "${SOURCE_REPOSITORY}"', history) + self.assertIn("x-access-token:%s", history) + self.assertIn("git config --local http.extraheader", history) + self.assertIn("git config --local --unset-all http.extraheader", history) + history_step = _named_step(self.workflow, "Prove complete non-shallow history") + self.assertIn("GITHUB_TOKEN: ${{ github.token }}", history_step) + + def test_profiles_are_source_mandatory_and_only_explicit_security_builds_images(self) -> None: + self.assertIn("inputs.profile == 'security'", self.security_job) + self.assertIn("inputs.profile == 'full'", self.security_job) + self.assertIn("!inputs.profile", self.security_job) + self.assertIn("SECURITY_PROFILE:", self.security_job) + images = _named_step(self.workflow, "Build and scan exact shipping images") + self.assertIn("gitea.event_name == 'workflow_dispatch'", images) + self.assertIn("inputs.profile == 'security'", images) + source = _named_step(self.workflow, "Collect source security reports") + self.assertNotIn("if:", source) + + def test_time_budget_arithmetic_reserves_evaluator_and_upload(self) -> None: + names = ( + "Prepare fail-closed security evidence", + "Check out security source with full history", + "Prove complete non-shallow history", + "Install checksum-pinned security scanners", + "Collect source security reports", + "Build and scan exact shipping images", + "Remove exact security images", + "Evaluate the complete security gate", + "Upload immutable security evidence", + ) + bounds = {name: _timeout_minutes(_named_step(self.workflow, name)) for name in names} + job_timeout = _timeout_minutes(self.security_job.split(" steps:", maxsplit=1)[0]) + self.assertGreaterEqual(bounds["Evaluate the complete security gate"], 5) + self.assertGreaterEqual(bounds["Upload immutable security evidence"], 5) + self.assertGreaterEqual(job_timeout - sum(bounds.values()), 10) + + def test_scanner_artifacts_are_version_and_checksum_pinned(self) -> None: + expected = { + "PIP_AUDIT_VERSION: 2.10.1", + "99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", + "GITLEAKS_VERSION: 8.30.1", + "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb", + "TRIVY_VERSION: 0.74.0", + "2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a", + } + for fragment in expected: + with self.subTest(fragment=fragment): + self.assertIn(fragment, self.security_job) + self.assertNotRegex(self.security_job, r"curl[^\n]*\|\s*(?:ba)?sh") + self.assertNotIn("trivy-action", self.security_job) + + def test_release_version_gate_accepts_only_stable_or_numbered_rc_semver(self) -> None: + history = _run_script(_named_step(self.workflow, "Prove complete non-shallow history")) + self.assertIn( + '^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)' + '(-rc\\.(0|[1-9][0-9]*))?$', + history, + ) + self.assertNotIn('^[0-9]+\\.[0-9]+\\.[0-9]+$', history) + self.assertNotIn("gitleaks-action", self.security_job) + + def test_pip_audit_toolchain_is_fully_resolved_and_hash_locked(self) -> None: + entries = [ + line.strip() + for line in PIP_AUDIT_LOCK.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + self.assertGreaterEqual(len(entries), 20) + for entry in entries: + with self.subTest(entry=entry): + self.assertRegex( + entry, + r"^[A-Za-z0-9._-]+==[^ ]+ --hash=sha256:[0-9a-f]{64}$", + ) + self.assertEqual(sum(entry.startswith("pip-audit==2.10.1 ") for entry in entries), 1) + install = _named_step(self.workflow, "Install checksum-pinned security scanners") + self.assertTrue(_run_script(install).startswith("set -euo pipefail\n")) + self.assertIn("--index-url https://pypi.org/simple", install) + self.assertGreaterEqual(install.count("--require-hashes"), 2) + self.assertIn("--no-index", install) + self.assertIn("config/security/pip-audit-toolchain.lock", install) + + def test_source_scans_are_complete_and_do_not_suppress_findings(self) -> None: + source = _named_step(self.workflow, "Collect source security reports") + self.assertTrue(_run_script(source).startswith("set -euo pipefail\n")) + required = ( + "source-inventory", + "--log-opts=--all", + "--redact=100", + "sanitize-gitleaks", + "pip-source-backend.json", + "pip-source-node-agent.json", + "pip-source-runtime-worker.json", + '"${PIP_AUDIT}" --strict', + "npm audit --json", + "npm audit --omit=dev --json", + "trivy-fs.json", + "--scanners vuln,misconfig", + "--severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", + '"${TRIVY}" version --format json', + ) + for fragment in required: + with self.subTest(fragment=fragment): + self.assertIn(fragment, source) + for forbidden in ("--ignore-vuln", "--ignore-unfixed", "--exit-code", "|| true"): + self.assertNotIn(forbidden, source) + self.assertNotIn("--no-deps", source) + self.assertNotIn("--disable-pip", source) + self.assertNotIn("${GITHUB_WORKSPACE}/${REPORTS}", source) + + def test_exactly_four_commit_bound_non_push_images_are_built_and_scanned(self) -> None: + images = _named_step(self.workflow, "Build and scan exact shipping images") + self.assertTrue(_run_script(images).startswith("set -euo pipefail\n")) + self.assertEqual(images.count(" docker build --pull"), 4) + self.assertEqual(images.count("docker build --pull --no-cache"), 4) + self.assertIn("modelforge_runtime_worker.main", images) + self.assertIn("sentence_transformers", images) + self.assertIn('{"pip", "setuptools", "wheel"} & packages', images) + self.assertEqual(images.count("scan_image "), 4) + self.assertIn("bind-trivy-image", images) + self.assertIn('--expected-tag "modelforge-security-${target}:${GITHUB_SHA}"', images) + self.assertIn('${SECURITY_TOOLS}/trivy-image-${target}.raw.json', images) + # Four image tags plus the exact expected tag passed to the Trivy binder. + self.assertEqual(images.count(":${GITHUB_SHA}"), 5) + for target in ("api", "web", "node-agent", "runtime-worker"): + self.assertIn(f"image-build-{target}", images) + self.assertIn(f"scan_image {target} ", images) + self.assertNotIn("docker push", images) + self.assertNotIn("--push", images) + self.assertEqual(images.count("image-identity --target"), 1) + self.assertIn('"${target}"', images) + self.assertIn('"${API_ID}"', images) + self.assertIn('"${WEB_ID}"', images) + self.assertIn("org.opencontainers.image.revision=${GITHUB_SHA}", images) + self.assertEqual(images.count("org.opencontainers.image.source=${SOURCE_REPOSITORY}"), 4) + self.assertEqual(images.count("org.opencontainers.image.version=${MODELFORGE_VERSION}"), 4) + self.assertIn("io.modelforge.security.bundle-path=/usr/share/nginx/html", images) + self.assertIn('--expected-source "${SOURCE_REPOSITORY}"', images) + self.assertIn('--expected-version "${MODELFORGE_VERSION}"', images) + self.assertIn('--bundle-path "${WEB_BUNDLE_PATH}"', images) + self.assertIn("VITE_API_BASE_URL=https://security-scan.invalid", images) + self.assertIn("web-bundle-evidence", images) + + cleanup = _named_step(self.workflow, "Remove exact security images") + self.assertIn("always()", cleanup) + self.assertIn("docker image rm", cleanup) + self.assertIn("docker image inspect", cleanup) + self.assertIn("status/image-cleanup.exit", cleanup) + self.assertNotIn("docker image prune", cleanup) + + def test_image_source_uses_the_canonical_https_repository(self) -> None: + source = _run_script(_named_step(self.workflow, "Prepare fail-closed security evidence")) + self.assertIn( + '[[ "${GITHUB_REPOSITORY}" == "Jens/ITWorx-ModelForge" ]]', source + ) + self.assertIn( + 'SOURCE_REPOSITORY}" == "https://git.example.com/example/modelforge"', + source, + ) + for relative_path in gate.IMAGE_DOCKERFILES.values(): + dockerfile = (REPOSITORY_ROOT / relative_path).read_text(encoding="utf-8") + self.assertEqual( + dockerfile.count( + f'LABEL org.opencontainers.image.source="{gate.CANONICAL_SOURCE_REPOSITORY}"' + ), + 1, + relative_path, + ) + + def test_api_release_builds_include_the_root_toolchain_lock(self) -> None: + image_build = _run_script( + _named_step(self.workflow, "Build and scan exact shipping images") + ) + self.assertIn("--file backend/Dockerfile .", image_build) + release_builder = (REPOSITORY_ROOT / "scripts" / "release_build.py").read_text( + encoding="utf-8" + ) + self.assertIn('"modelforge-api": (".", "backend/Dockerfile")', release_builder) + for relative_path, services in ( + ("docker-compose.yml", ("api", "migrate")), + ("docker-compose.production.yml", ("api",)), + ("docker-compose.dr.yml", ("dr-api",)), + ): + compose = (REPOSITORY_ROOT / relative_path).read_text(encoding="utf-8") + for service in services: + block = re.search( + rf"(?ms)^ {re.escape(service)}:\n(.*?)(?=^ [A-Za-z0-9_-]+:|\Z)", + compose, + ) + self.assertIsNotNone(block, f"{relative_path} is missing {service}") + self.assertRegex( + block.group(1), + r"(?m)^ build:\n context: \.\n dockerfile: backend/Dockerfile$", + ) + + def test_repository_root_image_context_excludes_local_secrets_and_model_data(self) -> None: + dockerignore = set( + (REPOSITORY_ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines() + ) + self.assertTrue({".env", "**/.env", "**/.env.*"}.issubset(dockerignore)) + self.assertTrue( + { + "**/*.key", + "**/*.pem", + "**/*.crt", + "**/*.p12", + "**/*.pfx", + "**/id_rsa*", + "**/id_ed25519*", + "**/private_key*", + "**/secrets/**", + "**/credentials/**", + "**/.npmrc", + "**/.pypirc", + "**/.netrc", + "**/.git-credentials", + "**/.htpasswd", + "**/pip.conf", + "**/.state/**", + "**/.ssh/**", + "**/.aws/**", + }.issubset(dockerignore) + ) + self.assertTrue( + { + "/models/", + "/model-artifacts/", + "/artifacts/", + "/data/", + "/.data/", + "/.state/", + "/state/", + "/hf-cache/", + "/huggingface/", + "**/*.safetensors", + "**/*.gguf", + "**/*.ckpt", + "**/*.onnx", + "**/*.bin", + "**/*.h5", + "**/*.pb", + "**/*.model", + }.issubset(dockerignore) + ) + self.assertNotIn("models/", dockerignore) + self.assertTrue((REPOSITORY_ROOT / "config/models/initial-candidates.yaml").is_file()) + self.assertIn( + ".dockerignore", + (REPOSITORY_ROOT / "public-source.allowlist").read_text(encoding="utf-8").splitlines(), + ) + + def test_console_network_policy_admits_only_the_build_bound_api_origin(self) -> None: + headers = (REPOSITORY_ROOT / "frontend" / "security-headers.inc.template").read_text( + encoding="utf-8" + ) + dockerfile = (REPOSITORY_ROOT / "frontend" / "Dockerfile").read_text(encoding="utf-8") + self.assertIn("connect-src __API_ORIGIN__;", headers) + self.assertNotIn("connect-src 'self'", headers) + self.assertIn('grep -q "connect-src ${API_ORIGIN};"', dockerfile) + + def test_shipping_python_inventory_is_extracted_and_strictly_audited(self) -> None: + images = _named_step(self.workflow, "Build and scan exact shipping images") + self.assertEqual(images.count("collect_inventory "), 3) + self.assertEqual(images.count("pip-image-") // 2, 3) + for target in ("api", "node-agent", "runtime-worker"): + self.assertIn(f'collect_inventory {target} "', images) + self.assertIn(f"pip-image-{target}.json", images) + self.assertIn("importlib.metadata", images) + self.assertIn("--strict --no-deps", images) + self.assertIn("--disable-pip", images) + + def test_python_validation_environments_are_not_shared(self) -> None: + self.assertIn("BACKEND_VENV:", self.workflow) + self.assertIn("NODE_AGENT_VENV:", self.workflow) + self.assertIn("RUNTIME_WORKER_VENV:", self.workflow) + self.assertNotIn("VENV: /tmp/modelforge-validation-venv", self.workflow) + + def test_artifact_upload_is_immutable_retained_and_fail_closed(self) -> None: + upload = _named_step(self.workflow, "Upload immutable security evidence") + self.assertIn("if: always()", upload) + self.assertIn( + "https://gitea.com/actions/gitea-upload-artifact@" + "81f940d004763f986ba3582c007fd842dd5cb0d7", + upload, + ) + self.assertIn( + "name: security-gate-${{ gitea.run_id }}-${{ gitea.run_attempt }}-" + "${{ gitea.sha }}", + upload, + ) + self.assertIn("if-no-files-found: error", upload) + self.assertIn("retention-days: 90", upload) + self.assertIn("path: |", upload) + self.assertNotIn("path: ${{ env.REPORTS }}\n", upload) + uploaded_paths = set( + re.findall(r"\$\{\{ env\.REPORTS \}\}/([^\r\n]+)", upload) + ) + self.assertEqual( + uploaded_paths, + {"security-gate-result.json", *gate.expected_evidence_paths("security")}, + ) + self.assertNotIn("continue-on-error", upload) + + def test_gitleaks_scanner_config_has_narrow_current_github_rules_and_no_allowlist(self) -> None: + with GITLEAKS_CONFIG.open("rb") as handle: + config = tomllib.load(handle) + self.assertNotIn("extend", config) + rules = {rule["id"]: rule for rule in config["rules"]} + self.assertEqual( + set(rules), + { + "modelforge-github-opaque-token", + "modelforge-github-installation-token", + "modelforge-github-fine-grained-pat", + }, + ) + serialized = json.dumps(config).lower() + self.assertNotIn("allowlist", serialized) + self.assertTrue(all(rule.get("secretGroup") == 1 for rule in rules.values())) + source = _run_script(_named_step(self.workflow, "Collect source security reports")) + self.assertIn( + 'DEFAULT_GITLEAKS_REPOSITORY="${GITLEAKS_TEMP}/default-worktree"', source + ) + self.assertIn('rm -f -- "${DEFAULT_GITLEAKS_REPOSITORY}/.gitleaks.toml"', source) + self.assertIn("--config .gitleaks.toml", source) + self.assertIn("RAW_GITLEAKS_DEFAULT", source) + self.assertIn("RAW_GITLEAKS_CUSTOM", source) + self.assertIn("gitleaks-merge.exit", source) + classic = "".join(("gh", "p_", "aBcDeFgHiJkLmNoPqRsTuVwXyZ", "0123456789")) + self.assertIsNotNone(re.search(rules["modelforge-github-opaque-token"]["regex"], classic)) + opaque_body = classic.removeprefix("ghp_") + for prefix in ("ghp_", "gho_", "ghu_", "ghr_"): + with self.subTest(prefix=prefix): + self.assertIsNotNone( + re.search( + rules["modelforge-github-opaque-token"]["regex"], + prefix + opaque_body, + ) + ) + installation_rule = rules["modelforge-github-installation-token"]["regex"] + self.assertIsNotNone(re.search(installation_rule, "ghs_" + opaque_body)) + self.assertIsNotNone( + re.search(installation_rule, "ghs_" + "A" * 40 + "." + "B" * 40 + "." + "C" * 40) + ) + self.assertIsNotNone( + re.search( + rules["modelforge-github-fine-grained-pat"]["regex"], + "github_pat_" + "A1" * 41, + ) + ) + self.assertIsNone( + re.search( + rules["modelforge-github-opaque-token"]["regex"], + "example-not-a-credential", + ) + ) + install = _run_script( + _named_step(self.workflow, "Install checksum-pinned security scanners") + ) + for fragment in ( + "custom_formats=(ghp gho ghu ghs ghr github-pat)", + "custom-${format}", + "default-trigger", + "custom-control", + "default-control", + '"AK", "IA", "A1B2C3D4E5F6G7H8"', + "custom_trigger_code", + "default_trigger_code", + "trap cleanup_self_test EXIT", + 'rm -r -- "${test_root}"', + ): + self.assertIn(fragment, install) + self.assertNotIn("|| true", install) + + def test_pinned_gitleaks_binary_detects_temporary_history_and_benign_control(self) -> None: + binary = os.environ.get("GITLEAKS_BIN") or shutil.which("gitleaks") + if binary is None: + self.skipTest("a Gitleaks binary is not available locally") + version = subprocess.run( + [binary, "version"], capture_output=True, check=False, text=True + ) + if "8.30.1" not in version.stdout + version.stderr: + self.skipTest("available Gitleaks binary is not the pinned 8.30.1 build") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + custom_names = ("ghp", "gho", "ghu", "ghs", "ghr", "github-pat") + repositories = { + name: root / name + for name in (*custom_names, "default-trigger", "control") + } + for repository in repositories.values(): + repository.mkdir() + subprocess.run(["git", "-C", str(repository), "init", "-q"], check=True) + subprocess.run( + ["git", "-C", str(repository), "config", "user.name", "security-gate"], + check=True, + ) + subprocess.run( + [ + "git", + "-C", + str(repository), + "config", + "user.email", + "security-gate@invalid.example", + ], + check=True, + ) + body = "".join(("aBcDeFgHiJkLmNoPqRsTuVwXyZ", "0123456789")) + aws_key = "".join(("AK", "IA", "A1B2C3D4E5F6G7H8")) + custom_tokens = { + name: "".join(("gh", f"{name[-1]}_", body)) + for name in custom_names + if name != "github-pat" + } + custom_tokens["github-pat"] = "".join(("github", "_pat_", "A" * 82)) + trigger_values = { + **{ + name: f'token = "{token}"\n' + for name, token in custom_tokens.items() + }, + "default-trigger": f'key = "{aws_key}"\n', + } + for name, value in trigger_values.items(): + trigger_path = repositories[name] / "credential.txt" + trigger_path.write_text(value, encoding="utf-8") + _git(repositories[name], "add", ".") + _git(repositories[name], "commit", "-qm", "trigger") + trigger_path.write_text("credential removed\n", encoding="utf-8") + _git(repositories[name], "add", ".") + _git(repositories[name], "commit", "-qm", "remove") + (repositories["control"] / "control.txt").write_text( + 'token = "example-not-a-credential"\n', encoding="utf-8" + ) + subprocess.run(["git", "-C", str(repositories["control"]), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(repositories["control"]), "commit", "-qm", "control"], + check=True, + ) + + def detect(name: str, *, custom: bool) -> subprocess.CompletedProcess[str]: + command = [binary, "git"] + if custom: + command.extend(("--config", str(GITLEAKS_CONFIG))) + command.extend( + ( + "--log-opts=--all", + "--redact=100", + "--report-format", + "json", + "--report-path", + str(root / f"{name}-{'custom' if custom else 'default'}.json"), + "--no-banner", + str(repositories[name]), + ) + ) + return subprocess.run( + command, + capture_output=True, + check=False, + text=True, + timeout=60, + ) + + trigger_custom = [detect(name, custom=True) for name in custom_names] + trigger_default = detect("default-trigger", custom=False) + control_default = detect("control", custom=False) + control_custom = detect("control", custom=True) + self.assertEqual([result.returncode for result in trigger_custom], [1] * 6) + self.assertEqual(trigger_default.returncode, 1) + self.assertEqual(control_default.returncode, 0) + self.assertEqual(control_custom.returncode, 0) + + def test_dual_pass_gitleaks_matches_exact_historical_false_positive_hashes(self) -> None: + binary = os.environ.get("GITLEAKS_BIN") or shutil.which("gitleaks") + if binary is None: + self.skipTest("a Gitleaks binary is not available locally") + version = subprocess.run( + [binary, "version"], capture_output=True, check=False, text=True + ) + if "8.30.1" not in version.stdout + version.stderr: + self.skipTest("available Gitleaks binary is not the pinned 8.30.1 build") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = root / "config-free-history" + subprocess.run( + ["git", "clone", "--quiet", "--no-local", str(REPOSITORY_ROOT), str(repository)], + check=True, + timeout=60, + ) + (repository / ".gitleaks.toml").unlink(missing_ok=True) + + def scan(*, custom: bool) -> tuple[subprocess.CompletedProcess[str], Path]: + output = root / f"repository-{'custom' if custom else 'default'}.json" + command = [binary, "git"] + if custom: + command.extend(("--config", str(GITLEAKS_CONFIG))) + command.extend( + ( + "--log-opts=--all", + "--redact=100", + "--report-format", + "json", + "--report-path", + str(output), + "--no-banner", + str(repository), + ) + ) + result = subprocess.run( + command, + capture_output=True, + check=False, + text=True, + timeout=120, + ) + return result, output + + default_result, default_report = scan(custom=False) + custom_result, custom_report = scan(custom=True) + self.assertEqual(default_result.returncode, 1) + self.assertEqual(custom_result.returncode, 0) + combined = root / "combined.json" + _write_json( + combined, + [ + *json.loads(default_report.read_text(encoding="utf-8")), + *json.loads(custom_report.read_text(encoding="utf-8")), + ], + ) + sanitized = root / "sanitized.json" + gate.sanitize_gitleaks(combined, sanitized) + findings = json.loads(sanitized.read_text(encoding="utf-8"))["findings"] + actual = {(item["rule_id"], item["fingerprint_sha256"]) for item in findings} + registered = json.loads(GITLEAKS_FALSE_POSITIVES.read_text(encoding="utf-8")) + expected = { + (item["rule_id"], item["fingerprint_sha256"]) for item in registered + } + self.assertEqual(actual, expected) + + def test_risk_registry_is_exact_bounded_and_tracked(self) -> None: + registry = json.loads(ACCEPTED_RISKS.read_text(encoding="utf-8")) + self.assertEqual(registry["schema_version"], 1) + risks = registry["accepted_risks"] + self.assertEqual(risks, []) + identities = { + ( + entry["scanner"], + entry["target"], + entry["advisory_id"], + entry["package"], + entry["installed_version"], + ) + for entry in risks + } + self.assertEqual(len(identities), len(risks)) + for entry in risks: + self.assertEqual(entry["scanner"], "trivy") + self.assertEqual(entry["target"], "node-agent") + self.assertEqual(entry["classification"], "ACCEPTED_TEMPORARY") + self.assertEqual(entry["owner"], "ModelForge security owner") + self.assertEqual(entry["accepted_on"], "2026-09-02") + self.assertEqual(entry["expires_on"], "2026-10-02") + self.assertEqual( + entry["tracking"], + "https://git.example.com/example/modelforge/issues/24", + ) + self.assertNotIn("*", "|".join(str(value) for value in entry.values())) + + def test_false_positives_use_only_exact_hashes(self) -> None: + false_positives = json.loads(GITLEAKS_FALSE_POSITIVES.read_text(encoding="utf-8")) + self.assertEqual(len(false_positives), 2) + for entry in false_positives: + self.assertEqual(entry["classification"], "FALSE_POSITIVE") + self.assertRegex(entry["fingerprint_sha256"], r"^[0-9a-f]{64}$") + self.assertNotIn("path", entry) + self.assertNotIn("commit", entry) + self.assertNotIn("secret", entry) + + def test_precheckout_fail_evidence_survives_workspace_cleanup(self) -> None: + script = _run_script(_named_step(self.workflow, "Prepare fail-closed security evidence")) + for fragment in ( + "GITHUB_RUN_ID", + "GITHUB_RUN_ATTEMPT", + "GITHUB_SHA", + "mktemp -d", + "XXXXXXXX", + "umask 077", + "stat -c '%u'", + "stat -c '%a'", + ): + self.assertIn(fragment, script) + self.assertNotIn("mkdir -p", script) + sha = "a" * 40 + execution_script = "\n".join( + ( + "set -euo pipefail", + "test_base=$(mktemp -d /tmp/modelforge-root-policy.XXXXXXXX)", + "cleanup_test() { code=$?; trap - EXIT; rm -r -- \"${test_base}\"; exit \"${code}\"; }", + "trap cleanup_test EXIT", + "export RUNNER_TEMP=${test_base}", + "export GITHUB_ENV=${test_base}/github-env", + f"export GITHUB_SHA={sha}", + "export GITHUB_REF=refs/pull/1/head", + "export GITHUB_RUN_ID=synthetic-run", + "export GITHUB_RUN_ATTEMPT=7", + "export GITHUB_SERVER_URL=http://192.0.2.10:3000", + "export GITHUB_REPOSITORY=Jens/ITWorx-ModelForge", + "export SECURITY_PROFILE=full", + "poison_target=${test_base}/poison-target", + "mkdir \"${poison_target}\"", + "ln -s \"${poison_target}\" \"${test_base}/modelforge-security-evidence-synthetic-run-7-" + + sha + + "\"", + script, + "[[ ${SOURCE_REPOSITORY} == " + "https://git.example.com/example/modelforge ]]", + "first_reports=${REPORTS}", + "first_tools=${SECURITY_TOOLS}", + "[[ ${first_reports} == ${test_base}/modelforge-security-evidence-synthetic-run-7-" + + sha + + ".* ]]", + "[[ -d ${first_reports} && ! -L ${first_reports} ]]", + "[[ $(stat -c '%a' \"${first_reports}\") == 700 ]]", + "printf '99\\n' > \"${first_reports}/status/stale.exit\"", + "workspace=${test_base}/workspace", + "mkdir \"${workspace}\"", + "printf 'ephemeral\\n' > \"${workspace}/checkout-marker\"", + "rm \"${workspace}/checkout-marker\"", + "python3 - \"${first_reports}/security-gate-result.json\" <<'PY'", + "import json, sys", + "from pathlib import Path", + "value = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))", + "assert value['decision'] == 'FAIL' and value['profile'] == 'full'", + "PY", + script, + "second_reports=${REPORTS}", + "second_tools=${SECURITY_TOOLS}", + "[[ ${second_reports} != ${first_reports} && ${second_tools} != ${first_tools} ]]", + "[[ ! -e ${second_reports}/status/stale.exit ]]", + "[[ -f ${first_reports}/security-gate-result.json ]]", + ) + ) + result = subprocess.run( + ["bash"], + input=execution_script.encode(), + cwd=REPOSITORY_ROOT, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace")) + + def test_precheckout_rejects_unknown_insecure_source_server(self) -> None: + script = _run_script(_named_step(self.workflow, "Prepare fail-closed security evidence")) + execution_script = "\n".join( + ( + "set -euo pipefail", + "test_base=$(mktemp -d /tmp/modelforge-source-policy.XXXXXXXX)", + "cleanup_test() { code=$?; trap - EXIT; rm -r -- \"${test_base}\"; " + "exit \"${code}\"; }", + "trap cleanup_test EXIT", + "export RUNNER_TEMP=${test_base}", + "export GITHUB_ENV=${test_base}/github-env", + f"export GITHUB_SHA={'a' * 40}", + "export GITHUB_REF=refs/pull/1/head", + "export GITHUB_RUN_ID=synthetic-run", + "export GITHUB_RUN_ATTEMPT=1", + "export GITHUB_SERVER_URL=http://untrusted.invalid", + "export GITHUB_REPOSITORY=Jens/ITWorx-ModelForge", + "export SECURITY_PROFILE=full", + script, + ) + ) + result = subprocess.run( + ["bash"], + input=execution_script.encode(), + cwd=REPOSITORY_ROOT, + capture_output=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Untrusted non-HTTPS Gitea server URL", result.stderr.decode()) + + def test_source_inventory_reads_every_optional_group_and_first_party_package(self) -> None: + inventory = gate.build_source_inventory(REPOSITORY_ROOT) + components = {component["target"]: component for component in inventory["components"]} + self.assertEqual(set(components), {"backend", "node-agent", "runtime-worker"}) + self.assertEqual(components["backend"]["optional_groups"], ["dev"]) + self.assertEqual(components["node-agent"]["optional_groups"], ["dev"]) + self.assertEqual(components["runtime-worker"]["optional_groups"], ["dev", "inference"]) + own = { + item["package"] + for item in inventory["first_party_coverage"] + if item["consumer_target"] == item["provider_target"] + } + self.assertEqual( + own, {"modelforge-api", "modelforge-node-agent", "modelforge-runtime-worker"} + ) + + def test_pep517_requirements_and_build_toolchain_are_exact_and_hash_locked(self) -> None: + inventory = gate.build_source_inventory(REPOSITORY_ROOT) + components = {component["target"]: component for component in inventory["components"]} + for target in gate.SOURCE_COMPONENTS: + by_name = { + requirement["name"]: requirement + for requirement in components[target]["requirements"] + } + with self.subTest(target=target): + backend_requirement = next(iter(gate.PEP517_BUILD_SYSTEMS[target][1])) + self.assertIn("build-system", by_name[backend_requirement]["groups"]) + for name, version in gate.BUILD_TOOLCHAIN_PINS.items(): + self.assertEqual(by_name[name]["version"], version) + self.assertIn("build-toolchain", by_name[name]["groups"]) + + lock_lines = [ + line.strip() + for line in (REPOSITORY_ROOT / gate.BUILD_TOOLCHAIN_LOCK) + .read_text(encoding="utf-8") + .splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + self.assertEqual(len(lock_lines), len(gate.BUILD_TOOLCHAIN_PINS)) + self.assertTrue(all("--hash=sha256:" in line for line in lock_lines)) + msgpack_line = next(line for line in lock_lines if line.startswith("msgpack==")) + self.assertEqual(msgpack_line.count("--hash=sha256:"), 14) + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + _copy_source_gate_inputs(repository) + manifest = repository / "backend/pyproject.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + ' "hatchling==1.32.4",', ' "hatchling",' + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "not pinned"): + gate.build_source_inventory(repository) + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + _copy_source_gate_inputs(repository) + dockerfile = repository / "node-agent/Dockerfile" + dockerfile.write_text( + dockerfile.read_text(encoding="utf-8").replace( + "--require-hashes", "", 1 + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "hash-locked bootstrap"): + gate.build_source_inventory(repository) + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + _copy_source_gate_inputs(repository) + dockerfile = repository / "backend/Dockerfile" + dockerfile.write_text( + dockerfile.read_text(encoding="utf-8").replace( + "--no-build-isolation", "", 1 + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "disable build isolation"): + gate.build_source_inventory(repository) + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + _copy_source_gate_inputs(repository) + dockerfile = repository / "node-agent/Dockerfile" + dockerfile.write_text( + dockerfile.read_text(encoding="utf-8").replace( + "--ignore-installed", "", 1 + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "runtime dependencies"): + gate.build_source_inventory(repository) + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + _copy_source_gate_inputs(repository) + dockerfile = repository / "frontend/Dockerfile" + dockerfile.write_text( + dockerfile.read_text(encoding="utf-8").replace( + gate.CANONICAL_SOURCE_REPOSITORY, + "https://git.example.com/example/modelforge.git", + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "source label bound"): + gate.build_source_inventory(repository) + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + _copy_source_gate_inputs(repository) + manifest_files = [] + for relative_path in gate.IMAGE_DOCKERFILES.values(): + dockerfile = repository / relative_path + rendered = dockerfile.read_text(encoding="utf-8").replace( + gate.CANONICAL_SOURCE_REPOSITORY, + gate.PUBLIC_SOURCE_LABEL_PLACEHOLDER, + ) + dockerfile.write_text(rendered, encoding="utf-8") + rendered_bytes = rendered.encode("utf-8") + manifest_files.append( + { + "path": relative_path, + "bytes": len(rendered_bytes), + "sha256": hashlib.sha256(rendered_bytes).hexdigest(), + } + ) + _write_json( + repository / gate.PUBLIC_SOURCE_MANIFEST, + { + "schemaVersion": 1, + "sourceRevision": "a" * 40, + "sourceRepository": gate.PUBLIC_SOURCE_LABEL_PLACEHOLDER, + "files": manifest_files, + }, + ) + exporter = (REPOSITORY_ROOT / "scripts" / "export-public-source.mjs").read_text( + encoding="utf-8" + ) + self.assertIn(f'"{gate.PUBLIC_SOURCE_LABEL_PLACEHOLDER}"', exporter) + gate.build_source_inventory(repository) + + manifest_path = repository / gate.PUBLIC_SOURCE_MANIFEST + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["sourceRepository"] = "https://attacker.invalid/repository" + _write_json(manifest_path, manifest) + with self.assertRaisesRegex(gate.GateInputError, "approved sanitized identity"): + gate.build_source_inventory(repository) + + manifest["sourceRepository"] = gate.PUBLIC_SOURCE_LABEL_PLACEHOLDER + _write_json(manifest_path, manifest) + + dockerfile = repository / "frontend/Dockerfile" + dockerfile.write_text( + dockerfile.read_text(encoding="utf-8").replace( + gate.PUBLIC_SOURCE_LABEL_PLACEHOLDER, "https://attacker.invalid/repository" + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "does not bind the current"): + gate.build_source_inventory(repository) + + def test_evaluator_rejects_a_non_https_repository_identity(self) -> None: + request = gate.EvaluationRequest( + reports_dir=Path("reports"), + accepted_risks=Path("accepted-risks.json"), + gitleaks_false_positives=Path("false-positives.json"), + repository=REPOSITORY_ROOT, + commit="a" * 40, + ref="refs/heads/master", + run_id="test", + source_repository="http://git.example.com/Jens/ITWorx-ModelForge", + profile="full", + output=Path("reports/result.json"), + as_of=date(2026, 8, 30), + evaluation_time=datetime(2026, 8, 30, 0, 10, tzinfo=UTC), + ) + with self.assertRaisesRegex(gate.GateInputError, "canonical HTTPS"): + gate.Evaluator(request) + + def test_source_inventory_emits_exact_pep508_extras(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + gate.collect_source_inventory( + REPOSITORY_ROOT, + root / "source-inventory.json", + root / "source-requirements", + ) + backend = (root / "source-requirements" / "backend.txt").read_text( + encoding="utf-8" + ) + self.assertIn("uvicorn[standard]==0.52.4\n", backend) + self.assertIn("psycopg[binary]==3.3.4\n", backend) + self.assertIn("hatchling==1.32.4\n", backend) + self.assertIn("editables==0.5\n", backend) + self.assertIn("msgpack==1.2.2\n", backend) + self.assertIn("pip==26.2.1\n", backend) + self.assertNotIn("\nuvicorn==0.52.4\n", f"\n{backend}") + self.assertNotIn("\npsycopg==3.3.4\n", f"\n{backend}") + + def test_source_inventory_rejects_identical_extra_declarations_before_dedup(self) -> None: + declarations = ("uvicorn[standard]==0.52.4", "psycopg[binary]==3.3.4") + with tempfile.TemporaryDirectory() as temporary: + temporary_root = Path(temporary) + for attack_index, declaration in enumerate(declarations): + with self.subTest(declaration=declaration): + repository = temporary_root / f"attack-{attack_index}" + _copy_source_gate_inputs(repository) + manifest = repository / "backend" / "pyproject.toml" + text = manifest.read_text(encoding="utf-8") + declaration_line = f' "{declaration}",' + first_line = text.splitlines().index(declaration_line) + 1 + manifest.write_text( + text.replace( + declaration_line, + f"{declaration_line}\n{declaration_line}", + 1, + ), + encoding="utf-8", + ) + with self.assertRaises(gate.GateInputError) as raised: + gate.build_source_inventory(repository) + message = str(raised.exception) + self.assertIn("identical manifest declaration", message) + self.assertIn(f"backend/pyproject.toml:{first_line}", message) + self.assertIn(f"backend/pyproject.toml:{first_line + 1}", message) + self.assertIn("project.dependencies", message) + + def test_all_security_shell_blocks_parse(self) -> None: + names = ( + "Prepare fail-closed security evidence", + "Prove complete non-shallow history", + "Install checksum-pinned security scanners", + "Collect source security reports", + "Build and scan exact shipping images", + "Evaluate the complete security gate", + ) + for name in names: + result = subprocess.run( + ["bash", "-n"], + input=_run_script(_named_step(self.workflow, name)).encode(), + capture_output=True, + check=False, + ) + with self.subTest(name=name): + self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace")) + + +class GateFixture: + commit = "a" * 40 + as_of = date(2026, 8, 30) + evaluation_time = datetime(2026, 8, 30, 0, 10, tzinfo=UTC) + source_repository = gate.CANONICAL_SOURCE_REPOSITORY + + def __init__(self, root: Path, *, profile: str = "full") -> None: + self.root = root + self.profile = profile + self.reports = root / "reports" + self.risks = root / "accepted-risks.json" + self.false_positives = root / "gitleaks-false-positives.json" + self.output = self.reports / "security-gate-result.json" + (self.reports / "status").mkdir(parents=True) + self.reports.chmod(0o700) + (self.reports / "versions").mkdir() + for status in gate.expected_status_codes(profile): + (self.reports / "status" / f"{status}.exit").write_text("0\n", encoding="ascii") + + _write_json( + self.reports / "git-history.json", + { + "schema_version": 1, + "target": "full-history", + "head": self.commit, + "expected_commit": self.commit, + "is_shallow": False, + "head_commit_count": 151, + "all_commit_count": 153, + "root_commits": ["b" * 40], + "remote_name": "origin", + "remote_url": self.source_repository, + "advertised_remote_heads": [ + {"ref": "refs/heads/master", "object": "c" * 40} + ], + "local_remote_heads": [ + {"ref": "refs/heads/master", "object": "c" * 40} + ], + "advertised_tags": [], + "local_tags": [], + "all_ref_count": 1, + "all_objects_reachable": True, + }, + ) + gate.collect_source_inventory( + REPOSITORY_ROOT, + self.reports / "source-inventory.json", + self.reports / "source-requirements", + ) + source_inventory = json.loads( + (self.reports / "source-inventory.json").read_text(encoding="utf-8") + ) + for component in source_inventory["components"]: + packages = { + item["name"]: item["version"] + for item in component["requirements"] + if not item["first_party"] + } + _write_json( + self.reports / f"pip-source-{component['target']}.json", + self.pip_report(packages), + ) + + _write_json( + self.reports / "gitleaks.json", + { + "schema_version": 1, + "scanner": "gitleaks", + "target": "full-history", + "redaction_percent": 100, + "findings": [], + }, + ) + clean_npm = { + "auditReportVersion": 2, + "vulnerabilities": {}, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 0, + "critical": 0, + "total": 0, + } + }, + } + _write_json(self.reports / "npm-audit-all.json", clean_npm) + _write_json(self.reports / "npm-audit-production.json", clean_npm) + _write_json( + self.reports / "trivy-fs.json", + { + "SchemaVersion": 2, + "ArtifactName": str(REPOSITORY_ROOT.resolve()), + "ArtifactType": "repository", + "Results": [], + }, + ) + + image_packages = { + "api": {"modelforge-api": PRODUCT_VERSION, "httpx": "0.28.1"}, + "node-agent": { + "modelforge-api": PRODUCT_VERSION, + "modelforge-node-agent": PRODUCT_VERSION, + "httpx": "0.28.1", + }, + "runtime-worker": { + "modelforge-runtime-worker": PRODUCT_VERSION, + **gate.IMAGE_NON_PYPI_AUDIT_EXCEPTIONS["runtime-worker"], + "httpx": "0.28.1", + }, + } + if profile == "security": + identity_characters = { + "api": "1", + "web": "2", + "node-agent": "3", + "runtime-worker": "4", + } + for target in gate.IMAGE_TARGETS: + image_id = "sha256:" + identity_characters[target] * 64 + tag = f"modelforge-security-{target}:{self.commit}" + identity = { + "schema_version": 1, + "target": target, + "tag": tag, + "image_id": image_id, + "repo_tags": [tag], + "repo_digests": [], + "revision": self.commit, + "source": self.source_repository, + "version": PRODUCT_VERSION, + "bundle_path": gate.WEB_BUNDLE_PATH if target == "web" else None, + } + _write_json(self.reports / f"image-identity-{target}.json", identity) + _write_json( + self.reports / f"trivy-image-{target}.json", + { + "SchemaVersion": 2, + "ArtifactName": image_id, + "ArtifactID": "sha256:" + "a" * 64, + "ArtifactType": "container_image", + "ScannedImageID": image_id, + "Metadata": { + "ImageID": image_id, + "Reference": tag, + "RepoTags": [tag], + "RepoDigests": [], + }, + "Results": [], + }, + ) + _write_json( + self.reports / "web-bundle-origin.json", + { + "schema_version": 1, + "target": "web", + "image_id": "sha256:" + "2" * 64, + "bundle_path": gate.WEB_BUNDLE_PATH, + "bundle_sha256": "f" * 64, + "bundle_file_count": 3, + "provenance_path": gate.WEB_PROVENANCE_PATH, + "provenance_sha256": "e" * 64, + "served_provenance_sha256": "e" * 64, + "provenance_binding_sha256": gate._web_provenance_binding( + gate.WEB_SECURITY_ORIGIN + ), + "runtime_chunk_path": "assets/index.js", + "runtime_chunk_sha256": "d" * 64, + "api_origins": [gate.WEB_SECURITY_ORIGIN], + "non_api_origins": list(gate.WEB_EXPECTED_NON_API_ORIGINS), + }, + ) + for target, packages in image_packages.items(): + _write_json( + self.reports / f"image-inventory-{target}.json", + { + "schema_version": 1, + "target": target, + "python_version": "3.12.0", + "packages": [ + {"name": name, "version": version} + for name, version in packages.items() + ], + }, + ) + _write_json( + self.reports / f"pip-image-{target}.json", + self.pip_report( + { + name: version + for name, version in packages.items() + if not name.startswith("modelforge-") + and name + not in gate.IMAGE_NON_PYPI_AUDIT_EXCEPTIONS.get(target, {}) + } + ), + ) + + (self.reports / "versions" / "pip-audit.txt").write_text( + "pip-audit 2.10.1\n", encoding="utf-8" + ) + (self.reports / "versions" / "gitleaks.txt").write_text( + "8.30.1\n", encoding="utf-8" + ) + (self.reports / "versions" / "npm.txt").write_text("11.0.0\n", encoding="utf-8") + _write_json( + self.reports / "versions" / "trivy.json", + { + "Version": "0.74.0", + "VulnerabilityDB": { + "Version": 2, + "UpdatedAt": "2026-08-30T00:00:00Z", + "DownloadedAt": "2026-08-30T00:05:00Z", + }, + }, + ) + self.write_risks([]) + _write_json(self.false_positives, []) + + @staticmethod + def pip_report(packages: dict[str, str]) -> dict[str, Any]: + return { + "dependencies": [ + {"name": name, "version": version, "vulns": []} + for name, version in sorted(packages.items()) + ], + "fixes": [], + } + + def evaluate(self) -> dict[str, Any]: + return gate.evaluate_security_gate( + gate.EvaluationRequest( + reports_dir=self.reports, + accepted_risks=self.risks, + gitleaks_false_positives=self.false_positives, + repository=REPOSITORY_ROOT, + commit=self.commit, + ref="refs/pull/1/head", + run_id="synthetic-1", + source_repository=self.source_repository, + profile=self.profile, + output=self.output, + as_of=self.as_of, + evaluation_time=self.evaluation_time, + ) + ) + + def write_risks(self, entries: list[dict[str, str]]) -> None: + _write_json( + self.risks, + {"schema_version": gate.SCHEMA_VERSION, "accepted_risks": entries}, + ) + + def add_pip_vulnerability( + self, *, fix_versions: list[str] | None = None, duplicate: bool = False + ) -> dict[str, str]: + path = self.reports / "pip-source-backend.json" + report = json.loads(path.read_text(encoding="utf-8")) + dependency = report["dependencies"][0] + vulnerability = {"id": "PYSEC-TEST-1", "fix_versions": fix_versions or []} + dependency["vulns"] = [vulnerability, copy.deepcopy(vulnerability)] if duplicate else [vulnerability] + _write_json(path, report) + (self.reports / "status" / "pip-source-backend.exit").write_text("1\n") + return { + "scanner": "pip-audit", + "target": "backend", + "advisory_id": "PYSEC-TEST-1", + "package": dependency["name"], + "installed_version": dependency["version"], + } + + def risk(self, identity: dict[str, str]) -> dict[str, str]: + return { + **identity, + "classification": "ACCEPTED_TEMPORARY", + "owner": "security-owner", + "accepted_on": "2026-08-30", + "expires_on": "2026-09-29", + "tracking": "https://tracker.invalid/RC-DEL-02", + "reason": "No upstream fix exists; bounded review is required before release.", + } + + +class SecurityGateEvaluatorTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.fixture = GateFixture(Path(self.temporary.name)) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def assert_failed_with(self, result: dict[str, Any], advisory_id: str) -> None: + self.assertEqual(result["decision"], "FAIL") + self.assertIn(advisory_id, {item["advisory_id"] for item in result["blocked_findings"]}) + + def test_clean_complete_evidence_passes(self) -> None: + result = self.fixture.evaluate() + self.assertEqual(result["decision"], "PASS") + self.assertEqual(result["blocked_findings"], []) + self.assertEqual(result["expected_targets"], result["actual_targets"]) + self.assertEqual(result["scanner_versions"]["trivy"], "0.74.0") + self.assertEqual( + result["vulnerability_database_times"]["trivy"]["updated_at"], + "2026-08-30T00:00:00Z", + ) + self.assertEqual(result["profile"], "full") + self.assertNotIn("image_scan", result["expected_targets"]) + + def test_security_profile_requires_and_accepts_source_plus_exact_images(self) -> None: + fixture = GateFixture(Path(self.temporary.name) / "security", profile="security") + result = fixture.evaluate() + self.assertEqual(result["decision"], "PASS") + self.assertEqual(result["expected_targets"], result["actual_targets"]) + self.assertEqual(set(result["image_identities"]), set(gate.IMAGE_TARGETS)) + (fixture.reports / "source-inventory.json").unlink() + self.assert_failed_with(fixture.evaluate(), "GATE-SOURCE-INVENTORY") + + def test_runtime_worker_inventory_rejects_installers(self) -> None: + fixture = GateFixture(Path(self.temporary.name) / "installers", profile="security") + inventory = fixture.reports / "image-inventory-runtime-worker.json" + report = json.loads(inventory.read_text(encoding="utf-8")) + report["packages"].append({"name": "pip", "version": "26.0"}) + _write_json(inventory, report) + self.assert_failed_with(fixture.evaluate(), "GATE-IMAGE-FORBIDDEN-PACKAGE") + + def test_source_only_profile_defers_but_validates_image_risks(self) -> None: + identity = { + "scanner": "trivy", + "target": "node-agent", + "advisory_id": "CVE-2099-0001", + "package": "base-package", + "installed_version": "1.0", + } + risk = self.fixture.risk(identity) + self.fixture.write_risks([risk]) + result = self.fixture.evaluate() + self.assertEqual(result["decision"], "PASS") + self.assertEqual(result["applied_risks"], []) + self.assertEqual(result["unused_or_invalid_risks"], []) + + risk["expires_on"] = "2026-08-30" + self.fixture.write_risks([risk]) + self.assert_failed_with(self.fixture.evaluate(), "GATE-RISK-INVALID") + + security = GateFixture(Path(self.temporary.name) / "unused-image", profile="security") + security.write_risks([security.risk(identity)]) + self.assert_failed_with(security.evaluate(), "GATE-RISK-UNUSED") + + def test_scanner_timeout_fails(self) -> None: + (self.fixture.reports / "status" / "trivy-fs.exit").write_text("124\n") + self.assert_failed_with(self.fixture.evaluate(), "GATE-STATUS-FAILED") + + def test_missing_empty_and_malformed_reports_fail(self) -> None: + cases = (None, "", "{") + path = self.fixture.reports / "npm-audit-all.json" + original = path.read_text(encoding="utf-8") + for replacement in cases: + with self.subTest(replacement=replacement): + if replacement is None: + path.unlink() + else: + path.write_text(replacement, encoding="utf-8") + self.assert_failed_with(self.fixture.evaluate(), "GATE-NPM-REPORT") + path.write_text(original, encoding="utf-8") + + def test_component_gap_fails(self) -> None: + path = self.fixture.reports / "pip-source-node-agent.json" + report = json.loads(path.read_text(encoding="utf-8")) + report["dependencies"].pop(0) + _write_json(path, report) + self.assert_failed_with(self.fixture.evaluate(), "GATE-PIP-COMPONENT-GAP") + + def test_source_direct_packages_are_exact_and_resolved_transitives_are_recorded(self) -> None: + path = self.fixture.reports / "pip-source-backend.json" + report = json.loads(path.read_text(encoding="utf-8")) + report["dependencies"].extend( + [ + {"name": "starlette", "version": "0.47.3", "vulns": []}, + {"name": "uvloop", "version": "0.21.0", "vulns": []}, + {"name": "psycopg-binary", "version": "3.3.4", "vulns": []}, + ] + ) + _write_json(path, report) + result = self.fixture.evaluate() + self.assertEqual(result["decision"], "PASS") + for transitive in ( + {"name": "starlette", "version": "0.47.3"}, + {"name": "uvloop", "version": "0.21.0"}, + {"name": "psycopg-binary", "version": "3.3.4"}, + ): + self.assertIn(transitive, result["resolved_transitive_packages"]["backend"]) + + report["dependencies"] = [ + dependency + for dependency in report["dependencies"] + if gate._normalize_name(dependency["name"]) != "fastapi" + ] + _write_json(path, report) + self.assert_failed_with(self.fixture.evaluate(), "GATE-PIP-COMPONENT-GAP") + + def test_only_pip_audit_packaging_tool_omissions_are_accepted(self) -> None: + source = self.fixture.reports / "pip-source-runtime-worker.json" + report = json.loads(source.read_text(encoding="utf-8")) + report["dependencies"] = [ + dependency + for dependency in report["dependencies"] + if gate._normalize_name(dependency["name"]) + not in gate.PIP_AUDIT_SOURCE_PACKAGING_TOOL_OMISSIONS + ] + _write_json(source, report) + self.assertEqual(self.fixture.evaluate()["decision"], "PASS") + + report["dependencies"] = [ + dependency + for dependency in report["dependencies"] + if gate._normalize_name(dependency["name"]) != "sentencepiece" + ] + _write_json(source, report) + self.assert_failed_with(self.fixture.evaluate(), "GATE-PIP-COMPONENT-GAP") + + image_fixture = GateFixture( + Path(self.temporary.name) / "packaging-tool-image", profile="security" + ) + image = image_fixture.reports / "pip-image-runtime-worker.json" + image_report = json.loads(image.read_text(encoding="utf-8")) + image_report["dependencies"] = [ + dependency + for dependency in image_report["dependencies"] + if gate._normalize_name(dependency["name"]) + not in gate.PIP_AUDIT_PACKAGING_TOOL_OMISSIONS + ] + _write_json(image, image_report) + self.assertEqual(image_fixture.evaluate()["decision"], "PASS") + + def test_source_pep508_extra_omission_attack_fails(self) -> None: + requirements = self.fixture.reports / "source-requirements" / "backend.txt" + retained = requirements.read_text(encoding="utf-8") + self.assertIn("uvicorn[standard]==0.52.4", retained) + self.assertIn("psycopg[binary]==3.3.4", retained) + poisoned = retained.replace("uvicorn[standard]", "uvicorn").replace( + "psycopg[binary]", "psycopg" + ) + requirements.write_text(poisoned, encoding="utf-8") + self.assert_failed_with(self.fixture.evaluate(), "GATE-SOURCE-REQUIREMENTS") + + def test_identical_duplicate_pip_row_is_malformed(self) -> None: + path = self.fixture.reports / "pip-source-backend.json" + report = json.loads(path.read_text(encoding="utf-8")) + report["dependencies"].append(copy.deepcopy(report["dependencies"][0])) + _write_json(path, report) + self.assert_failed_with(self.fixture.evaluate(), "GATE-PIP-DUPLICATE") + + def test_evidence_symlink_redirection_fails_before_hashing(self) -> None: + outside = self.fixture.root / "outside-evidence" + outside.mkdir() + redirect = self.fixture.reports / "redirected-evidence" + if os.name == "nt": + subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(redirect), str(outside)], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + else: + redirect.symlink_to(outside, target_is_directory=True) + result = self.fixture.evaluate() + self.assert_failed_with(result, "GATE-EVIDENCE-PATH") + self.assertEqual(result["report_hashes"], {}) + + def test_evidence_metadata_rejects_foreign_owner_and_writable_modes(self) -> None: + def metadata(mode: int, uid: int) -> Any: + return SimpleNamespace( + st_dev=1, + st_ino=2, + st_mode=mode, + st_uid=uid, + st_size=1, + st_mtime_ns=1, + st_ctime_ns=1, + st_file_attributes=0, + ) + + with mock.patch.object(os, "geteuid", return_value=1000, create=True): + with self.assertRaisesRegex(gate.GateInputError, "not owned"): + gate._validate_evidence_metadata( + metadata(stat.S_IFREG | 0o600, 65534), + path="foreign.json", + expected_kind="file", + enforce_posix_permissions=True, + ) + for mode, kind, root in ( + (stat.S_IFDIR | 0o777, "directory", True), + (stat.S_IFREG | 0o666, "file", False), + ): + with self.subTest(mode=oct(mode)): + with self.assertRaisesRegex(gate.GateInputError, "permissions|writable"): + gate._validate_evidence_metadata( + metadata(mode, 1000), + path="unsafe", + expected_kind=kind, + root=root, + enforce_posix_permissions=True, + ) + + def test_evidence_lstat_open_race_fails_before_replacement_is_read(self) -> None: + root = self.fixture.root / "race-reports" + root.mkdir(mode=0o700) + root.chmod(0o700) + victim = root / "report.json" + victim.write_bytes(b"inside-safe") + outside = self.fixture.root / "outside-secret.json" + outside.write_bytes(b"outside-must-not-be-consumed") + replaced = False + + def replace_after_lstat(event: str, path: Path) -> None: + nonlocal replaced + if event == "after_lstat" and path == victim and not replaced: + replaced = True + os.replace(outside, victim) + + with mock.patch.object( + gate.SecureEvidenceSnapshot, + "_read_fd", + wraps=gate.SecureEvidenceSnapshot._read_fd, + ) as reader: + with self.assertRaisesRegex(gate.GateInputError, "changed before open"): + gate.SecureEvidenceSnapshot( + root, root / "result.json", race_hook=replace_after_lstat + ) + self.assertTrue(replaced) + reader.assert_not_called() + + @unittest.skipUnless(os.name == "posix", "open-handle replacement semantics are Linux-only") + def test_evidence_after_open_swap_reads_only_original_descriptor_then_fails(self) -> None: + root = self.fixture.root / "open-race-reports" + root.mkdir(mode=0o700) + root.chmod(0o700) + victim = root / "report.json" + victim.write_bytes(b"inside-safe") + outside = self.fixture.root / "outside-open-race.json" + outside.write_bytes(b"outside-must-not-be-consumed") + consumed: list[bytes] = [] + original_reader = gate.SecureEvidenceSnapshot._read_fd + + def swap_after_open(event: str, path: Path) -> None: + if event == "after_open" and path == victim: + os.replace(outside, victim) + + def read_descriptor(file_descriptor: int) -> bytes: + value = original_reader(file_descriptor) + consumed.append(value) + return value + + with mock.patch.object( + gate.SecureEvidenceSnapshot, "_read_fd", side_effect=read_descriptor + ): + with self.assertRaisesRegex(gate.GateInputError, "changed during read"): + gate.SecureEvidenceSnapshot( + root, root / "result.json", race_hook=swap_after_open + ) + self.assertEqual(consumed, [b"inside-safe"]) + + def test_evidence_directory_substitution_after_snapshot_is_rejected(self) -> None: + root = self.fixture.root / "snapshot-reports" + nested = root / "nested" + nested.mkdir(parents=True) + root.chmod(0o700) + (nested / "report.json").write_bytes(b"inside-safe") + outside = self.fixture.root / "outside-directory" + outside.mkdir() + (outside / "report.json").write_bytes(b"outside-must-not-be-consumed") + snapshot = gate.SecureEvidenceSnapshot(root, root / "result.json") + (nested / "report.json").unlink() + nested.rmdir() + if os.name == "nt": + subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(nested), str(outside)], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + else: + nested.symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex( + gate.GateInputError, "identity changed|not a real|changed after secure capture" + ): + snapshot.read_bytes("nested/report.json") + + def test_evidence_in_place_mutation_after_snapshot_is_rejected(self) -> None: + root = self.fixture.root / "mutation-reports" + root.mkdir(mode=0o700) + root.chmod(0o700) + report = root / "report.json" + report.write_bytes(b"inside-safe") + snapshot = gate.SecureEvidenceSnapshot(root, root / "result.json") + report.write_bytes(b"outside-poison-is-longer") + with self.assertRaisesRegex(gate.GateInputError, "changed after secure capture"): + snapshot.read_bytes("report.json") + + def test_record_history_rejects_nonshallow_single_branch_clone(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + origin = root / "origin.git" + seed = root / "seed" + partial = root / "partial" + complete = root / "complete" + origin.mkdir() + seed.mkdir() + _git(origin, "init", "--bare", "--initial-branch=main") + _git(seed, "init", "--initial-branch=main") + _git(seed, "config", "user.name", "security-gate") + _git(seed, "config", "user.email", "security-gate@invalid.example") + (seed / "tracked.txt").write_text("history proof\n", encoding="utf-8") + _git(seed, "add", "tracked.txt") + _git(seed, "commit", "-m", "initial") + _git(seed, "branch", "retained-remote-branch") + _git(seed, "tag", "retained-tag") + _git(seed, "remote", "add", "origin", str(origin)) + _git(seed, "push", "origin", "--all") + _git(seed, "push", "origin", "--tags") + + subprocess.run( + [ + "git", + "clone", + "--single-branch", + "--branch", + "main", + "--no-tags", + str(origin), + str(partial), + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + head = _git(partial, "rev-parse", "HEAD").stdout.strip() + with self.assertRaisesRegex(gate.GateInputError, "advertised remote heads"): + gate.record_history(partial, head, root / "partial-history.json") + + subprocess.run( + ["git", "clone", str(origin), str(complete)], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + complete_head = _git(complete, "rev-parse", "HEAD").stdout.strip() + output = root / "complete-history.json" + gate.record_history(complete, complete_head, output) + report = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(len(report["advertised_remote_heads"]), 2) + self.assertEqual(report["advertised_tags"][0]["ref"], "refs/tags/retained-tag") + + def test_shallow_history_fails(self) -> None: + path = self.fixture.reports / "git-history.json" + report = json.loads(path.read_text(encoding="utf-8")) + report["is_shallow"] = True + _write_json(path, report) + self.assert_failed_with(self.fixture.evaluate(), "GATE-HISTORY-INCOMPLETE") + + def test_history_origin_and_ref_namespace_are_exact(self) -> None: + attacks: tuple[tuple[str, Any], ...] = ( + ("remote_url", "https://attacker.invalid/repository"), + ( + "advertised_remote_heads", + [{"ref": "refs/pull/1/head", "object": "c" * 40}], + ), + ("advertised_remote_heads", []), + ) + for index, (field, value) in enumerate(attacks): + with self.subTest(field=field, value=value): + fixture = GateFixture(self.fixture.root / f"history-attack-{index}") + path = fixture.reports / "git-history.json" + report = json.loads(path.read_text(encoding="utf-8")) + report[field] = value + if field == "advertised_remote_heads" and value: + report["local_remote_heads"] = copy.deepcopy(value) + _write_json(path, report) + self.assert_failed_with(fixture.evaluate(), "GATE-HISTORY-INCOMPLETE") + + def test_unclassified_gitleaks_finding_fails_without_secret_material(self) -> None: + fingerprint = "c" * 64 + _write_json( + self.fixture.reports / "gitleaks.json", + { + "schema_version": 1, + "scanner": "gitleaks", + "target": "full-history", + "redaction_percent": 100, + "findings": [ + {"rule_id": "generic-api-key", "fingerprint_sha256": fingerprint} + ], + }, + ) + (self.fixture.reports / "status" / "gitleaks.exit").write_text("1\n") + result = self.fixture.evaluate() + self.assert_failed_with(result, "generic-api-key") + serialized = json.dumps(result) + for forbidden in ('"Secret"', '"Match"', '"Line"', '"author"', '"email"'): + self.assertNotIn(forbidden, serialized) + + def test_gitleaks_sanitizer_and_evaluator_reject_metadata_fields(self) -> None: + raw = self.fixture.root / "raw-gitleaks.json" + sanitized = self.fixture.root / "sanitized-gitleaks.json" + _write_json( + raw, + [ + { + "RuleID": "synthetic-rule", + "Fingerprint": "synthetic-fingerprint", + "Secret": "must-not-survive", + "Match": "must-not-survive", + "File": "private/path.txt", + "Commit": "b" * 40, + "Author": "private-author", + } + ], + ) + gate.sanitize_gitleaks(raw, sanitized) + report = json.loads(sanitized.read_text(encoding="utf-8")) + self.assertEqual(set(report), gate.GITLEAKS_REPORT_FIELDS) + self.assertEqual(set(report["findings"][0]), gate.GITLEAKS_FINDING_FIELDS) + serialized = json.dumps(report) + for forbidden in ( + "must-not-survive", + "private/path.txt", + "private-author", + "synthetic-fingerprint", + ): + self.assertNotIn(forbidden, serialized) + + unsafe = json.loads(raw.read_text(encoding="utf-8")) + unsafe[0]["RuleID"] = "../private/path" + _write_json(raw, unsafe) + with self.assertRaisesRegex(gate.GateInputError, "safe rule metadata"): + gate.sanitize_gitleaks(raw, sanitized) + + for field, value in ( + ("path", "private/path.txt"), + ("commit", "b" * 40), + ("secret", "must-not-survive"), + ("unknown", "unexpected"), + ): + with self.subTest(field=field): + fixture = GateFixture(self.fixture.root / f"gitleaks-{field}") + path = fixture.reports / "gitleaks.json" + poisoned = json.loads(path.read_text(encoding="utf-8")) + poisoned["findings"] = [ + { + "rule_id": "synthetic-rule", + "fingerprint_sha256": "c" * 64, + field: value, + } + ] + _write_json(path, poisoned) + fixture_status = fixture.reports / "status" / "gitleaks.exit" + fixture_status.write_text("1\n", encoding="ascii") + self.assert_failed_with(fixture.evaluate(), "GATE-GITLEAKS-SCHEMA") + + def test_every_pip_and_npm_vulnerability_fails(self) -> None: + self.fixture.add_pip_vulnerability() + self.assert_failed_with(self.fixture.evaluate(), "PYSEC-TEST-1") + + self.fixture = GateFixture(Path(self.temporary.name) / "npm") + npm = { + "auditReportVersion": 2, + "vulnerabilities": { + "react": { + "name": "react", + "severity": "high", + "via": [ + {"source": 1, "url": "https://github.com/advisories/GHSA-test-0001"} + ], + "fixAvailable": False, + } + }, + "metadata": {"vulnerabilities": {"high": 1, "total": 1}}, + } + _write_json(self.fixture.reports / "npm-audit-all.json", npm) + (self.fixture.reports / "status" / "npm-audit-all.exit").write_text("1\n") + self.assert_failed_with(self.fixture.evaluate(), "GHSA-test-0001") + + def test_trivy_unknown_high_and_critical_fail_but_medium_does_not(self) -> None: + path = self.fixture.reports / "trivy-fs.json" + for severity, expected in ( + ("UNKNOWN", "CVE-UNKNOWN"), + ("HIGH", "CVE-HIGH"), + ("CRITICAL", "CVE-CRITICAL"), + ): + with self.subTest(severity=severity): + _write_json( + path, + { + "SchemaVersion": 2, + "ArtifactName": str(REPOSITORY_ROOT.resolve()), + "ArtifactType": "repository", + "Results": [ + { + "Target": ".", + "Vulnerabilities": [ + { + "VulnerabilityID": expected, + "PkgName": "package", + "InstalledVersion": "1.0", + "FixedVersion": "", + "Severity": severity, + } + ], + } + ], + }, + ) + self.assert_failed_with(self.fixture.evaluate(), expected) + _write_json( + path, + { + "SchemaVersion": 2, + "ArtifactName": str(REPOSITORY_ROOT.resolve()), + "ArtifactType": "repository", + "Results": [ + { + "Target": ".", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-MEDIUM", + "PkgName": "package", + "InstalledVersion": "1.0", + "Severity": "MEDIUM", + } + ] + } + ], + }, + ) + self.assertEqual(self.fixture.evaluate()["decision"], "PASS") + + def test_trivy_filesystem_and_image_reports_are_bound_to_exact_targets(self) -> None: + filesystem = self.fixture.reports / "trivy-fs.json" + report = json.loads(filesystem.read_text(encoding="utf-8")) + report["ArtifactType"] = "filesystem" + _write_json(filesystem, report) + self.assert_failed_with(self.fixture.evaluate(), "GATE-TRIVY-TARGET") + + report["ArtifactType"] = "repository" + report["ArtifactName"] = "swapped-target" + _write_json(filesystem, report) + self.assert_failed_with(self.fixture.evaluate(), "GATE-TRIVY-TARGET") + + fixture = GateFixture(Path(self.temporary.name) / "image-target", profile="security") + api_report = fixture.reports / "trivy-image-api.json" + swapped = json.loads( + (fixture.reports / "trivy-image-web.json").read_text(encoding="utf-8") + ) + _write_json(api_report, swapped) + self.assert_failed_with(fixture.evaluate(), "GATE-TRIVY-TARGET") + + fixture = GateFixture(Path(self.temporary.name) / "nested-target", profile="security") + api_report = fixture.reports / "trivy-image-api.json" + nested = json.loads(api_report.read_text(encoding="utf-8")) + nested["Results"] = [ + { + "Target": "sha256:" + "9" * 64, + "ComponentTarget": "Python", + "Vulnerabilities": [], + } + ] + _write_json(api_report, nested) + self.assert_failed_with(fixture.evaluate(), "GATE-TRIVY-TARGET") + + fixture = GateFixture( + Path(self.temporary.name) / "duplicate-trivy-alias", profile="security" + ) + api_report = fixture.reports / "trivy-image-api.json" + duplicate_alias = json.loads(api_report.read_text(encoding="utf-8")) + duplicate_alias["Metadata"]["RepoTags"] *= 2 + _write_json(api_report, duplicate_alias) + self.assert_failed_with(fixture.evaluate(), "GATE-TRIVY-TARGET") + + fixture = GateFixture( + Path(self.temporary.name) / "missing-scan-binding", profile="security" + ) + api_report = fixture.reports / "trivy-image-api.json" + missing_binding = json.loads(api_report.read_text(encoding="utf-8")) + del missing_binding["ScannedImageID"] + _write_json(api_report, missing_binding) + self.assert_failed_with(fixture.evaluate(), "GATE-TRIVY-TARGET") + + fixture = GateFixture( + Path(self.temporary.name) / "malformed-artifact-id", profile="security" + ) + api_report = fixture.reports / "trivy-image-api.json" + malformed_artifact = json.loads(api_report.read_text(encoding="utf-8")) + malformed_artifact["ArtifactID"] = "mutable-artifact" + _write_json(api_report, malformed_artifact) + self.assert_failed_with(fixture.evaluate(), "GATE-TRIVY-SCHEMA") + + fixture = GateFixture( + Path(self.temporary.name) / "missing-component-target", profile="security" + ) + api_report = fixture.reports / "trivy-image-api.json" + nested = json.loads(api_report.read_text(encoding="utf-8")) + nested["Results"] = [ + { + "Target": "sha256:" + "1" * 64, + "Vulnerabilities": [], + } + ] + _write_json(api_report, nested) + self.assert_failed_with(fixture.evaluate(), "GATE-TRIVY-TARGET") + + def test_trivy_binding_preserves_component_and_rejects_swapped_artifact(self) -> None: + image_id = "sha256:" + "1" * 64 + expected_tag = "modelforge-security-api:" + "a" * 40 + raw = self.fixture.root / "raw-trivy-image.json" + bound = self.fixture.root / "bound-trivy-image.json" + report = { + "SchemaVersion": 2, + "ArtifactName": image_id, + "ArtifactID": "sha256:" + "2" * 64, + "ArtifactType": "container_image", + "Metadata": {"ImageID": image_id, "Reference": image_id}, + "Results": [{"Target": "Python", "Vulnerabilities": []}], + } + _write_json(raw, report) + gate.bind_trivy_image_report(raw, bound, image_id, expected_tag) + normalized = json.loads(bound.read_text(encoding="utf-8")) + self.assertEqual(normalized["Results"][0]["Target"], image_id) + self.assertEqual(normalized["Results"][0]["ComponentTarget"], "Python") + self.assertEqual(normalized["ScannedImageID"], image_id) + + report["Metadata"]["Reference"] = expected_tag + _write_json(raw, report) + gate.bind_trivy_image_report(raw, bound, image_id, expected_tag) + + report["Metadata"]["Reference"] = "attacker:latest" + _write_json(raw, report) + with self.assertRaisesRegex(gate.GateInputError, "metadata"): + gate.bind_trivy_image_report(raw, bound, image_id, expected_tag) + + report["Metadata"]["Reference"] = image_id + report["ArtifactName"] = "sha256:" + "9" * 64 + _write_json(raw, report) + with self.assertRaisesRegex(gate.GateInputError, "exact scanned image"): + gate.bind_trivy_image_report(raw, bound, image_id, expected_tag) + + report["ArtifactName"] = image_id + report["ArtifactID"] = "mutable-artifact" + _write_json(raw, report) + with self.assertRaisesRegex(gate.GateInputError, "ArtifactID"): + gate.bind_trivy_image_report(raw, bound, image_id, expected_tag) + + def test_runtime_non_pypi_exception_is_exact_and_still_inventory_bound(self) -> None: + fixture = GateFixture( + Path(self.temporary.name) / "non-pypi-runtime", profile="security" + ) + self.assertEqual(fixture.evaluate()["decision"], "PASS") + inventory_path = fixture.reports / "image-inventory-runtime-worker.json" + inventory = json.loads(inventory_path.read_text(encoding="utf-8")) + next(item for item in inventory["packages"] if item["name"] == "conda-build")[ + "version" + ] = "25.5.1" + _write_json(inventory_path, inventory) + self.assert_failed_with(fixture.evaluate(), "GATE-IMAGE-NON-PYPI-VERSION") + + def test_trivy_malformed_identity_fields_and_result_target_cannot_be_waived(self) -> None: + path = self.fixture.reports / "trivy-fs.json" + base: dict[str, Any] = { + "SchemaVersion": 2, + "ArtifactName": str(REPOSITORY_ROOT.resolve()), + "ArtifactType": "repository", + "Results": [ + { + "Target": ".", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-SYNTHETIC", + "PkgName": "package", + "InstalledVersion": "1.0", + "Severity": "HIGH", + "FixedVersion": "", + } + ], + } + ], + } + for missing in ( + "VulnerabilityID", + "PkgName", + "InstalledVersion", + "Severity", + ): + with self.subTest(missing=missing): + malformed = copy.deepcopy(base) + del malformed["Results"][0]["Vulnerabilities"][0][missing] + _write_json(path, malformed) + self.assert_failed_with(self.fixture.evaluate(), "GATE-TRIVY-SCHEMA") + malformed_target = copy.deepcopy(base) + del malformed_target["Results"][0]["Target"] + _write_json(path, malformed_target) + self.fixture.write_risks( + [ + self.fixture.risk( + { + "scanner": "trivy", + "target": "repository", + "advisory_id": "TRIVY-UNKNOWN", + "package": "unknown", + "installed_version": "unknown", + } + ) + ] + ) + result = self.fixture.evaluate() + self.assert_failed_with(result, "GATE-TRIVY-SCHEMA") + self.assertEqual(result["applied_risks"], []) + + def test_trivy_filesystem_result_must_remain_within_repository(self) -> None: + path = self.fixture.reports / "trivy-fs.json" + _write_json( + path, + { + "SchemaVersion": 2, + "ArtifactName": str(REPOSITORY_ROOT.resolve()), + "ArtifactType": "repository", + "Results": [{"Target": "../outside", "Vulnerabilities": []}], + }, + ) + self.assert_failed_with(self.fixture.evaluate(), "GATE-TRIVY-TARGET") + + def test_trivy_database_timestamps_are_required_utc_current_and_nonfuture(self) -> None: + path = self.fixture.reports / "versions" / "trivy.json" + original = json.loads(path.read_text(encoding="utf-8")) + cases: list[tuple[str, Any]] = [ + ("missing", None), + ("malformed", "not-a-time"), + ("non-utc", "2026-08-30T01:00:00+01:00"), + ("future", "2026-08-30T00:11:00Z"), + ("stale", "2026-08-27T00:00:00Z"), + ] + for name, updated in cases: + with self.subTest(name=name): + document = copy.deepcopy(original) + if updated is None: + document["VulnerabilityDB"].pop("UpdatedAt") + else: + document["VulnerabilityDB"]["UpdatedAt"] = updated + _write_json(path, document) + self.assert_failed_with(self.fixture.evaluate(), "GATE-TRIVY-DB-TIME") + document = copy.deepcopy(original) + document["VulnerabilityDB"]["DownloadedAt"] = "2026-08-29T00:00:00Z" + _write_json(path, document) + self.assert_failed_with(self.fixture.evaluate(), "GATE-TRIVY-DB-TIME") + + def test_exact_active_no_fix_risk_can_be_applied(self) -> None: + identity = self.fixture.add_pip_vulnerability() + self.fixture.write_risks([self.fixture.risk(identity)]) + result = self.fixture.evaluate() + self.assertEqual(result["decision"], "PASS") + self.assertEqual(len(result["applied_risks"]), 1) + + def test_risk_expiration_is_exclusive_after_exactly_thirty_calendar_dates(self) -> None: + identity = self.fixture.add_pip_vulnerability() + risk = self.fixture.risk(identity) + risk["accepted_on"] = "2026-08-01" + risk["expires_on"] = "2026-08-31" + self.fixture.write_risks([risk]) + self.fixture.as_of = date(2026, 8, 30) + self.assertEqual(self.fixture.evaluate()["decision"], "PASS") + self.fixture.as_of = date(2026, 8, 31) + self.assert_failed_with(self.fixture.evaluate(), "GATE-RISK-INVALID") + + def test_risk_with_fix_available_fails(self) -> None: + identity = self.fixture.add_pip_vulnerability(fix_versions=["9.9.9"]) + self.fixture.write_risks([self.fixture.risk(identity)]) + self.assert_failed_with(self.fixture.evaluate(), "GATE-RISK-FIX-AVAILABLE") + + def test_risk_registry_version_and_classification_are_exact(self) -> None: + _write_json(self.fixture.risks, []) + self.assert_failed_with(self.fixture.evaluate(), "GATE-RISK-SCHEMA") + identity = self.fixture.add_pip_vulnerability() + risk = self.fixture.risk(identity) + risk["classification"] = "ACCEPTED" + self.fixture.write_risks([risk]) + self.assert_failed_with(self.fixture.evaluate(), "GATE-RISK-INVALID") + + def test_image_inventory_requires_exact_first_party_versions_and_web_origin(self) -> None: + fixture = GateFixture(Path(self.temporary.name) / "image-policy", profile="security") + inventory_path = fixture.reports / "image-inventory-api.json" + inventory = json.loads(inventory_path.read_text(encoding="utf-8")) + next( + package + for package in inventory["packages"] + if package["name"] == "modelforge-api" + )["version"] = "0.0.0" + _write_json(inventory_path, inventory) + self.assert_failed_with(fixture.evaluate(), "GATE-IMAGE-FIRST-PARTY-VERSION") + + fixture = GateFixture(Path(self.temporary.name) / "web-policy", profile="security") + evidence_path = fixture.reports / "web-bundle-origin.json" + evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + evidence["api_origins"] = [gate.WEB_SECURITY_ORIGIN, "https://attacker.invalid"] + _write_json(evidence_path, evidence) + self.assert_failed_with(fixture.evaluate(), "GATE-WEB-BUNDLE-SCHEMA") + + def test_web_bundle_evidence_schema_binding_and_origin_controls(self) -> None: + origins = gate._extract_http_origins( + b'const api="https:\\/\\/security-scan.invalid/v1";' + ) + self.assertEqual(origins, {gate.WEB_SECURITY_ORIGIN}) + poisoned = gate._extract_http_origins( + b"https://security-scan.invalid/v1 https://attacker.invalid/v1" + ) + self.assertEqual(poisoned, {gate.WEB_SECURITY_ORIGIN, "https://attacker.invalid"}) + split_poison = gate._extract_http_origins( + b'const origin = "https" + "://attacker.invalid";' + ) + self.assertEqual(split_poison, {"https://attacker.invalid"}) + for wrong_context in ( + b'fetch("https://react.dev/collect")', + b'fetch("http://www.w3.org/attacker")', + ): + with self.subTest(wrong_context=wrong_context): + with self.assertRaisesRegex(gate.GateInputError, "pinned context"): + gate._validate_non_api_origin_contexts(wrong_context) + for origin in ( + b"http://127.42.0.1:8080", + b"http://[::1]:8080", + b"http://LOCALHOST.:8080", + b"https://api.localhost/v1", + ): + with self.subTest(origin=origin): + with self.assertRaisesRegex(gate.GateInputError, "loopback"): + gate._extract_http_origins(origin) + + attacks: tuple[tuple[str, Any], ...] = ( + ("image_id", "sha256:" + "9" * 64), + ("bundle_path", "/tmp/dead-marker"), + ("bundle_sha256", "not-a-digest"), + ("bundle_file_count", 0), + ("provenance_path", "assets/dead-marker.json"), + ("served_provenance_sha256", "0" * 64), + ("provenance_binding_sha256", "0" * 64), + ("runtime_chunk_path", "../outside.js"), + ("runtime_chunk_sha256", "not-a-digest"), + ("api_origins", [gate.WEB_SECURITY_ORIGIN, "https://attacker.invalid"]), + ("non_api_origins", ["https://attacker.invalid"]), + ("unexpected", "field"), + ) + for field, value in attacks: + with self.subTest(field=field): + fixture = GateFixture(self.fixture.root / f"web-{field}", profile="security") + path = fixture.reports / "web-bundle-origin.json" + evidence = json.loads(path.read_text(encoding="utf-8")) + evidence[field] = value + _write_json(path, evidence) + self.assert_failed_with(fixture.evaluate(), "GATE-WEB-BUNDLE-SCHEMA") + + def test_web_bundle_analyzer_binds_provenance_and_rejects_dead_marker_split_poison(self) -> None: + bundle = self.fixture.root / "synthetic-bundle" + provenance_path = bundle / gate.WEB_PROVENANCE_PATH + runtime_path = bundle / "assets" / "index.js" + provenance_path.parent.mkdir(parents=True) + runtime_path.parent.mkdir(parents=True) + binding = gate._web_provenance_binding(gate.WEB_SECURITY_ORIGIN) + provenance = { + "schema_version": 1, + "target": "modelforge-web", + "api_base_url": gate.WEB_SECURITY_ORIGIN, + "binding_sha256": binding, + } + _write_json(provenance_path, provenance) + framework_constants = ( + 'const svg="http://www.w3.org/2000/svg";' + 'const docs="https://react.dev/errors/";' + 'const runbook="https://git.example.com/example/modelforge/' + 'src/branch/master/docs/operations/RUNBOOK_NODE_OFFLINE.md";' + ) + runtime_path.write_text( + f'globalThis.__MODELFORGE_BUILD_PROVENANCE__={{api_base_url:' + f'"{gate.WEB_SECURITY_ORIGIN}",binding_sha256:"{binding}"}};' + f"{framework_constants}\n", + encoding="utf-8", + ) + served_provenance = provenance_path.read_bytes() + evidence = gate._analyze_web_bundle( + bundle, + image_id="sha256:" + "2" * 64, + bundle_path=gate.WEB_BUNDLE_PATH, + expected_origin=gate.WEB_SECURITY_ORIGIN, + served_provenance=served_provenance, + ) + self.assertEqual(evidence["api_origins"], [gate.WEB_SECURITY_ORIGIN]) + self.assertEqual(evidence["runtime_chunk_path"], "assets/index.js") + self.assertEqual(evidence["provenance_path"], gate.WEB_PROVENANCE_PATH) + self.assertEqual( + evidence["non_api_origins"], list(gate.WEB_EXPECTED_NON_API_ORIGINS) + ) + + with self.assertRaisesRegex(gate.GateInputError, "served Web provenance differs"): + gate._analyze_web_bundle( + bundle, + image_id="sha256:" + "2" * 64, + bundle_path=gate.WEB_BUNDLE_PATH, + expected_origin=gate.WEB_SECURITY_ORIGIN, + served_provenance=b"{}\n", + ) + + runtime_path.write_text( + f'const dead="{gate.WEB_SECURITY_ORIGIN}";const binding="{binding}";' + f'{framework_constants}const effective="https" + "://attacker.invalid";\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "network origins are not exact"): + gate._analyze_web_bundle( + bundle, + image_id="sha256:" + "2" * 64, + bundle_path=gate.WEB_BUNDLE_PATH, + expected_origin=gate.WEB_SECURITY_ORIGIN, + served_provenance=served_provenance, + ) + + runtime_path.write_text( + f'const api="{gate.WEB_SECURITY_ORIGIN}";const binding="{binding}";' + f"{framework_constants}\n", + encoding="utf-8", + ) + (bundle / "assets" / "index.js.map").write_text( + '{"sourcesContent":["const evil = `wss://${\"attacker.invalid\"}`;"]}\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.GateInputError, "network origins are not exact"): + gate._analyze_web_bundle( + bundle, + image_id="sha256:" + "2" * 64, + bundle_path=gate.WEB_BUNDLE_PATH, + expected_origin=gate.WEB_SECURITY_ORIGIN, + served_provenance=served_provenance, + ) + + def test_oci_identity_requires_exact_source_revision_version_and_bundle_path(self) -> None: + attacks: tuple[tuple[str, str, Any], ...] = ( + ("api", "source", "https://attacker.invalid/repository"), + ("api", "revision", "b" * 40), + ("api", "version", "0.0.0"), + ( + "api", + "repo_tags", + [ + f"modelforge-security-api:{GateFixture.commit}", + "attacker:latest", + ], + ), + ( + "api", + "repo_tags", + [ + f"modelforge-security-api:{GateFixture.commit}", + f"modelforge-security-api:{GateFixture.commit}", + ], + ), + ("api", "repo_digests", ["attacker.invalid/api@sha256:" + "9" * 64]), + ("web", "bundle_path", "/tmp/dead-marker"), + ("runtime-worker", "unexpected", "field"), + ) + for attack_index, (target, field, value) in enumerate(attacks): + with self.subTest(target=target, field=field): + fixture = GateFixture( + self.fixture.root / f"identity-{attack_index}-{target}-{field}", + profile="security", + ) + path = fixture.reports / f"image-identity-{target}.json" + identity = json.loads(path.read_text(encoding="utf-8")) + identity[field] = value + _write_json(path, identity) + self.assert_failed_with( + fixture.evaluate(), "GATE-IMAGE-IDENTITY-SCHEMA" + ) + + fixture = GateFixture(self.fixture.root / "duplicate-image-id", profile="security") + web = fixture.reports / "image-identity-web.json" + identity = json.loads(web.read_text(encoding="utf-8")) + identity["image_id"] = "sha256:" + "1" * 64 + _write_json(web, identity) + self.assert_failed_with(fixture.evaluate(), "GATE-IMAGE-SET") + + fixture = GateFixture(self.fixture.root / "extra-image", profile="security") + _write_json( + fixture.reports / "image-identity-attacker.json", + {"schema_version": 1, "target": "attacker", "tag": "attacker:latest"}, + ) + self.assert_failed_with(fixture.evaluate(), "GATE-EVIDENCE-PATH") + + def test_evidence_manifest_rejects_unknown_bytes_before_hashing(self) -> None: + sentinel = b"unexpected-evidence-must-not-be-consumed" + (self.fixture.reports / "unexpected-secret.txt").write_bytes(sentinel) + original_reader = gate.SecureEvidenceSnapshot._read_fd + consumed: list[bytes] = [] + + def record_reads(file_descriptor: int) -> bytes: + value = original_reader(file_descriptor) + consumed.append(value) + return value + + with mock.patch.object(gate.SecureEvidenceSnapshot, "_read_fd", side_effect=record_reads): + result = self.fixture.evaluate() + self.assert_failed_with(result, "GATE-EVIDENCE-PATH") + self.assertEqual(result["report_hashes"], {}) + self.assertNotIn(sentinel, consumed) + + def test_evidence_hardlink_is_rejected_before_hashing(self) -> None: + root = self.fixture.root / "hardlink-evidence" + root.mkdir(mode=0o700) + root.chmod(0o700) + outside = self.fixture.root / "outside-evidence.json" + outside.write_bytes(b"outside-must-not-be-consumed") + linked = root / "report.json" + os.link(outside, linked) + with mock.patch.object( + gate.SecureEvidenceSnapshot, + "_read_fd", + wraps=gate.SecureEvidenceSnapshot._read_fd, + ) as reader: + with self.assertRaisesRegex(gate.GateInputError, "hard links"): + gate.SecureEvidenceSnapshot(root, root / "result.json") + reader.assert_not_called() + + def test_evaluator_output_hardlink_is_rejected_and_never_overwritten(self) -> None: + outside = self.fixture.root / "outside-result.json" + original = b"external-result-must-survive" + outside.write_bytes(original) + os.link(outside, self.fixture.output) + result = self.fixture.evaluate() + self.assert_failed_with(result, "GATE-EVIDENCE-PATH") + self.assertEqual(outside.read_bytes(), original) + + # Result persistence replaces a supplied directory entry instead of following it. + gate._write_json(self.fixture.output, {"replacement": "safe"}) + self.assertEqual(outside.read_bytes(), original) + self.assertEqual(json.loads(self.fixture.output.read_text(encoding="utf-8")), {"replacement": "safe"}) + + def test_duplicate_json_keys_and_normalized_inventory_rows_fail_closed(self) -> None: + trivy = self.fixture.reports / "trivy-fs.json" + trivy.write_text( + '{"SchemaVersion":2,"ArtifactName":"' + str(REPOSITORY_ROOT.resolve()) + + '","ArtifactType":"repository","Results":[{"Target":"' + + str(REPOSITORY_ROOT.resolve()) + + '","Vulnerabilities":[{"VulnerabilityID":"CVE-1","PkgName":"x",' + '"InstalledVersion":"1","Severity":"CRITICAL"}]}],"Results":[]}', + encoding="utf-8", + ) + self.assert_failed_with(self.fixture.evaluate(), "GATE-TRIVY-REPORT") + + raw_inventory = self.fixture.root / "raw-inventory.json" + output = self.fixture.root / "inventory.json" + requirements = self.fixture.root / "requirements.txt" + for packages in ( + [{"name": "HTTPX", "version": "0.28.1"}, {"name": "httpx", "version": "0.28.1"}], + [{"name": "httpx", "version": "0.28.1"}, {"name": "httpx", "version": "0.28.2"}], + ): + _write_json(raw_inventory, {"python_version": "3.13", "packages": packages}) + with self.assertRaisesRegex(gate.GateInputError, "conflicting|duplicate"): + gate.normalize_image_inventory("api", raw_inventory, output, requirements) + + runtime_packages = [ + {"name": name, "version": version} + for name, version in gate.IMAGE_NON_PYPI_AUDIT_EXCEPTIONS[ + "runtime-worker" + ].items() + ] + [ + {"name": "httpx", "version": "0.28.1"}, + ] + _write_json( + raw_inventory, + {"python_version": "3.11", "packages": runtime_packages}, + ) + gate.normalize_image_inventory( + "runtime-worker", raw_inventory, output, requirements + ) + self.assertNotIn("conda", requirements.read_text(encoding="utf-8")) + next(item for item in runtime_packages if item["name"] == "conda")[ + "version" + ] = "25.5.1" + _write_json( + raw_inventory, + {"python_version": "3.11", "packages": runtime_packages}, + ) + with self.assertRaisesRegex(gate.GateInputError, "non-PyPI"): + gate.normalize_image_inventory( + "runtime-worker", raw_inventory, output, requirements + ) + + def test_wildcard_expired_duplicate_unused_and_ambiguous_risks_fail(self) -> None: + identity = self.fixture.add_pip_vulnerability() + base = self.fixture.risk(identity) + cases: list[tuple[str, list[dict[str, str]], str]] = [] + wildcard = copy.deepcopy(base) + wildcard["target"] = "*" + cases.append(("wildcard", [wildcard], "GATE-RISK-INVALID")) + expired = copy.deepcopy(base) + expired["accepted_on"] = "2026-07-01" + expired["expires_on"] = "2026-07-31" + cases.append(("expired", [expired], "GATE-RISK-INVALID")) + cases.append(("duplicate", [base, copy.deepcopy(base)], "GATE-RISK-INVALID")) + unused = copy.deepcopy(base) + unused["advisory_id"] = "PYSEC-STALE" + cases.append(("unused", [unused], "GATE-RISK-UNUSED")) + for name, risks, advisory in cases: + with self.subTest(name=name): + self.fixture.write_risks(risks) + self.assert_failed_with(self.fixture.evaluate(), advisory) + self.fixture = GateFixture(Path(self.temporary.name) / "ambiguous") + identity = self.fixture.add_pip_vulnerability(duplicate=True) + self.fixture.write_risks([self.fixture.risk(identity)]) + self.assert_failed_with(self.fixture.evaluate(), "GATE-RISK-AMBIGUOUS") + + def test_exact_gitleaks_false_positive_applies_and_drift_or_duplicate_fails(self) -> None: + fingerprint = "d" * 64 + _write_json( + self.fixture.reports / "gitleaks.json", + { + "schema_version": 1, + "scanner": "gitleaks", + "target": "full-history", + "redaction_percent": 100, + "findings": [ + {"rule_id": "generic-api-key", "fingerprint_sha256": fingerprint} + ], + }, + ) + (self.fixture.reports / "status" / "gitleaks.exit").write_text("1\n") + entry = { + "scanner": "gitleaks", + "rule_id": "generic-api-key", + "fingerprint_sha256": fingerprint, + "classification": "FALSE_POSITIVE", + "owner": "security-owner", + "classified_on": "2026-08-30", + "review_on": "2026-09-29", + "tracking": "RC-DEL-02", + "reason": "Verified synthetic test fixture, never credential material.", + } + _write_json(self.fixture.false_positives, [entry]) + result = self.fixture.evaluate() + self.assertEqual(result["decision"], "PASS") + self.assertEqual(len(result["applied_gitleaks_false_positives"]), 1) + + drifted = copy.deepcopy(entry) + drifted["fingerprint_sha256"] = "e" * 64 + _write_json(self.fixture.false_positives, [drifted]) + self.assert_failed_with(self.fixture.evaluate(), "GATE-GITLEAKS-FP-UNUSED") + _write_json(self.fixture.false_positives, [entry, copy.deepcopy(entry)]) + self.assert_failed_with(self.fixture.evaluate(), "GATE-GITLEAKS-FP-INVALID") + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitea/workflows/managed-validation.yml b/.gitea/workflows/managed-validation.yml index b9f8b7e..95687d3 100644 --- a/.gitea/workflows/managed-validation.yml +++ b/.gitea/workflows/managed-validation.yml @@ -1,14 +1,8 @@ name: Managed validation -# The gate on protected master. Before v1 this keyed off manifests at the repository root, and this -# repository has none — pyproject.toml, package.json and the lock files all live in backend/, -# frontend/, node-agent/ and runtime-worker/. The "full" profile therefore completed in nine seconds -# having run a whitespace check, a merge-marker scan and py_compile, and no test suite at all, while -# reporting success to a branch protection rule that required it. -# -# It now targets the component roots explicitly, and refuses to report success when a component that -# should have run tests ran none. A gate that passes because it found nothing to do is worse than no -# gate: it produces the paperwork of validation without the fact of it. +# Protected-source validation. The ordinary validation job preserves the existing profile contract. +# The security job always scans source for pull requests and full runs; only an explicit dispatched +# `security` profile pays the cost of building and scanning the four exact shipping images. on: # Public exports require explicit owner dispatch; fork PRs never reach private runners. @@ -31,15 +25,27 @@ concurrency: jobs: full: name: full - runs-on: ubuntu-latest + runs-on: linux-validation timeout-minutes: 45 env: PROFILE: ${{ inputs.profile || 'full' }} - # An explicit interpreter path rather than PATH manipulation: how a runner propagates PATH - # between steps varies, and a validation gate should not depend on that detail. - VENV: /tmp/modelforge-validation-venv + BACKEND_VENV: /tmp/modelforge-validation-backend-venv + NODE_AGENT_VENV: /tmp/modelforge-validation-node-agent-venv + RUNTIME_WORKER_VENV: /tmp/modelforge-validation-runtime-worker-venv + VALIDATION_COMPOSE_PROJECT: modelforge-validation-${{ gitea.run_id }}-${{ gitea.run_attempt }} + MODELFORGE_POSTGRES_ADMIN_PASSWORD: managed-validation-admin + MODELFORGE_MIGRATION_DB_PASSWORD: managed-validation-owner + MODELFORGE_RUNTIME_DB_PASSWORD: managed-validation-runtime + # Port zero asks Docker for a collision-free ephemeral loopback port. The boundary step + # exports the resolved URLs only to later steps in this job. + MODELFORGE_POSTGRES_PORT: "0" steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Check out validation source with full history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false - name: Production delivery policy shell: bash @@ -61,6 +67,7 @@ jobs: run: | set -euo pipefail git diff --check + python3 scripts/rc_readiness.py if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then echo "Unresolved merge markers detected" >&2 exit 1 @@ -71,10 +78,10 @@ jobs: shell: bash run: mkdir -p reports - # Deliberately not using setup-python/setup-node: this runs on a self-hosted runner whose - # image already carries both, and the previous workflow depended on that too. What changes is - # that a missing toolchain now stops the run instead of quietly reducing what gets validated. + # Each Python component gets its own environment. Sharing one environment made the later + # component inherit the earlier component's dependency resolution and invalidated isolation. - name: Toolchain + if: ${{ env.PROFILE != 'security' }} shell: bash run: | set -euo pipefail @@ -84,70 +91,17 @@ jobs: echo " python $(python3 --version)" echo " node $(node --version)" echo " npm $(npm --version)" - python3 -m venv "${VENV}" - "${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet --upgrade pip - if [[ "${PROFILE}" == security || "${PROFILE}" == full ]]; then - command -v gitleaks >/dev/null || { - echo "gitleaks is required for the security profile" >&2 - exit 1 - } - "${VENV}/bin/python" -m pip install \ - --disable-pip-version-check --quiet 'pip-audit==2.10.0' - fi - echo " venv $("${VENV}/bin/python" --version) at ${VENV}" - - - name: Backend — install, lint, typecheck, test - id: backend - shell: bash - working-directory: backend - run: | - set -euo pipefail - "${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet -e '.[dev]' - if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m ruff check src tests - fi - if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m mypy src - fi - if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m pytest -q --no-header --junitxml=../reports/backend.xml - fi - - - name: Node Agent — install, lint, typecheck, test - id: node_agent - shell: bash - working-directory: node-agent - run: | - set -euo pipefail - "${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet -e '.[dev]' - if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m ruff check src - fi - if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m mypy src - fi - if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m pytest -q --no-header --junitxml=../reports/node-agent.xml - fi - - - name: Runtime Worker — install, lint, typecheck, test - id: runtime_worker - shell: bash - working-directory: runtime-worker - run: | - set -euo pipefail - "${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet -e '.[dev]' - if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m ruff check src - fi - if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m mypy src - fi - if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then - "${VENV}/bin/python" -m pytest -q --no-header --junitxml=../reports/runtime-worker.xml - fi + for venv in "${BACKEND_VENV}" "${NODE_AGENT_VENV}" "${RUNTIME_WORKER_VENV}"; do + python3 -m venv "${venv}" + "${venv}/bin/python" -m pip install --disable-pip-version-check --quiet \ + --require-hashes --only-binary=:all: --index-url https://pypi.org/simple \ + -r config/security/build-toolchain.lock + done + # Build first so the backend packaging policy inspects the artifact that would ship instead + # of recording a skip because frontend/dist does not exist yet. - name: Console — install frozen, typecheck, test, build + if: ${{ env.PROFILE != 'security' }} id: console shell: bash working-directory: frontend @@ -156,21 +110,123 @@ jobs: npm ci if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then npx tsc --noEmit; fi if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then - npx vitest run --reporter=junit --outputFile=../reports/frontend.xml + npx vitest run --reporter=default --reporter=junit --outputFile=../reports/frontend.xml + node --test ../clients/typescript/modelforge-client.test.mjs fi if [[ "${PROFILE}" == build || "${PROFILE}" == full ]]; then npm run build; fi - - name: Security — secrets and vulnerable dependencies - if: ${{ env.PROFILE == 'full' || env.PROFILE == 'security' }} + - name: Managed PostgreSQL boundary + if: ${{ env.PROFILE == 'full' }} shell: bash run: | set -euo pipefail - gitleaks git . --no-banner --redact --report-format json \ - --report-path reports/gitleaks.json - "${VENV}/bin/python" -m pip_audit --strict --skip-editable --desc=on \ - --format=json --output=reports/python-audit.json - cd frontend - npm audit --audit-level=high --omit=dev --json > ../reports/npm-audit.json + command -v docker >/dev/null || { + echo "The full profile requires Docker for its disposable PostgreSQL boundary." >&2 + exit 1 + } + # Compose validates required interpolation for every declared service even though this + # step starts PostgreSQL only. Keep these container-internal URLs scoped to this shell; + # the backend test process receives only the resolved host-side test URLs below. + export MODELFORGE_MIGRATION_DATABASE_URL=postgresql+psycopg://modelforge:managed-validation-owner@postgres:5432/modelforge + export MODELFORGE_RUNTIME_DATABASE_URL=postgresql+psycopg://modelforge_runtime:managed-validation-runtime@postgres:5432/modelforge + docker compose --env-file .env.example -p "${VALIDATION_COMPOSE_PROJECT}" \ + up --detach --wait postgres + docker compose --env-file .env.example -p "${VALIDATION_COMPOSE_PROJECT}" ps postgres + # The Actions job runs in a container, so the Docker host's loopback is not this job's + # 127.0.0.1. Join the disposable Compose network and use Docker service DNS instead. + validation_network="${VALIDATION_COMPOSE_PROJECT}_default" + job_container="$(hostname)" + docker inspect "${job_container}" >/dev/null + docker network connect "${validation_network}" "${job_container}" + # Docker can report PostgreSQL healthy before the newly attached job container + # can reach its listener. Bound the network readiness check to 30 seconds. + python3 - <<'PY' + import socket + import time + + for attempt in range(30): + try: + with socket.create_connection(("postgres", 5432), timeout=2): + break + except (OSError, TimeoutError): + if attempt == 29: + raise + time.sleep(1) + PY + { + echo "MODELFORGE_TEST_POSTGRES_URL=postgresql+psycopg://postgres:managed-validation-admin@postgres:5432/modelforge" + echo "MODELFORGE_TEST_POSTGRES_OWNER_URL=postgresql+psycopg://modelforge:managed-validation-owner@postgres:5432/modelforge" + echo "MODELFORGE_TEST_POSTGRES_RUNTIME_URL=postgresql+psycopg://modelforge_runtime:managed-validation-runtime@postgres:5432/modelforge" + echo "MODELFORGE_TEST_POSTGRES_CONTAINER=$(docker compose --env-file .env.example -p "${VALIDATION_COMPOSE_PROJECT}" ps -q postgres)" + echo "MODELFORGE_TEST_POSTGRES_DATABASE_LIFECYCLE=managed-validation-v1" + } >> "${GITHUB_ENV}" + + - name: Backend — install, lint, typecheck, test + if: ${{ env.PROFILE != 'security' }} + id: backend + shell: bash + working-directory: backend + run: | + set -euo pipefail + "${BACKEND_VENV}/bin/python" -m pip install --disable-pip-version-check --quiet \ + --no-build-isolation -e '.[dev]' + if [[ "${PROFILE}" == full ]]; then + MODELFORGE_MIGRATION_DATABASE_URL="${MODELFORGE_TEST_POSTGRES_OWNER_URL}" \ + "${BACKEND_VENV}/bin/python" -m alembic upgrade head + MODELFORGE_MIGRATION_DATABASE_URL="${MODELFORGE_TEST_POSTGRES_OWNER_URL}" \ + "${BACKEND_VENV}/bin/python" -m alembic downgrade 20260828_0022 + MODELFORGE_MIGRATION_DATABASE_URL="${MODELFORGE_TEST_POSTGRES_OWNER_URL}" \ + "${BACKEND_VENV}/bin/python" -m alembic upgrade head + fi + if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then + "${BACKEND_VENV}/bin/python" -m ruff check src tests + fi + if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then + "${BACKEND_VENV}/bin/python" -m mypy src + fi + if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then + "${BACKEND_VENV}/bin/python" -m pytest -q --no-header --junitxml=../reports/backend.xml + fi + + - name: Node Agent — install, lint, typecheck, test + if: ${{ env.PROFILE != 'security' }} + id: node_agent + shell: bash + working-directory: node-agent + run: | + set -euo pipefail + "${NODE_AGENT_VENV}/bin/python" -m pip install --disable-pip-version-check --quiet \ + --no-build-isolation -e '../backend' -e '.[dev]' + if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then + "${NODE_AGENT_VENV}/bin/python" -m ruff check src + fi + if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then + "${NODE_AGENT_VENV}/bin/python" -m mypy src + fi + if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then + "${NODE_AGENT_VENV}/bin/python" -m pytest -q --no-header \ + --junitxml=../reports/node-agent.xml + fi + + - name: Runtime Worker — install, lint, typecheck, test + if: ${{ env.PROFILE != 'security' }} + id: runtime_worker + shell: bash + working-directory: runtime-worker + run: | + set -euo pipefail + "${RUNTIME_WORKER_VENV}/bin/python" -m pip install \ + --disable-pip-version-check --quiet --no-build-isolation -e '.[dev]' + if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then + "${RUNTIME_WORKER_VENV}/bin/python" -m ruff check src + fi + if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then + "${RUNTIME_WORKER_VENV}/bin/python" -m mypy src + fi + if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then + "${RUNTIME_WORKER_VENV}/bin/python" -m pytest -q --no-header \ + --junitxml=../reports/runtime-worker.xml + fi - name: Compose projections if: ${{ env.PROFILE == 'full' || env.PROFILE == 'build' }} @@ -178,36 +234,26 @@ jobs: run: | set -euo pipefail if ! command -v docker >/dev/null; then - echo "SKIPPED: no docker CLI on this runner; compose projections are validated by the" - echo "local release gate instead. This step never reports a pass it did not earn." + echo "SKIPPED: no docker CLI on this runner; image security has its own fail-closed job." exit 0 fi - # Compose interpolation needs values, not live credentials. These fixed validation-only - # strings never reach a service and ensure the fail-closed production projection is - # actually parsed on every full/build run. - export MODELFORGE_POSTGRES_DB=modelforge_validation - export MODELFORGE_POSTGRES_ADMIN_PASSWORD=validation-admin-only - export MODELFORGE_MIGRATION_DB_PASSWORD=validation-owner-only - export MODELFORGE_RUNTIME_DB_PASSWORD=validation-runtime-only - export MODELFORGE_MIGRATION_DATABASE_URL=postgresql+psycopg://modelforge:validation-owner-only@postgres:5432/modelforge_validation - export MODELFORGE_RUNTIME_DATABASE_URL=postgresql+psycopg://modelforge_runtime:validation-runtime-only@postgres:5432/modelforge_validation - export MODELFORGE_OPERATOR_API_KEY=validation-operator-key-32-characters # gitleaks:allow — synthetic Compose interpolation only - export MODELFORGE_BACKUP_ENCRYPTION_KEY=dmFsaWRhdGlvbi1vbmx5LWtleS0zMi1ieXRlcw== # gitleaks:allow — base64 of a public validation-only string - export MODELFORGE_CORS_ORIGINS=https://modelforge.example.test - export VITE_API_BASE_URL=https://modelforge.example.test - export MODELFORGE_VERSION=1.2.1 + export MODELFORGE_POSTGRES_ADMIN_PASSWORD=compose-validation-admin + export MODELFORGE_MIGRATION_DB_PASSWORD=compose-validation-owner + export MODELFORGE_RUNTIME_DB_PASSWORD=compose-validation-runtime + export MODELFORGE_RUNTIME_DATABASE_URL=postgresql+psycopg://modelforge_runtime:compose-validation-runtime@postgres:5432/modelforge + export MODELFORGE_MIGRATION_DATABASE_URL=postgresql+psycopg://modelforge:compose-validation-owner@postgres:5432/modelforge export MODELFORGE_AGENT_CONTROL_PLANE_HOST_ADDRESS=127.0.0.1 export MODELFORGE_AGENT_CA_CERT_PATH=./config/ca.crt failures=0 check() { - if docker compose "$@" config -q >/dev/null 2>&1; then + if docker compose --env-file .env.example "$@" config -q >/dev/null 2>&1; then echo " OK $*" else echo " FAIL $*"; failures=$((failures + 1)) fi } check -f docker-compose.yml - for overlay in backup dr gpu node-agent node-recovery production runtime-worker; do + for overlay in backup dr gpu node-agent node-recovery runtime-worker; do check -f docker-compose.yml -f "docker-compose.${overlay}.yml" done check -f docker-compose.yml -f docker-compose.node-agent.yml \ @@ -220,17 +266,14 @@ jobs: if: ${{ env.PROFILE == 'full' }} shell: bash run: | - "${VENV}/bin/python" scripts/generate_configuration_docs.py --check + "${BACKEND_VENV}/bin/python" scripts/generate_configuration_docs.py --check - # The rule that makes the rest of this meaningful. Every component above declares a manifest, - # so every component must have reported a test count. Zero tests where tests were expected is - # a failure, not a pass — that is exactly how the previous workflow reported success. - name: Refuse a validation that silently ran no tests if: ${{ env.PROFILE == 'full' || env.PROFILE == 'test' }} shell: bash run: | set -euo pipefail - "${VENV}/bin/python" - <<'PY' + "${BACKEND_VENV}/bin/python" - <<'PY' import sys import xml.etree.ElementTree as ET from pathlib import Path @@ -271,6 +314,18 @@ jobs: sys.exit(1) PY + - name: Remove managed PostgreSQL boundary + if: ${{ always() && env.PROFILE == 'full' }} + shell: bash + run: | + set -euo pipefail + export MODELFORGE_MIGRATION_DATABASE_URL=postgresql+psycopg://modelforge:managed-validation-owner@postgres:5432/modelforge + export MODELFORGE_RUNTIME_DATABASE_URL=postgresql+psycopg://modelforge_runtime:managed-validation-runtime@postgres:5432/modelforge + docker network disconnect "${VALIDATION_COMPOSE_PROJECT}_default" "$(hostname)" \ + >/dev/null 2>&1 || true + docker compose --env-file .env.example -p "${VALIDATION_COMPOSE_PROJECT}" \ + down --volumes --remove-orphans + - name: Validation summary if: always() shell: bash @@ -279,10 +334,855 @@ jobs: echo "profile: ${PROFILE}" ls -l reports/ 2>/dev/null || echo "no reports directory" + security: + name: security + if: ${{ !inputs.profile || inputs.profile == 'security' || inputs.profile == 'full' }} + runs-on: linux-validation + # Explicit worst-case step bounds total 113 minutes for the image profile. The 17-minute job + # reserve means cleanup, the always-run evaluator and immutable upload retain time after + # earlier bounds. + timeout-minutes: 130 + env: + SECURITY_PROFILE: ${{ gitea.event_name == 'workflow_dispatch' && inputs.profile == 'security' && 'security' || 'full' }} + PIP_AUDIT_VERSION: 2.10.1 + PIP_AUDIT_SHA256: 99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a + GITLEAKS_VERSION: 8.30.1 + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + TRIVY_VERSION: 0.74.0 + TRIVY_SHA256: 2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a + steps: + - name: Prepare fail-closed security evidence + timeout-minutes: 2 + shell: bash + run: | + set -euo pipefail + umask 077 + : "${GITHUB_RUN_ID:?missing run id}" + : "${GITHUB_RUN_ATTEMPT:?missing run attempt}" + : "${GITHUB_SHA:?missing commit SHA}" + : "${GITHUB_SERVER_URL:?missing server URL}" + : "${GITHUB_REPOSITORY:?missing repository identity}" + : "${GITHUB_ENV:?missing runner environment file}" + run_key="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_SHA}" + temp_base="${RUNNER_TEMP:-/tmp}" + [[ -d "${temp_base}" && ! -L "${temp_base}" ]] + create_private_root() { + label="$1" + root="$(mktemp -d "${temp_base%/}/modelforge-${label}-${run_key}.XXXXXXXX")" + [[ -d "${root}" && ! -L "${root}" ]] + [[ "$(stat -c '%u' "${root}")" == "$(id -u)" ]] + [[ "$(stat -c '%a' "${root}")" == 700 ]] + printf '%s\n' "${root}" + } + REPORTS="$(create_private_root security-evidence)" + SECURITY_TOOLS="$(create_private_root security-tools)" + SOURCE_REQUIREMENTS="${REPORTS}/source-requirements" + IMAGE_REQUIREMENTS="${REPORTS}/image-requirements" + case "${GITHUB_SERVER_URL%/}" in + https://git.example.com|http://git.example.com|http://192.0.2.10:3000|http://gitea:3000|http://Gitea:3000|http://gitea-runner-internal:3000) + SOURCE_SERVER_URL="https://git.example.com" ;; + *) echo "Untrusted Gitea server URL (legacy: Untrusted non-HTTPS Gitea server URL)" >&2; exit 1 ;; + esac + [[ "${GITHUB_REPOSITORY}" == "Jens/ITWorx-ModelForge" ]] || { + echo "Unexpected canonical repository identity: ${GITHUB_REPOSITORY}" >&2 + exit 1 + } + SOURCE_REPOSITORY="${SOURCE_SERVER_URL}/${GITHUB_REPOSITORY}" + [[ "${SOURCE_REPOSITORY}" == "https://git.example.com/example/modelforge" ]] + install -d -m 700 "${REPORTS}/status" "${REPORTS}/versions" + { + printf 'REPORTS=%s\n' "${REPORTS}" + printf 'SECURITY_TOOLS=%s\n' "${SECURITY_TOOLS}" + printf 'SOURCE_REQUIREMENTS=%s\n' "${SOURCE_REQUIREMENTS}" + printf 'IMAGE_REQUIREMENTS=%s\n' "${IMAGE_REQUIREMENTS}" + printf 'SOURCE_REPOSITORY=%s\n' "${SOURCE_REPOSITORY}" + } >> "${GITHUB_ENV}" + export REPORTS SECURITY_TOOLS SOURCE_REQUIREMENTS IMAGE_REQUIREMENTS SOURCE_REPOSITORY + python3 - <<'PY' + import json + import os + from datetime import UTC, datetime + from pathlib import Path + + result = { + "schema_version": 1, + "commit": os.environ.get("GITHUB_SHA", "unavailable"), + "ref": os.environ.get("GITHUB_REF", "unavailable"), + "run_id": os.environ.get("GITHUB_RUN_ID", "unavailable"), + "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", "unavailable"), + "profile": os.environ.get("SECURITY_PROFILE", "unavailable"), + "evaluated_at": datetime.now(UTC).isoformat(), + "blocked_findings": [{ + "scanner": "security-gate", + "target": "bootstrap", + "advisory_id": "GATE-INCOMPLETE", + "severity": "ERROR", + "reason": "the central evaluator did not complete", + }], + "applied_risks": [], + "unused_or_invalid_risks": [], + "decision": "FAIL", + } + path = Path(os.environ["REPORTS"]) / "security-gate-result.json" + path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + PY + + - name: Check out security source with full history + timeout-minutes: 5 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Prove complete non-shallow history + timeout-minutes: 6 + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + : "${GITHUB_TOKEN:?missing read-only repository token}" + cleanup_history_auth() { + original_code=$? + trap - EXIT + git config --local --unset-all http.extraheader >/dev/null 2>&1 || true + exit "${original_code}" + } + trap cleanup_history_auth EXIT + git remote set-url origin "${SOURCE_REPOSITORY}" + auth_header="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w 0)" + git config --local http.extraheader "AUTHORIZATION: basic ${auth_header}" + unset auth_header + set +e + timeout --signal=TERM --kill-after=30s 3m git fetch --force --prune \ + --prune-tags --tags origin \ + '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' + fetch_code=$? + printf '%s\n' "${fetch_code}" > "${REPORTS}/status/history-fetch.exit" + timeout --signal=TERM --kill-after=30s 2m python3 scripts/security_gate.py history \ + --repository . --expected-commit "${GITHUB_SHA}" \ + --output "${REPORTS}/git-history.json" + code=$? + printf '%s\n' "${code}" > "${REPORTS}/status/history.exit" + MODELFORGE_VERSION="$(tr -d '\r\n' < VERSION)" + version_code=$? + if [[ "${version_code}" == 0 && \ + ! "${MODELFORGE_VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.(0|[1-9][0-9]*))?$ ]]; then + version_code=2 + fi + if [[ "${version_code}" == 0 ]]; then + printf 'MODELFORGE_VERSION=%s\n' "${MODELFORGE_VERSION}" >> "${GITHUB_ENV}" + fi + printf '%s\n' "${version_code}" > "${REPORTS}/status/release-version.exit" + set -e + + - name: Install checksum-pinned security scanners + timeout-minutes: 15 + shell: bash + run: | + set -euo pipefail + [[ -d "${SECURITY_TOOLS}" && ! -L "${SECURITY_TOOLS}" ]] + install -d -m 700 "${SECURITY_TOOLS}/downloads" "${SECURITY_TOOLS}/bin" + record_command() { + name="$1" + shift + set +e + timeout --signal=TERM --kill-after=30s 10m "$@" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/${name}.exit" + } + + record_command pip-audit-download python3 -m pip download \ + --disable-pip-version-check --index-url https://pypi.org/simple \ + --require-hashes --only-binary=:all: --dest "${SECURITY_TOOLS}/downloads" \ + --requirement config/security/pip-audit-toolchain.lock + printf '%s %s\n' "${PIP_AUDIT_SHA256}" \ + "${SECURITY_TOOLS}/downloads/pip_audit-${PIP_AUDIT_VERSION}-py3-none-any.whl" \ + > "${SECURITY_TOOLS}/pip-audit.sha256" + record_command pip-audit-hash sha256sum --check --strict \ + "${SECURITY_TOOLS}/pip-audit.sha256" + set +e + if [[ "$(<"${REPORTS}/status/pip-audit-download.exit")" == 0 && \ + "$(<"${REPORTS}/status/pip-audit-hash.exit")" == 0 ]]; then + timeout --signal=TERM --kill-after=30s 10m python3 -m venv \ + "${SECURITY_TOOLS}/pip-audit-venv" && \ + timeout --signal=TERM --kill-after=30s 10m \ + "${SECURITY_TOOLS}/pip-audit-venv/bin/pip" install \ + --disable-pip-version-check --no-index \ + --find-links "${SECURITY_TOOLS}/downloads" --require-hashes \ + --requirement config/security/pip-audit-toolchain.lock + code=$? + else + code=125 + fi + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-audit-install.exit" + set +e + timeout --signal=TERM --kill-after=30s 1m \ + "${SECURITY_TOOLS}/pip-audit-venv/bin/pip-audit" --version \ + > "${REPORTS}/versions/pip-audit.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-audit-version.exit" + + record_command gitleaks-download curl --fail --location --show-error \ + --retry 3 --retry-all-errors --output "${SECURITY_TOOLS}/downloads/gitleaks.tar.gz" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + printf '%s %s\n' "${GITLEAKS_SHA256}" \ + "${SECURITY_TOOLS}/downloads/gitleaks.tar.gz" > "${SECURITY_TOOLS}/gitleaks.sha256" + record_command gitleaks-hash sha256sum --check --strict \ + "${SECURITY_TOOLS}/gitleaks.sha256" + if [[ "$(<"${REPORTS}/status/gitleaks-hash.exit")" == 0 ]]; then + record_command gitleaks-extract tar --extract --gzip \ + --file "${SECURITY_TOOLS}/downloads/gitleaks.tar.gz" \ + --directory "${SECURITY_TOOLS}/bin" gitleaks + else + printf '125\n' > "${REPORTS}/status/gitleaks-extract.exit" + fi + set +e + timeout --signal=TERM --kill-after=30s 1m \ + "${SECURITY_TOOLS}/bin/gitleaks" version > "${REPORTS}/versions/gitleaks.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/gitleaks-version.exit" + + gitleaks_self_test() ( + set -euo pipefail + test_root="$(mktemp -d "${SECURITY_TOOLS}/gitleaks-self-test.XXXXXXXX")" + cleanup_self_test() { + original_code=$? + trap - EXIT + set +e + rm -r -- "${test_root}" + cleanup_code=$? + set -e + if [[ "${cleanup_code}" != 0 ]]; then + exit 125 + fi + exit "${original_code}" + } + trap cleanup_self_test EXIT + default_trigger="${test_root}/default-trigger" + control_repository="${test_root}/control" + custom_formats=(ghp gho ghu ghs ghr github-pat) + install -d -m 700 "${default_trigger}" "${control_repository}" + for format in "${custom_formats[@]}"; do + install -d -m 700 "${test_root}/custom-${format}" + done + for repository in "${default_trigger}" "${control_repository}" \ + "${test_root}"/custom-*; do + git -C "${repository}" init --quiet + git -C "${repository}" config user.name security-gate + git -C "${repository}" config user.email security-gate@invalid.example + done + python3 - "${test_root}" "${default_trigger}/credential.txt" <<'PY' + import sys + from pathlib import Path + + root = Path(sys.argv[1]) + body = "".join(("aBcDeFgHiJkLmNoPqRsTuVwXyZ", "0123456789")) + tokens = { + "ghp": "".join(("gh", "p_", body)), + "gho": "".join(("gh", "o_", body)), + "ghu": "".join(("gh", "u_", body)), + "ghs": "".join(("gh", "s_", body)), + "ghr": "".join(("gh", "r_", body)), + "github-pat": "".join(("github", "_pat_", "A" * 82)), + } + aws_key = "".join(("AK", "IA", "A1B2C3D4E5F6G7H8")) + for name, token in tokens.items(): + (root / f"custom-{name}" / "credential.txt").write_text( + f'token = "{token}"\n', encoding="utf-8" + ) + Path(sys.argv[2]).write_text(f'key = "{aws_key}"\n', encoding="utf-8") + PY + for repository in "${default_trigger}" "${test_root}"/custom-*; do + git -C "${repository}" add credential.txt + git -C "${repository}" commit --quiet -m synthetic-trigger + printf 'credential removed\n' > "${repository}/credential.txt" + git -C "${repository}" add credential.txt + git -C "${repository}" commit --quiet -m remove-trigger + done + printf 'token = "example-not-a-credential"\n' > "${control_repository}/control.txt" + git -C "${control_repository}" add control.txt + git -C "${control_repository}" commit --quiet -m benign-control + + for format in "${custom_formats[@]}"; do + set +e + timeout --signal=TERM --kill-after=10s 1m \ + "${SECURITY_TOOLS}/bin/gitleaks" git \ + --config "${GITHUB_WORKSPACE}/.gitleaks.toml" --log-opts=--all \ + --redact=100 --report-format json \ + --report-path "${test_root}/custom-${format}.json" \ + --no-banner "${test_root}/custom-${format}" >/dev/null 2>&1 + custom_trigger_code=$? + set -e + [[ "${custom_trigger_code}" == 1 ]] + done + set +e + timeout --signal=TERM --kill-after=10s 1m \ + "${SECURITY_TOOLS}/bin/gitleaks" git \ + --config "${GITHUB_WORKSPACE}/.gitleaks.toml" --log-opts=--all \ + --redact=100 --report-format json \ + --report-path "${test_root}/custom-control.json" \ + --no-banner "${control_repository}" >/dev/null 2>&1 + custom_control_code=$? + timeout --signal=TERM --kill-after=10s 1m \ + "${SECURITY_TOOLS}/bin/gitleaks" git --log-opts=--all \ + --redact=100 --report-format json \ + --report-path "${test_root}/default-trigger.json" \ + --no-banner "${default_trigger}" >/dev/null 2>&1 + default_trigger_code=$? + timeout --signal=TERM --kill-after=10s 1m \ + "${SECURITY_TOOLS}/bin/gitleaks" git --log-opts=--all \ + --redact=100 --report-format json \ + --report-path "${test_root}/default-control.json" \ + --no-banner "${control_repository}" >/dev/null 2>&1 + default_control_code=$? + set -e + [[ "${custom_control_code}" == 0 && "${default_trigger_code}" == 1 && \ + "${default_control_code}" == 0 ]] + ) + set +e + gitleaks_self_test + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/gitleaks-self-test.exit" + + record_command trivy-download curl --fail --location --show-error \ + --retry 3 --retry-all-errors --output "${SECURITY_TOOLS}/downloads/trivy.tar.gz" \ + "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" + printf '%s %s\n' "${TRIVY_SHA256}" \ + "${SECURITY_TOOLS}/downloads/trivy.tar.gz" > "${SECURITY_TOOLS}/trivy.sha256" + record_command trivy-hash sha256sum --check --strict \ + "${SECURITY_TOOLS}/trivy.sha256" + if [[ "$(<"${REPORTS}/status/trivy-hash.exit")" == 0 ]]; then + record_command trivy-extract tar --extract --gzip \ + --file "${SECURITY_TOOLS}/downloads/trivy.tar.gz" \ + --directory "${SECURITY_TOOLS}/bin" trivy + else + printf '125\n' > "${REPORTS}/status/trivy-extract.exit" + fi + + - name: Collect source security reports + timeout-minutes: 30 + shell: bash + run: | + set -euo pipefail + PIP_AUDIT="${SECURITY_TOOLS}/pip-audit-venv/bin/pip-audit" + GITLEAKS="${SECURITY_TOOLS}/bin/gitleaks" + TRIVY="${SECURITY_TOOLS}/bin/trivy" + GITLEAKS_TEMP="$(mktemp -d "${SECURITY_TOOLS}/gitleaks-run.XXXXXXXX")" + RAW_GITLEAKS_DEFAULT="${GITLEAKS_TEMP}/default.json" + RAW_GITLEAKS_CUSTOM="${GITLEAKS_TEMP}/custom.json" + RAW_GITLEAKS="${GITLEAKS_TEMP}/combined.json" + DEFAULT_GITLEAKS_REPOSITORY="${GITLEAKS_TEMP}/default-worktree" + cleanup_source_scan() { + original_code=$? + trap - EXIT + set +e + cleanup_code=0 + if [[ -e "${DEFAULT_GITLEAKS_REPOSITORY}/.git" ]]; then + git worktree remove --force "${DEFAULT_GITLEAKS_REPOSITORY}" \ + >/dev/null 2>&1 + cleanup_code=$? + fi + git worktree prune + prune_code=$? + rm -r -- "${GITLEAKS_TEMP}" + remove_code=$? + if [[ "${cleanup_code}" != 0 || "${prune_code}" != 0 || \ + "${remove_code}" != 0 ]]; then + cleanup_code=125 + fi + printf '%s\n' "${cleanup_code}" > "${REPORTS}/status/gitleaks-cleanup.exit" + set -e + if [[ "${cleanup_code}" != 0 ]]; then + exit 125 + fi + exit "${original_code}" + } + trap cleanup_source_scan EXIT + + set +e + timeout --signal=TERM --kill-after=30s 2m python3 scripts/security_gate.py \ + source-inventory --repository . --output "${REPORTS}/source-inventory.json" \ + --requirements-directory "${SOURCE_REQUIREMENTS}" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/source-inventory.exit" + + # Gitleaks 8.30.1 drops newly added rules when extend.useDefault is used (#2170). + # Run its built-in defaults on a config-free temporary worktree, run the explicit + # repository rules separately, then combine only temporary raw reports. + set +e + git worktree add --detach "${DEFAULT_GITLEAKS_REPOSITORY}" "${GITHUB_SHA}" \ + >/dev/null 2>&1 + default_code=$? + if [[ "${default_code}" == 0 ]]; then + rm -f -- "${DEFAULT_GITLEAKS_REPOSITORY}/.gitleaks.toml" + remove_config_code=$? + if [[ "${remove_config_code}" == 0 ]]; then + timeout --signal=TERM --kill-after=30s 10m "${GITLEAKS}" git \ + --log-opts=--all --redact=100 --report-format json \ + --report-path "${RAW_GITLEAKS_DEFAULT}" --no-banner \ + "${DEFAULT_GITLEAKS_REPOSITORY}" + default_code=$? + else + default_code=125 + fi + fi + set -e + printf '%s\n' "${default_code}" > "${REPORTS}/status/gitleaks-default.exit" + + set +e + timeout --signal=TERM --kill-after=30s 10m "${GITLEAKS}" git \ + --config .gitleaks.toml --log-opts=--all --redact=100 --report-format json \ + --report-path "${RAW_GITLEAKS_CUSTOM}" --no-banner . + custom_code=$? + set -e + printf '%s\n' "${custom_code}" > "${REPORTS}/status/gitleaks-custom.exit" + + set +e + python3 - "${RAW_GITLEAKS_DEFAULT}" "${RAW_GITLEAKS_CUSTOM}" \ + "${RAW_GITLEAKS}" <<'PY' + import json + import sys + from pathlib import Path + + findings = [] + for value in sys.argv[1:3]: + report = json.loads(Path(value).read_text(encoding="utf-8")) + if not isinstance(report, list): + raise TypeError(f"Gitleaks report is not a list: {value}") + findings.extend(report) + Path(sys.argv[3]).write_text( + json.dumps(findings, separators=(",", ":")) + "\n", encoding="utf-8" + ) + PY + merge_code=$? + set -e + printf '%s\n' "${merge_code}" > "${REPORTS}/status/gitleaks-merge.exit" + if [[ ( "${default_code}" == 0 || "${default_code}" == 1 ) && \ + ( "${custom_code}" == 0 || "${custom_code}" == 1 ) && \ + "${merge_code}" == 0 ]]; then + if [[ "${default_code}" == 1 || "${custom_code}" == 1 ]]; then + code=1 + else + code=0 + fi + else + code=125 + fi + printf '%s\n' "${code}" > "${REPORTS}/status/gitleaks.exit" + set +e + timeout --signal=TERM --kill-after=30s 2m python3 scripts/security_gate.py \ + sanitize-gitleaks --input "${RAW_GITLEAKS}" --output "${REPORTS}/gitleaks.json" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/gitleaks-sanitize.exit" + + set +e + timeout --signal=TERM --kill-after=30s 10m "${PIP_AUDIT}" --strict \ + --progress-spinner off --format json \ + --output "${REPORTS}/pip-source-backend.json" \ + --requirement "${SOURCE_REQUIREMENTS}/backend.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-source-backend.exit" + set +e + timeout --signal=TERM --kill-after=30s 10m "${PIP_AUDIT}" --strict \ + --progress-spinner off --format json \ + --output "${REPORTS}/pip-source-node-agent.json" \ + --requirement "${SOURCE_REQUIREMENTS}/node-agent.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-source-node-agent.exit" + set +e + timeout --signal=TERM --kill-after=30s 10m "${PIP_AUDIT}" --strict \ + --progress-spinner off --format json \ + --output "${REPORTS}/pip-source-runtime-worker.json" \ + --requirement "${SOURCE_REQUIREMENTS}/runtime-worker.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-source-runtime-worker.exit" + + set +e + npm --version > "${REPORTS}/versions/npm.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/npm-version.exit" + set +e + (cd frontend && timeout --signal=TERM --kill-after=30s 10m npm audit --json \ + > "${REPORTS}/npm-audit-all.json") + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/npm-audit-all.exit" + set +e + (cd frontend && timeout --signal=TERM --kill-after=30s 10m npm audit --omit=dev --json \ + > "${REPORTS}/npm-audit-production.json") + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/npm-audit-production.exit" + + set +e + timeout --signal=TERM --kill-after=30s 20m "${TRIVY}" fs \ + --scanners vuln,misconfig --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL \ + --format json --output "${REPORTS}/trivy-fs.json" "${GITHUB_WORKSPACE}" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/trivy-fs.exit" + + set +e + timeout --signal=TERM --kill-after=30s 1m "${TRIVY}" version --format json \ + > "${REPORTS}/versions/trivy.json" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/trivy-version.exit" + + - name: Build and scan exact shipping images + if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.profile == 'security' }} + timeout-minutes: 40 + shell: bash + run: | + set -euo pipefail + PIP_AUDIT="${SECURITY_TOOLS}/pip-audit-venv/bin/pip-audit" + TRIVY="${SECURITY_TOOLS}/bin/trivy" + API_IMAGE="modelforge-security-api:${GITHUB_SHA}" + WEB_IMAGE="modelforge-security-web:${GITHUB_SHA}" + NODE_AGENT_IMAGE="modelforge-security-node-agent:${GITHUB_SHA}" + RUNTIME_WORKER_IMAGE="modelforge-security-runtime-worker:${GITHUB_SHA}" + install -d -m 700 "${IMAGE_REQUIREMENTS}" + + record_command() { + name="$1" + shift + set +e + timeout --signal=TERM --kill-after=30s 25m "$@" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/${name}.exit" + } + + record_command docker docker version + record_command image-build-api docker build --pull --no-cache \ + --build-arg "MODELFORGE_COMMIT=${GITHUB_SHA}" \ + --build-arg "MODELFORGE_VERSION=${MODELFORGE_VERSION}" \ + --label "org.opencontainers.image.source=${SOURCE_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --label "org.opencontainers.image.version=${MODELFORGE_VERSION}" \ + --tag "${API_IMAGE}" \ + --file backend/Dockerfile . + record_command image-build-web docker build --pull --no-cache \ + --build-arg "MODELFORGE_COMMIT=${GITHUB_SHA}" \ + --build-arg "MODELFORGE_VERSION=${MODELFORGE_VERSION}" \ + --build-arg "VITE_API_BASE_URL=https://security-scan.invalid" \ + --label "org.opencontainers.image.source=${SOURCE_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --label "org.opencontainers.image.version=${MODELFORGE_VERSION}" \ + --label "io.modelforge.security.bundle-path=/usr/share/nginx/html" \ + --tag "${WEB_IMAGE}" \ + --file frontend/Dockerfile frontend + record_command image-build-node-agent docker build --pull --no-cache \ + --build-arg "MODELFORGE_COMMIT=${GITHUB_SHA}" \ + --build-arg "MODELFORGE_VERSION=${MODELFORGE_VERSION}" \ + --label "org.opencontainers.image.source=${SOURCE_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --label "org.opencontainers.image.version=${MODELFORGE_VERSION}" \ + --tag "${NODE_AGENT_IMAGE}" \ + --file node-agent/Dockerfile . + record_command image-build-runtime-worker docker build --pull --no-cache \ + --build-arg "MODELFORGE_COMMIT=${GITHUB_SHA}" \ + --build-arg "MODELFORGE_VERSION=${MODELFORGE_VERSION}" \ + --label "org.opencontainers.image.source=${SOURCE_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --label "org.opencontainers.image.version=${MODELFORGE_VERSION}" \ + --tag "${RUNTIME_WORKER_IMAGE}" \ + --file runtime-worker/Dockerfile . + + for target in api web node-agent runtime-worker; do + case "${target}" in + api) image="${API_IMAGE}" ;; + web) image="${WEB_IMAGE}" ;; + node-agent) image="${NODE_AGENT_IMAGE}" ;; + runtime-worker) image="${RUNTIME_WORKER_IMAGE}" ;; + esac + set +e + timeout --signal=TERM --kill-after=30s 3m python3 scripts/security_gate.py \ + image-identity --target "${target}" --tag "${image}" \ + --expected-revision "${GITHUB_SHA}" \ + --expected-source "${SOURCE_REPOSITORY}" \ + --expected-version "${MODELFORGE_VERSION}" \ + --output "${REPORTS}/image-identity-${target}.json" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/image-identity-${target}.exit" + done + + image_field() { + python3 - "$1" "$2" <<'PY' + import json + import sys + from pathlib import Path + + print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))[sys.argv[2]]) + PY + } + set +e + API_ID="$(image_field "${REPORTS}/image-identity-api.json" image_id)" \ + || API_ID=invalid + WEB_ID="$(image_field "${REPORTS}/image-identity-web.json" image_id)" \ + || WEB_ID=invalid + WEB_BUNDLE_PATH="$(image_field "${REPORTS}/image-identity-web.json" bundle_path)" \ + || WEB_BUNDLE_PATH=invalid + NODE_AGENT_ID="$(image_field "${REPORTS}/image-identity-node-agent.json" image_id)" \ + || NODE_AGENT_ID=invalid + RUNTIME_WORKER_ID="$(image_field \ + "${REPORTS}/image-identity-runtime-worker.json" image_id)" \ + || RUNTIME_WORKER_ID=invalid + set -e + + set +e + timeout --signal=TERM --kill-after=30s 3m python3 scripts/security_gate.py \ + web-bundle-evidence --image-id "${WEB_ID}" \ + --bundle-path "${WEB_BUNDLE_PATH}" \ + --expected-origin https://security-scan.invalid \ + --output "${REPORTS}/web-bundle-origin.json" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/web-bundle-origin.exit" + + INVENTORY_PROGRAM='import importlib.metadata as m,json,platform; print(json.dumps({"python_version":platform.python_version(),"packages":[{"name":d.metadata.get("Name",d.name),"version":d.version} for d in m.distributions()]}))' + runtime_python() { + image="$1" + for candidate in python python3; do + if docker run --rm --entrypoint "${candidate}" "${image}" \ + -c 'import sys; print(sys.executable)' >/dev/null 2>&1; then + printf '%s\n' "${candidate}" + return 0 + fi + done + return 1 + } + collect_inventory() { + target="$1" + image="$2" + raw="${SECURITY_TOOLS}/${target}-inventory.json" + set +e + python_command="$(runtime_python "${image}")" + code=$? + if [[ "${code}" == 0 ]]; then + timeout --signal=TERM --kill-after=30s 5m docker run --rm \ + --entrypoint "${python_command}" \ + "${image}" -c "${INVENTORY_PROGRAM}" > "${raw}" + code=$? + fi + if [[ "${code}" == 0 && "${target}" == "runtime-worker" ]]; then + timeout --signal=TERM --kill-after=30s 5m docker run --rm \ + --entrypoint "${python_command}" \ + "${image}" -c 'import importlib.metadata as m; import modelforge_runtime_worker.main, sentence_transformers, transformers, filelock, h2, idna, lief, pygments, requests; packages={d.metadata.get("Name", d.name).lower() for d in m.distributions()}; assert not ({"pip", "setuptools", "wheel"} & packages), packages' + code=$? + fi + if [[ "${code}" == 0 ]]; then + python3 scripts/security_gate.py image-inventory --target "${target}" \ + --input "${raw}" --output "${REPORTS}/image-inventory-${target}.json" \ + --requirements-output "${IMAGE_REQUIREMENTS}/${target}.txt" + code=$? + fi + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/image-inventory-${target}.exit" + } + collect_inventory api "${API_ID}" + collect_inventory node-agent "${NODE_AGENT_ID}" + collect_inventory runtime-worker "${RUNTIME_WORKER_ID}" + + set +e + timeout --signal=TERM --kill-after=30s 10m "${PIP_AUDIT}" --strict --no-deps \ + --disable-pip --progress-spinner off --format json \ + --output "${REPORTS}/pip-image-api.json" \ + --requirement "${IMAGE_REQUIREMENTS}/api.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-image-api.exit" + set +e + timeout --signal=TERM --kill-after=30s 10m "${PIP_AUDIT}" --strict --no-deps \ + --disable-pip --progress-spinner off --format json \ + --output "${REPORTS}/pip-image-node-agent.json" \ + --requirement "${IMAGE_REQUIREMENTS}/node-agent.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-image-node-agent.exit" + set +e + timeout --signal=TERM --kill-after=30s 10m "${PIP_AUDIT}" --strict --no-deps \ + --disable-pip --progress-spinner off --format json \ + --output "${REPORTS}/pip-image-runtime-worker.json" \ + --requirement "${IMAGE_REQUIREMENTS}/runtime-worker.txt" + code=$? + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/pip-image-runtime-worker.exit" + + scan_image() { + target="$1" + image_id="$2" + raw_report="${SECURITY_TOOLS}/trivy-image-${target}.raw.json" + set +e + timeout --signal=TERM --kill-after=30s 25m "${TRIVY}" image --scanners vuln \ + --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL --format json \ + --output "${raw_report}" "${image_id}" + code=$? + if [[ "${code}" == 0 ]]; then + python3 scripts/security_gate.py bind-trivy-image \ + --input "${raw_report}" \ + --output "${REPORTS}/trivy-image-${target}.json" \ + --image-id "${image_id}" \ + --expected-tag "modelforge-security-${target}:${GITHUB_SHA}" + code=$? + fi + set -e + printf '%s\n' "${code}" > "${REPORTS}/status/trivy-image-${target}.exit" + } + scan_image api "${API_ID}" + scan_image web "${WEB_ID}" + scan_image node-agent "${NODE_AGENT_ID}" + scan_image runtime-worker "${RUNTIME_WORKER_ID}" + + - name: Remove exact security images + if: ${{ always() && gitea.event_name == 'workflow_dispatch' && inputs.profile == 'security' }} + timeout-minutes: 5 + shell: bash + run: | + set -euo pipefail + code=0 + for target in api web node-agent runtime-worker; do + image="modelforge-security-${target}:${GITHUB_SHA}" + if docker image inspect "${image}" >/dev/null 2>&1; then + docker image rm "${image}" >/dev/null || code=1 + fi + if docker image inspect "${image}" >/dev/null 2>&1; then + code=1 + fi + done + printf '%s\n' "${code}" > "${REPORTS}/status/image-cleanup.exit" + exit "${code}" + + - name: Evaluate the complete security gate + if: always() + timeout-minutes: 5 + shell: bash + run: | + python3 scripts/security_gate.py evaluate --reports-dir "${REPORTS}" \ + --accepted-risks config/security/accepted-risks.json \ + --gitleaks-false-positives config/security/gitleaks-false-positives.json \ + --repository . \ + --commit "${GITHUB_SHA}" --ref "${GITHUB_REF}" --run-id "${GITHUB_RUN_ID}" \ + --source-repository "${SOURCE_REPOSITORY}" \ + --profile "${SECURITY_PROFILE}" \ + --output "${REPORTS}/security-gate-result.json" + + - name: Upload immutable security evidence + if: always() + timeout-minutes: 5 + uses: https://gitea.com/actions/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: security-gate-${{ gitea.run_id }}-${{ gitea.run_attempt }}-${{ gitea.sha }} + # Do not upload the report root wholesale. The evaluator accepts only this same fixed + # evidence manifest, so a scanner/tool side-file can neither be hashed nor exported. + path: | + ${{ env.REPORTS }}/security-gate-result.json + ${{ env.REPORTS }}/git-history.json + ${{ env.REPORTS }}/source-inventory.json + ${{ env.REPORTS }}/gitleaks.json + ${{ env.REPORTS }}/npm-audit-all.json + ${{ env.REPORTS }}/npm-audit-production.json + ${{ env.REPORTS }}/trivy-fs.json + ${{ env.REPORTS }}/versions/pip-audit.txt + ${{ env.REPORTS }}/versions/gitleaks.txt + ${{ env.REPORTS }}/versions/npm.txt + ${{ env.REPORTS }}/versions/trivy.json + ${{ env.REPORTS }}/source-requirements/backend.txt + ${{ env.REPORTS }}/source-requirements/node-agent.txt + ${{ env.REPORTS }}/source-requirements/runtime-worker.txt + ${{ env.REPORTS }}/pip-source-backend.json + ${{ env.REPORTS }}/pip-source-node-agent.json + ${{ env.REPORTS }}/pip-source-runtime-worker.json + ${{ env.REPORTS }}/image-identity-api.json + ${{ env.REPORTS }}/image-identity-web.json + ${{ env.REPORTS }}/image-identity-node-agent.json + ${{ env.REPORTS }}/image-identity-runtime-worker.json + ${{ env.REPORTS }}/web-bundle-origin.json + ${{ env.REPORTS }}/image-inventory-api.json + ${{ env.REPORTS }}/image-inventory-node-agent.json + ${{ env.REPORTS }}/image-inventory-runtime-worker.json + ${{ env.REPORTS }}/image-requirements/api.txt + ${{ env.REPORTS }}/image-requirements/node-agent.txt + ${{ env.REPORTS }}/image-requirements/runtime-worker.txt + ${{ env.REPORTS }}/pip-image-api.json + ${{ env.REPORTS }}/pip-image-node-agent.json + ${{ env.REPORTS }}/pip-image-runtime-worker.json + ${{ env.REPORTS }}/trivy-image-api.json + ${{ env.REPORTS }}/trivy-image-web.json + ${{ env.REPORTS }}/trivy-image-node-agent.json + ${{ env.REPORTS }}/trivy-image-runtime-worker.json + ${{ env.REPORTS }}/status/history-fetch.exit + ${{ env.REPORTS }}/status/history.exit + ${{ env.REPORTS }}/status/release-version.exit + ${{ env.REPORTS }}/status/pip-audit-download.exit + ${{ env.REPORTS }}/status/pip-audit-hash.exit + ${{ env.REPORTS }}/status/pip-audit-install.exit + ${{ env.REPORTS }}/status/pip-audit-version.exit + ${{ env.REPORTS }}/status/gitleaks-download.exit + ${{ env.REPORTS }}/status/gitleaks-hash.exit + ${{ env.REPORTS }}/status/gitleaks-extract.exit + ${{ env.REPORTS }}/status/gitleaks-version.exit + ${{ env.REPORTS }}/status/gitleaks-self-test.exit + ${{ env.REPORTS }}/status/trivy-download.exit + ${{ env.REPORTS }}/status/trivy-hash.exit + ${{ env.REPORTS }}/status/trivy-extract.exit + ${{ env.REPORTS }}/status/trivy-version.exit + ${{ env.REPORTS }}/status/source-inventory.exit + ${{ env.REPORTS }}/status/gitleaks-default.exit + ${{ env.REPORTS }}/status/gitleaks-custom.exit + ${{ env.REPORTS }}/status/gitleaks-merge.exit + ${{ env.REPORTS }}/status/gitleaks-cleanup.exit + ${{ env.REPORTS }}/status/gitleaks.exit + ${{ env.REPORTS }}/status/gitleaks-sanitize.exit + ${{ env.REPORTS }}/status/pip-source-backend.exit + ${{ env.REPORTS }}/status/pip-source-node-agent.exit + ${{ env.REPORTS }}/status/pip-source-runtime-worker.exit + ${{ env.REPORTS }}/status/npm-version.exit + ${{ env.REPORTS }}/status/npm-audit-all.exit + ${{ env.REPORTS }}/status/npm-audit-production.exit + ${{ env.REPORTS }}/status/trivy-fs.exit + ${{ env.REPORTS }}/status/docker.exit + ${{ env.REPORTS }}/status/image-build-api.exit + ${{ env.REPORTS }}/status/image-build-web.exit + ${{ env.REPORTS }}/status/image-build-node-agent.exit + ${{ env.REPORTS }}/status/image-build-runtime-worker.exit + ${{ env.REPORTS }}/status/image-identity-api.exit + ${{ env.REPORTS }}/status/image-identity-web.exit + ${{ env.REPORTS }}/status/image-identity-node-agent.exit + ${{ env.REPORTS }}/status/image-identity-runtime-worker.exit + ${{ env.REPORTS }}/status/web-bundle-origin.exit + ${{ env.REPORTS }}/status/image-inventory-api.exit + ${{ env.REPORTS }}/status/image-inventory-node-agent.exit + ${{ env.REPORTS }}/status/image-inventory-runtime-worker.exit + ${{ env.REPORTS }}/status/pip-image-api.exit + ${{ env.REPORTS }}/status/pip-image-node-agent.exit + ${{ env.REPORTS }}/status/pip-image-runtime-worker.exit + ${{ env.REPORTS }}/status/trivy-image-api.exit + ${{ env.REPORTS }}/status/trivy-image-web.exit + ${{ env.REPORTS }}/status/trivy-image-node-agent.exit + ${{ env.REPORTS }}/status/trivy-image-runtime-worker.exit + ${{ env.REPORTS }}/status/image-cleanup.exit + if-no-files-found: error + retention-days: 90 + # Gitea only lists dispatchable workflow files from the default branch. Reusing the dedicated - # workflow from the existing managed entry point lets an unmerged branch prove its exact public - # export on the server. Ordinary PR validation and every profile except an explicit `build` - # dispatch remain unchanged. + # workflow from this managed entry point lets an unmerged branch prove its exact public export on + # the server. Ordinary PR validation and every profile except an explicit `build` dispatch remain + # unchanged. public_candidate_acceptance: name: Public candidate server acceptance if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.profile == 'build' }} diff --git a/.gitea/workflows/public-candidate-acceptance.yml b/.gitea/workflows/public-candidate-acceptance.yml index 4b526b2..8cd4978 100644 --- a/.gitea/workflows/public-candidate-acceptance.yml +++ b/.gitea/workflows/public-candidate-acceptance.yml @@ -1,6 +1,6 @@ name: Public candidate server acceptance -# Builds only an exact, curated public-source commit in a disposable Compose namespace. This job +# Builds only an exact, curated public-source commit in a disposable validation Compose namespace. This job # never calls the Unraid deploy controller and cannot select the production deployment action. on: workflow_dispatch: @@ -14,6 +14,11 @@ on: required: true default: https://modelforge.example.test type: string + retain_image_bundle: + description: Retain the verified four-image Docker bundle for P12-P15 qualification + required: false + default: false + type: boolean workflow_call: inputs: source_commit: @@ -24,6 +29,11 @@ on: description: Bare API origin compiled into the candidate Console image required: true type: string + retain_image_bundle: + description: Retain the verified four-image Docker bundle for P12-P15 qualification + required: false + default: false + type: boolean permissions: contents: read @@ -140,14 +150,23 @@ jobs: shell: bash env: PUBLIC_API_ORIGIN: ${{ inputs.public_api_origin }} + RETAIN_IMAGE_BUNDLE: ${{ inputs.retain_image_bundle }} + SOURCE_COMMIT: ${{ inputs.source_commit }} run: | set -euo pipefail cd "${PUBLIC_SOURCE}" - python3 scripts/rc_server_acceptance.py \ - --public-api-origin "${PUBLIC_API_ORIGIN}" \ - --trivy "${TRIVY}" \ - --output acceptance-evidence \ + arguments=( + --public-api-origin "${PUBLIC_API_ORIGIN}" + --canonical-source-commit "${SOURCE_COMMIT}" + --trivy "${TRIVY}" + --output acceptance-evidence --project-suffix "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + ) + if [[ "${RETAIN_IMAGE_BUNDLE}" == "true" ]]; then + arguments+=(--retain-image-bundle) + fi + python3 scripts/rc_server_acceptance.py \ + "${arguments[@]}" - name: Always remove acceptance Docker resources if: always() @@ -157,7 +176,7 @@ jobs: cleanup_failed=0 suffix="$(printf '%s' "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | tail -c 24)" - project="modelforge-rc-${suffix}" + project="modelforge-validation-${suffix}" for kind in container volume network; do while IFS= read -r resource; do [[ -n "${resource}" ]] || continue @@ -171,7 +190,7 @@ jobs: done if [[ -n "${PUBLIC_SOURCE:-}" && -d "${PUBLIC_SOURCE}/.git" ]]; then - candidate_commit="$(git -C "${PUBLIC_SOURCE}" rev-parse HEAD 2>/dev/null)" + canonical_commit="${{ inputs.source_commit }}" version="$(tr -d '\r\n' < "${PUBLIC_SOURCE}/VERSION" 2>/dev/null)" for image in modelforge-api modelforge-web modelforge-node-agent \ modelforge-runtime-worker; do @@ -179,7 +198,7 @@ jobs: revision="$(docker inspect --format \ '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ "${tag}" 2>/dev/null)" - if [[ -n "${candidate_commit}" && "${revision}" == "${candidate_commit}" ]]; then + if [[ "${revision}" == "${canonical_commit}" ]]; then docker image rm --force "${tag}" || cleanup_failed=1 fi done @@ -208,6 +227,8 @@ jobs: ${{ env.PUBLIC_SOURCE }}/acceptance-evidence/*.json ${{ env.PUBLIC_SOURCE }}/acceptance-evidence/*.txt ${{ env.PUBLIC_SOURCE }}/acceptance-evidence/release/*.json + ${{ env.PUBLIC_SOURCE }}/acceptance-evidence/release/*.tar + ${{ env.PUBLIC_SOURCE }}/acceptance-evidence/release/*.tar.gz ${{ env.PUBLIC_SOURCE }}/acceptance-evidence/release/*SHA256SUMS if-no-files-found: warn retention-days: 90 diff --git a/.gitea/workflows/unraid-deploy.yml b/.gitea/workflows/unraid-deploy.yml deleted file mode 100644 index ee2a351..0000000 --- a/.gitea/workflows/unraid-deploy.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Unraid stable release deployment - -on: - workflow_dispatch: - inputs: - release_tag: - description: Annotated stable release tag (for example, v1.2.1) - required: true - type: string - release_commit: - description: Exact lowercase 40-character commit SHA referenced by the tag - required: true - type: string - action: - description: Verify provenance only, or deploy the verified stable release - required: true - default: VERIFY_ONLY - type: choice - options: - - VERIFY_ONLY - - DEPLOY_STABLE_TO_PRODUCTION - -concurrency: - group: unraid-production-itworx-modelforge - cancel-in-progress: false - -permissions: - contents: read - -jobs: - deploy: - name: Verify and optionally deploy a stable release - runs-on: unraid-deploy - timeout-minutes: 180 - steps: - - name: Validate immutable dispatch inputs - shell: bash - env: - RELEASE_TAG: ${{ inputs.release_tag }} - RELEASE_COMMIT: ${{ inputs.release_commit }} - DEPLOY_ACTION: ${{ inputs.action }} - run: | - set -euo pipefail - - if [[ "${GITHUB_REF:-}" != "refs/heads/master" ]]; then - echo "Stable production deployment must be dispatched from refs/heads/master" >&2 - exit 2 - fi - if [[ ! "${RELEASE_TAG}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "release_tag must be a strict stable SemVer tag such as v1.2.1" >&2 - exit 2 - fi - if [[ ! "${RELEASE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then - echo "release_commit must be an exact lowercase 40-character SHA" >&2 - exit 2 - fi - case "${DEPLOY_ACTION}" in - VERIFY_ONLY|DEPLOY_STABLE_TO_PRODUCTION) ;; - *) echo "action is not allowlisted: ${DEPLOY_ACTION}" >&2; exit 2 ;; - esac - - - name: Check out the exact release commit with full history - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ inputs.release_commit }} - fetch-depth: 0 - # The following provenance step performs an authenticated exact-ref fetch. - persist-credentials: true - - - name: Verify stable release provenance - shell: bash - env: - RELEASE_TAG: ${{ inputs.release_tag }} - RELEASE_COMMIT: ${{ inputs.release_commit }} - run: | - set -euo pipefail - - git fetch --force --no-recurse-submodules origin \ - "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" \ - "refs/heads/master:refs/remotes/origin/master" - - tag_ref="refs/tags/${RELEASE_TAG}" - if [[ "$(git cat-file -t "${tag_ref}")" != "tag" ]]; then - echo "${RELEASE_TAG} must be an annotated tag" >&2 - exit 1 - fi - - tag_object="$(git rev-parse "${tag_ref}^{tag}")" - tag_commit="$(git rev-parse "${tag_ref}^{commit}")" - head_commit="$(git rev-parse HEAD)" - if [[ "${tag_commit}" != "${RELEASE_COMMIT}" ]]; then - echo "Tag commit ${tag_commit} does not match release_commit ${RELEASE_COMMIT}" >&2 - exit 1 - fi - if [[ "${head_commit}" != "${RELEASE_COMMIT}" ]]; then - echo "Checked-out HEAD ${head_commit} does not match release_commit ${RELEASE_COMMIT}" >&2 - exit 1 - fi - - if [[ ! -f VERSION ]]; then - echo "VERSION is missing at the release commit" >&2 - exit 1 - fi - version="$(tr -d '\r\n' < VERSION)" - expected_version="${RELEASE_TAG#v}" - if [[ "${version}" != "${expected_version}" ]]; then - echo "VERSION ${version} does not match release tag ${RELEASE_TAG}" >&2 - exit 1 - fi - - if ! git merge-base --is-ancestor "${RELEASE_COMMIT}" refs/remotes/origin/master; then - echo "Release commit ${RELEASE_COMMIT} is not an ancestor of origin/master" >&2 - exit 1 - fi - - printf 'DEPLOY_COMMIT=%s\n' "${RELEASE_COMMIT}" >> "${GITHUB_ENV}" - echo "Verified annotated ${RELEASE_TAG} object ${tag_object} at immutable commit ${RELEASE_COMMIT}" - - - name: Verification-only result - if: ${{ inputs.action == 'VERIFY_ONLY' }} - shell: bash - run: echo "Stable release provenance verified; production was not changed." - - - name: Deploy verified stable release to production - if: ${{ inputs.action == 'DEPLOY_STABLE_TO_PRODUCTION' }} - shell: bash - run: | - set -euo pipefail - test -n "${DEPLOY_COMMIT:-}" - docker exec gitea-deploy-control \ - /opt/gitea-deploy/deploy.py deploy \ - "${GITHUB_REPOSITORY}" "${DEPLOY_COMMIT}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 354c1c9..ebcc4f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ commits are not listed unless they change what an operator sees or does. ## Unreleased +### Release qualification + +- Candidate version `1.3.0-rc.1` reports the `rc` channel rather than stable. +- The P15A path binds the exact canonical source identity, OCI labels and filesystem layer sets + into a retained image bundle before isolated RC qualification. +- This is not a release announcement: no RC tag, Gitea prerelease or production promotion is + authorized until the complete qualification matrix is verified. + +### Product workflow + +- Added the project-first model-management flow: persisted project bindings, human-readable model + and update context, guided onboarding, selected current/candidate comparison, impact and + migration/reindex boundaries, and explicit lifecycle verification/rollback. Projects continue + to bind to stable capabilities rather than model paths or concrete model names. +- Selected comparisons now retain immutable embedding-space dimensions and exact gateway-bound + current/candidate evidence; approved route replacement and rollback are fenced by generation + and CAS checks. + +### Fixed + +- The v1.2.1 upgrade path now detects when `modelforge` is PostgreSQL's protected bootstrap + superuser (OID 10) before mutating any role or privilege. Such installations are routed to a + documented fresh-cluster logical-restore path; direct system-catalog edits remain unsupported. +- Compose now passes the retained-admin variable names required by the bounded existing-cluster + provisioning script, while continuing to keep administrator and owner credentials out of the + API and migration processes. + +### Operations + +- Recorded the completed v1.2.2 production rollout, exact deployed image identities, role-split + cluster migration, verified recovery point, measured downtime and final production-worker LAB + probe. + +## v1.2.2 — 2026-09-01 + ### Security - Runtime Worker packaging now upgrades the digest-pinned Ubuntu base before installation and pins diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 818a8d2..957a78f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,9 @@ python -m ruff check backend/src backend/tests node-agent/src runtime-worker/src python -m mypy backend/src python -m mypy --config-file node-agent/pyproject.toml node-agent/src python -m mypy --config-file runtime-worker/pyproject.toml runtime-worker/src -python -m pytest backend/tests node-agent/tests runtime-worker/tests -q +(cd backend && python -m pytest -q) +(cd node-agent && python -m pytest -q) +(cd runtime-worker && python -m pytest -q) cd frontend && npm test -- --run && npm run build ``` @@ -35,6 +37,8 @@ production evidence, model weights, generated backups or personal data. - Applications depend on versioned capabilities, never concrete model names. - Production identities use exact commits, artifact digests and image digests. +- Document source, revision, license and checksum expectations for models and runtime artifacts. +- Use synthetic identities and configurable node addresses in tests and examples. - No production promotion occurs without evidence and explicit approval. - `trust_remote_code` stays false; untrusted model code is not executed. - Agents and runtime workers use narrow identities and least privilege. diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index e26201f..25d75ea 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,5 +1,5 @@ # Curated public source export -Generated from private canonical revision `b8755394117affad0178a79a627cf5574d3c8eef`. +Generated from private canonical revision `990401737f96c8d46a9f7b0d84994516cadcb6a0`. This parentless candidate excludes private operational history and uses synthetic example identifiers. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index cb2aa18..0ad3f93 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,16 +1,17 @@ { "schemaVersion": 1, - "sourceRevision": "b8755394117affad0178a79a627cf5574d3c8eef", + "sourceRevision": "990401737f96c8d46a9f7b0d84994516cadcb6a0", + "sourceRepository": "https://git.example.com/example/modelforge", "files": [ { "path": ".dockerignore", - "bytes": 183, - "sha256": "156d4a337261c194ec0a791884d7d1a5a588345d769ebf43b6e4f00f8d32008a" + "bytes": 1195, + "sha256": "b91cc0a07ff2a7aae4f251c8636aa8ab6d034ff5edef14f301488f7aceab3223" }, { "path": ".env.example", - "bytes": 13008, - "sha256": "b9d5da83afcdd6946bc7897cd6605c5ace2e1460600b1192684b8aff018bd916" + "bytes": 15513, + "sha256": "bc2bf470871abf659f24d99b9028ccb60d39cbff54098114f13169c57bd3070d" }, { "path": ".gitattributes", @@ -19,33 +20,48 @@ }, { "path": ".gitea/tests/test_production_deploy_policy.py", - "bytes": 9934, - "sha256": "1f5e5f656bbca3ed2992de3bd60aafc7534623d255ac7175a46e286b3853a4fc" + "bytes": 7353, + "sha256": "f41a5a2a99263cc2577cd5ce33b9702d357860e8c4e29095c9a103c747c362dd" }, { "path": ".gitea/tests/test_public_source_export.py", - "bytes": 8266, - "sha256": "826d1b83852b44b1bf6c8fe17b6b3300aaceb2894fda54bcfba8ea3c166d8deb" + "bytes": 14522, + "sha256": "3787851626421635a647b0a7b7280e6d873e351b9fcad18fcf06f992aac3bd53" }, { "path": ".gitea/tests/test_rc_acceptance_policy.py", - "bytes": 13392, - "sha256": "905b038d9056d2535f111621e340b0cf622ec08d890ad459355d0cacd3503c68" + "bytes": 20587, + "sha256": "f429a897dd7c583c954222178533b592d5559647d040ff5c21d97128e9fde1f8" + }, + { + "path": ".gitea/tests/test_rc_isolation_preflight.py", + "bytes": 11192, + "sha256": "5fc4dd0d22ad191495398d6e21a6c66c716b00a621e2a9da1d00079d3f07e0aa" + }, + { + "path": ".gitea/tests/test_rc_stack_retention.py", + "bytes": 13097, + "sha256": "3c3548f01ccffca8ffa1aa624fbbe32bffcd5871f33b9a4992117669286a59d7" + }, + { + "path": ".gitea/tests/test_security_origin_policy.py", + "bytes": 4002, + "sha256": "2636626bae46c6f485ea0cc20e78d6866ffc5e6676f21af73c8848958660dcf0" + }, + { + "path": ".gitea/tests/test_security_validation_policy.py", + "bytes": 113981, + "sha256": "c36b24c614fb976a0bd2f90a061139c10845ecd08981bc8fdf9e728f7de3e91a" }, { "path": ".gitea/workflows/managed-validation.yml", - "bytes": 13398, - "sha256": "cf43e95af3b58f663eca0b22330f5be2d01ba7d0807f45ec87029f2626a7bce7" + "bytes": 57275, + "sha256": "dd5c18b2c3d41c022d3cc8e22f2aa0171f76419fdd9d4b2c751700c8ba75355f" }, { "path": ".gitea/workflows/public-candidate-acceptance.yml", - "bytes": 9581, - "sha256": "1702ad62e4f41d0b564927f5667fdce2cbccb5344b4306b8bc2a95247f107dc8" - }, - { - "path": ".gitea/workflows/unraid-deploy.yml", - "bytes": 4842, - "sha256": "c0a08b4c7e1189d5631fb8d2ad7e7ecb0c8c2dec91a523e32a9a3f4bf3980a94" + "bytes": 10418, + "sha256": "83e34aa2780d666160b5c5da3bf68c3dcdf26c54414dd16650df898b12b07cbb" }, { "path": ".gitignore", @@ -74,8 +90,8 @@ }, { "path": "backend/alembic/versions/20260824_0001_m0_foundation.py", - "bytes": 7318, - "sha256": "1a9eedbf61eafdadb5237e735e5cee05bf0badfc19696d4494efa4c0d5742ab7" + "bytes": 11101, + "sha256": "28eb97036a00d8210b5d96c6c9271a6974122c8fe748d74c512acb6cc5d98715" }, { "path": "backend/alembic/versions/20260824_0002_m1_hardware_plane.py", @@ -189,18 +205,93 @@ }, { "path": "backend/alembic/versions/20260830_0024_audit_chain_checkpoint.py", - "bytes": 30108, - "sha256": "62eecea09c21e1ad437fc192510643d85ce94eb4f63e46b3a22b246f8595221d" + "bytes": 30107, + "sha256": "fab526a4f939beb88f6bc2eac508f02ce2ac75776d7f9caa943433986854bb5a" + }, + { + "path": "backend/alembic/versions/20260907_0025_configuration.py", + "bytes": 1789, + "sha256": "13a4ca879cb46cfb08f5bcbb510658714b8856ac1e8f4ff8faa1f1b4b744a4c2" + }, + { + "path": "backend/alembic/versions/20260908_0026_project_onboarding.py", + "bytes": 2255, + "sha256": "c690da538a1516dd2143da18519468d533d8553b274795e7e1703e2a8c96ee9e" + }, + { + "path": "backend/alembic/versions/20260908_0027_managed_operator_identities.py", + "bytes": 3690, + "sha256": "f66bf1e22728d4006e39b03305eee0b065f3f052294ada5f31e50f60a19e7dd2" + }, + { + "path": "backend/alembic/versions/20260908_0028_platform_secret_slots.py", + "bytes": 3336, + "sha256": "069eaf43a417d5334e71fa39432991be741139400d03da131700e330a715d998" + }, + { + "path": "backend/alembic/versions/20260909_0029_external_creative_coordination.py", + "bytes": 2891, + "sha256": "ac41923b2bd7a8729abeaae5c872afa3186d4b188fe002015d77c9bc23b9c5db" + }, + { + "path": "backend/alembic/versions/20260909_0030_model_update_inbox.py", + "bytes": 2463, + "sha256": "265379bd5e1c2cee78a9c4a4e6004ec802a6dbbc57e5f30c422e8eb91fea99cf" + }, + { + "path": "backend/alembic/versions/20260909_0031_external_creative_governance.py", + "bytes": 4476, + "sha256": "397d4b288bb7564eb50da5950e1478db237f2a91d801de3d3786112b638f2c55" + }, + { + "path": "backend/alembic/versions/20260909_0032_audio_creative_workloads.py", + "bytes": 1121, + "sha256": "ede826fa5ada798e5730ef99b0a8380d1831d3ed11a1baccda9ed5bc0e60d362" + }, + { + "path": "backend/alembic/versions/20260909_0033_deployed_hub_convergence.py", + "bytes": 6497, + "sha256": "373e9ce63db0a22d1491d8ab0523a639cecd776d656e8a54eb107d1cb454dc6a" + }, + { + "path": "backend/alembic/versions/20260910_0034_approved_serving_transition.py", + "bytes": 1098, + "sha256": "6fc188d8f929d587036d7be968f9dd818342a887584a75ffd577fc6278d66ba7" + }, + { + "path": "backend/alembic/versions/20260911_0035_user_model_change_cases.py", + "bytes": 4936, + "sha256": "55b050f9b20c608cc6e545ebb58ecfcaa90c7b64e5bb53bd07fd8c5db9ec869b" + }, + { + "path": "backend/alembic/versions/20260923_0036_portfolio_repository_reconciliation.py", + "bytes": 6508, + "sha256": "c54efb00c3c26eb6c09b3edc606584f2054036c0b60b3b05f19f1f1d28eb0aba" + }, + { + "path": "backend/alembic/versions/20260923_0037_alert_notification_outbox.py", + "bytes": 4497, + "sha256": "d6146257a67db5d70697875fc675057832413006b2dd7d59a9620d2e73555b56" + }, + { + "path": "backend/alembic/versions/20260924_0038_lab_execution_runs.py", + "bytes": 5677, + "sha256": "42b383d2eda6a9bf0f77f1eb557931f2ed32eeabf0339a521387092e9e7d41ee" + }, + { + "path": "backend/alembic/versions/20260924_0039_operator_benchmark_runs.py", + "bytes": 7556, + "sha256": "1487edd78480629b18f4067bf2e796aedfe3b264c69d8cb036286c1f4a37e26d" }, { "path": "backend/Dockerfile", - "bytes": 2533, - "sha256": "df0916fcdf1fd28f363b06872d9a04295eabb6b57405047e712c5e07fbb30857" + "bytes": 2815, + "sha256": "62b599253effcd23b357d0efd5ca41350a594748d270f36937ff07e5ef88d327" }, { "path": "backend/pyproject.toml", - "bytes": 1675, - "sha256": "2c2442e0a1993ab0262a0454bdf41da73d39f4d5d309f0aceccb9341e1a112f7" + "bytes": 1713, + "sha256": "77d5e11282a03d6ca619107a9bcabe34e11021607459f15304a1c81b28f63553" }, { "path": "backend/src/modelforge_api/__init__.py", @@ -214,13 +305,13 @@ }, { "path": "backend/src/modelforge_api/api/authorization.py", - "bytes": 8211, - "sha256": "ce778ce2de3fb8a3ecca1c1521cf7e19fcd6fa7427831376e8b4c8d1043c37b7" + "bytes": 12209, + "sha256": "fb90393fcb685e556ceb5f8600dc906ca673c1b78eed5368fe3135991ef35bb5" }, { "path": "backend/src/modelforge_api/api/request_limits.py", - "bytes": 8361, - "sha256": "b9efd47418abc2bae8f625a4f3d6b838aa753b0098db4ce1d1ca5dc701173cd9" + "bytes": 8578, + "sha256": "09fc7b445a35648a960083430b02befbcfd30da024ff53c801bfab6f8b238f42" }, { "path": "backend/src/modelforge_api/api/routes/__init__.py", @@ -229,24 +320,39 @@ }, { "path": "backend/src/modelforge_api/api/routes/acquisition.py", - "bytes": 4383, - "sha256": "2f082f9798a867e4a436bc61dcbd8fb39fa3fb1a399a5a9a629b69167254731e" + "bytes": 4332, + "sha256": "c0402cb57b93a770c8f52f29a0574e0e061e6b144757601ae06c9fc065364ade" }, { "path": "backend/src/modelforge_api/api/routes/agent.py", - "bytes": 11250, - "sha256": "1901e355fe4aad7a1a1a6be31d009dc3df8e24e2da7193ac2a5bfcd7f2b97d80" + "bytes": 12190, + "sha256": "e59978cce4721a0eb93d05f519ede39dd26609a9fc54b6f4cb76a65ee9acac89" + }, + { + "path": "backend/src/modelforge_api/api/routes/audit_timeline.py", + "bytes": 1152, + "sha256": "225175ade1e0cae2a871c82cfab4ff404433aed52814ceacd528026a0228fbf2" }, { "path": "backend/src/modelforge_api/api/routes/catalog.py", - "bytes": 11449, - "sha256": "7984703991e5e459269f7c37836aec22e8f64d2a6e86a26e07c7443e3502434a" + "bytes": 21047, + "sha256": "fd44a946b9db6d351ddd951ed4e46806ef41626c04682fa88b7ea69e8328b542" + }, + { + "path": "backend/src/modelforge_api/api/routes/configuration.py", + "bytes": 5220, + "sha256": "001b07f15222f86f4155ff617ce2584af9e907315d3b266a2a7158689e458e7b" }, { "path": "backend/src/modelforge_api/api/routes/evaluation.py", "bytes": 12620, "sha256": "8535fd9b5ab27f9d06005e93e5d3d80c54be6d64f196e310948a531ae22166a7" }, + { + "path": "backend/src/modelforge_api/api/routes/external_creative.py", + "bytes": 5944, + "sha256": "efa7b4dd589414cac8a943c05c12681e70996f0261c29de5d48efbe4955c17e6" + }, { "path": "backend/src/modelforge_api/api/routes/hardware.py", "bytes": 2945, @@ -257,25 +363,55 @@ "bytes": 3728, "sha256": "6d268e22b460796e30fe2801f57d103681605b4c15ec803492b03b20dd03e923" }, + { + "path": "backend/src/modelforge_api/api/routes/identity.py", + "bytes": 3017, + "sha256": "44e91c8f0995838c8d875de1d643d634f9ae43cea6ecfcb94ad5dcd695850b0c" + }, + { + "path": "backend/src/modelforge_api/api/routes/lab.py", + "bytes": 5445, + "sha256": "e627263d60b406535adcd4c59548af4f34f6d0ac06f4e0fbc6090efbf6edcc71" + }, { "path": "backend/src/modelforge_api/api/routes/lifecycle.py", - "bytes": 8374, - "sha256": "57ebee2c1c7aceffc046b38a2c6ef92bd1286fe6422535f2c084e2390f90c558" + "bytes": 8615, + "sha256": "7c1652ef465d211e3a009df5bc576fb2dc688b349800fdfa15705e819061e67d" }, { "path": "backend/src/modelforge_api/api/routes/migrations.py", - "bytes": 6546, - "sha256": "de16a2170f4434d9119cff111152ec73909cd12b76d08efd04c84e4f1ff8476c" + "bytes": 6622, + "sha256": "197278baf24b12601fca370ca93ea79bf401c7f32025901036c129010c1e8843" + }, + { + "path": "backend/src/modelforge_api/api/routes/model_updates.py", + "bytes": 3343, + "sha256": "0ffd52a3bfc08038310ed9085794fc36a1bf5356545f6109900b188e31a738db" }, { "path": "backend/src/modelforge_api/api/routes/observability.py", - "bytes": 7653, - "sha256": "2be728006566bc0b7b43c73cd1a1ef2fe073d00d6ebe7b00d86db3b617d252d4" + "bytes": 10535, + "sha256": "d492cb41bc61d403825c92616419f3b2b55372eccbebd0a29798ddfbc23205a9" + }, + { + "path": "backend/src/modelforge_api/api/routes/operator_login.py", + "bytes": 3141, + "sha256": "242d627a28636ad6338fc345dc71142345ed192f70c1a29a4dc79f9019cb4edf" + }, + { + "path": "backend/src/modelforge_api/api/routes/portfolio.py", + "bytes": 1795, + "sha256": "b51b81636939d225582875edc94023c09f04793c66c0c5dc2c3fc8d39473961a" + }, + { + "path": "backend/src/modelforge_api/api/routes/projects.py", + "bytes": 5707, + "sha256": "59e93c3608c284f177d450a92a2959ddd1bba3258820da29198a8ff0af636fd6" }, { "path": "backend/src/modelforge_api/api/routes/recovery.py", - "bytes": 6935, - "sha256": "7b78ba2e263e939947766b690ef46799c172ba31ef55d852ecd126e5b44ab9e5" + "bytes": 7165, + "sha256": "bfc212869415bcc79c30e7a337aee3505676163ab7aced089555d2e3539f32f2" }, { "path": "backend/src/modelforge_api/api/routes/registry.py", @@ -287,10 +423,25 @@ "bytes": 6235, "sha256": "69e33d9ccb9d571a4f76d1cf3ec22493b5c6c1fb5a0128b901b6ccca1ed7a7d9" }, + { + "path": "backend/src/modelforge_api/api/routes/selected_model_comparison.py", + "bytes": 4493, + "sha256": "33166b1a82ccd1c1a70a56e1df9eec9d0722a16a27aba31e62dea0650f31898e" + }, + { + "path": "backend/src/modelforge_api/api/routes/selected_replacement.py", + "bytes": 900, + "sha256": "c84c6ebe573c64a15a85a6174ef81ff32d6bd6b7082abad58952949f11d974e0" + }, { "path": "backend/src/modelforge_api/api/routes/serving.py", - "bytes": 18102, - "sha256": "4d6cf87cc542a72646e0b2c2df4b0494736ab721a5768da700a737aae0e04959" + "bytes": 23066, + "sha256": "11d9e3f5755b155146e0d192fbba7bc19ba0a9147c1d1abec8b855e85dfc8ba2" + }, + { + "path": "backend/src/modelforge_api/api/routes/update_preflight.py", + "bytes": 1218, + "sha256": "b3f48d4a457bc2e06a4b20e93022173e3398c857ff2b338c3a406cd1d24e5cfb" }, { "path": "backend/src/modelforge_api/cli/__init__.py", @@ -319,28 +470,58 @@ }, { "path": "backend/src/modelforge_api/domain/agent_protocol.py", - "bytes": 3844, - "sha256": "96036412dee4280f324b03fa44f147c9d8618d73441060ae9695d99f5e33bfee" + "bytes": 3884, + "sha256": "dbab36a05e7483db502d8ff7a5bdc30da83e4f76bc0220624d1fa953c8859cb1" + }, + { + "path": "backend/src/modelforge_api/domain/alert_notifications.py", + "bytes": 1218, + "sha256": "23167a8cb05d987f038bfeb291939393d966bda5a2d92d35f70fa0f2ef08997d" + }, + { + "path": "backend/src/modelforge_api/domain/audit_timeline.py", + "bytes": 2668, + "sha256": "e1edebde61f7adc29d5199ecdb7da9c16c10b6b722d665b2c6190a138c5daa82" }, { "path": "backend/src/modelforge_api/domain/audit.py", "bytes": 5045, "sha256": "d5a6fc6e89538b12b8744f601e0d8f0e3285198203031d339c4431ec703fca80" }, + { + "path": "backend/src/modelforge_api/domain/benchmarking.py", + "bytes": 1704, + "sha256": "beebc0ddeea2b5045c5da703a46e803a52b36e6471eee36d4a586eb28e257154" + }, { "path": "backend/src/modelforge_api/domain/capability_evaluation.py", "bytes": 6253, "sha256": "38b3a710b5572a4e15ff851c2d7c4b02fac1b9115561e9269262572d2c87fddd" }, + { + "path": "backend/src/modelforge_api/domain/configuration_policy.py", + "bytes": 441, + "sha256": "fa8363d3dad971f1abe026609acc8f60986435b48036e43a12b0d98b05bb7d94" + }, { "path": "backend/src/modelforge_api/domain/configuration_reference.py", - "bytes": 17213, - "sha256": "e134350e4fc03c94a01c2d175abca2ba52b3eff31f6d7b50a9069a48724d5b7a" + "bytes": 19845, + "sha256": "994b589ff6ac6e8f99f59f203269d84a5b4c7d865e4db801aa4d61b31de1607d" + }, + { + "path": "backend/src/modelforge_api/domain/configuration.py", + "bytes": 3922, + "sha256": "9bf268b6fabdc068423d5bbdb11eac5d964551238535e6c77d401ae1fa1c64c8" }, { "path": "backend/src/modelforge_api/domain/contracts.py", - "bytes": 14817, - "sha256": "ca8a1b18871ee015faafd71eca27dcd6ca5dc85be34771f57fc3bf51bd09cbd9" + "bytes": 14831, + "sha256": "0ef79fbac27034beb4f015a9575a78415ef54703a8a6e4fb1390d641e4397075" + }, + { + "path": "backend/src/modelforge_api/domain/dispatch_context.py", + "bytes": 2804, + "sha256": "e3978a559063d814fe7ab03a9cee9b48ad4151fbf39f97a352f5b64646b6c3e8" }, { "path": "backend/src/modelforge_api/domain/enums.py", @@ -352,40 +533,85 @@ "bytes": 20073, "sha256": "f15f0302e645934bb2722fcd8983439adaca755f057e1a7decc753f912237e21" }, + { + "path": "backend/src/modelforge_api/domain/external_creative.py", + "bytes": 4734, + "sha256": "1adbd925635b4e7255ce7bec6da16d2a0c536c4528e0edc36650e994bd27ff22" + }, { "path": "backend/src/modelforge_api/domain/hardware.py", "bytes": 8180, "sha256": "6893c58f144c613eff3d26917c1d748e8b0b362f2e20ce032c1879672f86d9ea" }, + { + "path": "backend/src/modelforge_api/domain/lab.py", + "bytes": 3970, + "sha256": "c63283418b35cba1bdadfc3b8e57dcecbf9717d02de13559376d0d377d02d8d1" + }, { "path": "backend/src/modelforge_api/domain/lifecycle_contracts.py", - "bytes": 12901, - "sha256": "d37068fc10193f6b6c8ec63a679b73c70e73f7f85757869fcae0e24440a7d749" + "bytes": 13810, + "sha256": "34a334fbc61a708dcae1ae8222a8f6b5f51f640d67852de7efd275bdccda7425" }, { "path": "backend/src/modelforge_api/domain/lifecycle.py", "bytes": 3172, "sha256": "53d144a9b9ff314bdc5134cb14aaa681d804522e9acd7be490953565f411a533" }, + { + "path": "backend/src/modelforge_api/domain/managed_identity.py", + "bytes": 2479, + "sha256": "d7ab0b7f6ab60dfeba84a6539ae16ac13f7ad1737ca9d389afb8c32e2f9f61a0" + }, { "path": "backend/src/modelforge_api/domain/migration_contracts.py", "bytes": 19575, "sha256": "59d0a0127824665dce28feff7de8b1ef4147357ba44506c8965c48897b697a81" }, + { + "path": "backend/src/modelforge_api/domain/model_changes.py", + "bytes": 4257, + "sha256": "1904c4e917573931007b0aa1f8f979a023b6fc2386293a4ff3db194b1e428230" + }, + { + "path": "backend/src/modelforge_api/domain/model_updates.py", + "bytes": 1290, + "sha256": "4b75688c589125e90f8e0778c2eceb8ce8e97fa115bc3f25075989119bf52050" + }, { "path": "backend/src/modelforge_api/domain/node_decommission.py", "bytes": 1742, "sha256": "6bc976b02bb87e149b1131c1ea11dff0874ba0b98cd8c74e2de1b38ac1a0b848" }, + { + "path": "backend/src/modelforge_api/domain/node_lifecycle.py", + "bytes": 1567, + "sha256": "16cda111f9e0a681873c03be272e636240df2a5e3e9d63e6628a1a62ef46af4f" + }, { "path": "backend/src/modelforge_api/domain/observability.py", - "bytes": 13944, - "sha256": "fb06302edf24b9096188a62c00197a60c07a35a698e2a9091ff9d0ce0d298a9b" + "bytes": 14816, + "sha256": "07c6aafee9ff07656135ae96a03d14c81f8328fbe7d2023df690fe1dc6609004" + }, + { + "path": "backend/src/modelforge_api/domain/operator_identity.py", + "bytes": 2406, + "sha256": "8560c8e2e608ec205f5e45108fc9784d56f5afa2ad73e4d2a62c6dfe6bb2a8a2" + }, + { + "path": "backend/src/modelforge_api/domain/portfolio.py", + "bytes": 7383, + "sha256": "7de222dd98375aa957c483e37720bb9124197a77fc2cff0cce2a6ecea37154ab" + }, + { + "path": "backend/src/modelforge_api/domain/projects.py", + "bytes": 2598, + "sha256": "ff765e4395a7e570e3506efe16f16b7451be240d92c8094e300b60dde66b0634" }, { "path": "backend/src/modelforge_api/domain/recovery.py", - "bytes": 16203, - "sha256": "b675882609c0eb458858714bce5cb1dd7f4736567006eb5c8b072ce5d30d9749" + "bytes": 16892, + "sha256": "544bca7853c23a01bba4922c1fa3a991459b584b65838277e33ae9d68da75f7a" }, { "path": "backend/src/modelforge_api/domain/registry.py", @@ -394,8 +620,8 @@ }, { "path": "backend/src/modelforge_api/domain/release.py", - "bytes": 8211, - "sha256": "50e77a5c4f08b4cb539225909c06733d7b895f878d959c191e16ce74927ccd86" + "bytes": 10297, + "sha256": "4ebe294198eb6b123a6bd471c24f3f10b19a9c3595d31241677dfc75942b2070" }, { "path": "backend/src/modelforge_api/domain/runtime.py", @@ -407,10 +633,35 @@ "bytes": 4421, "sha256": "fef7fcdac138c8f87c1ef5025b0e08e9a82ccf82bb469986d52e6c08816d9741" }, + { + "path": "backend/src/modelforge_api/domain/secret_slots.py", + "bytes": 2679, + "sha256": "aaebeb1e6809ea4601dd80da5f32dc7e2ea3a32470b3cb83eeb68b0a437d99dc" + }, + { + "path": "backend/src/modelforge_api/domain/selected_model_comparison.py", + "bytes": 3334, + "sha256": "e1a1328b1d0a4ecc7f393c0f8b27f76185a9b8e7762ee464648338d0206cde92" + }, + { + "path": "backend/src/modelforge_api/domain/selected_replacement.py", + "bytes": 1363, + "sha256": "964b7fb309c85b6eefa7d5ec316f920188c5fbceaebe05a8dcdc0e05d505475b" + }, + { + "path": "backend/src/modelforge_api/domain/self_check.py", + "bytes": 1911, + "sha256": "d819d16a92a44ad09b0d6027ec42e4a82551ae617dfa38a52d1e726c905425ae" + }, { "path": "backend/src/modelforge_api/domain/serving.py", - "bytes": 25381, - "sha256": "45de8a4878d1a9a8ea7a1b3f505ecb9047a8a1d19add56a59b021263c0788ac9" + "bytes": 26767, + "sha256": "4b647b7ef57170b22c1b6788219595ec631c75fac5840a77bbbe410c394f9b77" + }, + { + "path": "backend/src/modelforge_api/domain/update_preflight.py", + "bytes": 3600, + "sha256": "6b3ca9f1de4835d42dd06be9376e5f59522543ab31684dd1c7279883935c18ec" }, { "path": "backend/src/modelforge_api/hardware/__init__.py", @@ -424,13 +675,13 @@ }, { "path": "backend/src/modelforge_api/main.py", - "bytes": 23921, - "sha256": "8c4363d98a28c600884d56580ecd80da96e68883568c2956afb59cec52dd1352" + "bytes": 30628, + "sha256": "d51d38b6f734baed634d1374d682283f5d162899cd15012b81a28a8da67c0d78" }, { "path": "backend/src/modelforge_api/persistence/__init__.py", - "bytes": 45, - "sha256": "97a111f6a491e3d07b223a54e2b2630b4d6ac2dd32b9c01fb6bf0263f16debf5" + "bytes": 106, + "sha256": "b9e0339ade309d716dff6164103b6a06bd844052858338dfeca8d92a34012611" }, { "path": "backend/src/modelforge_api/persistence/acquisition_repository.py", @@ -444,18 +695,23 @@ }, { "path": "backend/src/modelforge_api/persistence/hardware_repository.py", - "bytes": 4554, - "sha256": "5c7a0f8d26e6869da81fe87f472bdbce1d10e47a66af0fc9b9d5669c42e3281b" + "bytes": 4635, + "sha256": "1d7235fcf8107d86572aa9584877c3cabe5511bccf6cab178f5f9e00b2562f4e" + }, + { + "path": "backend/src/modelforge_api/persistence/model_change.py", + "bytes": 3867, + "sha256": "e3c3740d247ef98fe045b8ce20e40e38ab91c07c75e174698bc0fb64488eaa83" }, { "path": "backend/src/modelforge_api/persistence/models.py", - "bytes": 198975, - "sha256": "2ebdcf4ce91a100857c9fc55d6d265810774a431c4620f683a49c07b05555df2" + "bytes": 228422, + "sha256": "c89a60f4015bec556bd137515f8994caba2bad740217e93a4d90fe55885bfb46" }, { "path": "backend/src/modelforge_api/persistence/registry_repository.py", - "bytes": 10529, - "sha256": "12ff6d8e1c91a2c9954a2b8b3247534fd840d0376156e12b2b7897bfd8bfe236" + "bytes": 11430, + "sha256": "66a7fbe8220a5b457afae62e54c63c4d7593934a306d86d8eadfb4a8865daa8e" }, { "path": "backend/src/modelforge_api/persistence/repositories.py", @@ -464,8 +720,8 @@ }, { "path": "backend/src/modelforge_api/persistence/runtime_engine.py", - "bytes": 671, - "sha256": "251ad0f5a9599e147be4462790881de2386937f55033e877be057ccbeb5515f4" + "bytes": 670, + "sha256": "8efe68c0530189544c940546a010c617fad0add4f06f560472a8031fa728ea54" }, { "path": "backend/src/modelforge_api/persistence/runtime_repository.py", @@ -474,8 +730,8 @@ }, { "path": "backend/src/modelforge_api/persistence/serving_repository.py", - "bytes": 15734, - "sha256": "a03a317c8c333a4ef2b567df8ce0eb1a5204ff8963c7c56e62a5101ed6a21dc1" + "bytes": 16281, + "sha256": "9b147dbe213b7100a98e770d0b6487fbaa5f6381beb2cf401fa1bd03e523b752" }, { "path": "backend/src/modelforge_api/providers/__init__.py", @@ -484,8 +740,8 @@ }, { "path": "backend/src/modelforge_api/providers/huggingface.py", - "bytes": 8555, - "sha256": "dadf052e979dbe34815dae9a063de99dd2035c762d25d798a570d7ac699e527e" + "bytes": 8820, + "sha256": "aaefd4dc26b6ffdab8201c316845344bac9d36f27e6018929318d7bbd050db9e" }, { "path": "backend/src/modelforge_api/py.typed", @@ -499,23 +755,53 @@ }, { "path": "backend/src/modelforge_api/services/acquisition.py", - "bytes": 39577, - "sha256": "c68445dcd0f2566f86d0af323349f9c15bdf1c0c0adb1136c868ef5979efc053" + "bytes": 39969, + "sha256": "1b88dbe95fa7b2246acd39357f7f925979d46594531effd8325eb363bd01b599" + }, + { + "path": "backend/src/modelforge_api/services/alert_notifications.py", + "bytes": 11375, + "sha256": "788f4e18e666e878aa2a0c1b3828a334d49d75c4a663e71e4ce7e02aeb0261a7" + }, + { + "path": "backend/src/modelforge_api/services/audit_timeline.py", + "bytes": 7685, + "sha256": "f7c6ae797701d9a5dbaa77aa032ef4ebf00fc17333183cbe3a68a097f66ea51a" }, { "path": "backend/src/modelforge_api/services/audit.py", - "bytes": 22510, - "sha256": "df7b12f6694f0a363c9613bd40841230143b794a2ffbfa39508ee0550401f340" + "bytes": 22624, + "sha256": "58b24032fa92183316600730453e347f6bb15b469b905ec77925bebf8f8c7753" + }, + { + "path": "backend/src/modelforge_api/services/benchmark_runs.py", + "bytes": 50865, + "sha256": "cd8f4399d76db34d0dd055af6e6a03067ff0b582c7d5913e8535d6adf90a40fb" }, { "path": "backend/src/modelforge_api/services/capability_evaluation.py", "bytes": 12595, "sha256": "53ce2cb1ffc3e92b6cd73fff4fde53a184303a6a71c708c7f4c0f7e4ef61892d" }, + { + "path": "backend/src/modelforge_api/services/configuration_changes.py", + "bytes": 13695, + "sha256": "69eb071e4587d79c7e74385c83660439a39a93acac74c51247c8f4e82dd9736f" + }, + { + "path": "backend/src/modelforge_api/services/configuration.py", + "bytes": 5574, + "sha256": "2ea4d11f3aa5a216f86076a72f1aebd7e7ec746a854cb10738606e12c5790055" + }, { "path": "backend/src/modelforge_api/services/evaluation.py", - "bytes": 69120, - "sha256": "f99c47d1fcc084c1fb61fbebbce5f842dba01d41c952063e1f17a09668532a92" + "bytes": 70665, + "sha256": "d8e1c24339b376932b06121781fe0464c1bc74e94ad8721e8036f2a87e7e053b" + }, + { + "path": "backend/src/modelforge_api/services/external_creative.py", + "bytes": 30631, + "sha256": "c921cade1bcff6e3fec3c2e89ac6039d3e50eb87266b90f8e2e1d8fcdfaab0ab" }, { "path": "backend/src/modelforge_api/services/gpu_scheduler.py", @@ -542,15 +828,30 @@ "bytes": 29696, "sha256": "6581dd6c0a92e93556651c5d28e31038380f60e8ee31f19016d181f6bc3239ed" }, + { + "path": "backend/src/modelforge_api/services/lab_runs.py", + "bytes": 32884, + "sha256": "60d4ead6ebef33c0263c30b38f10b0caf73316708995633ed876e09afb4b1f64" + }, + { + "path": "backend/src/modelforge_api/services/lab.py", + "bytes": 9634, + "sha256": "ba5d0c4b062797ae2e156b22dd36ad262e27f6eb57017980fe4f1a8a9987d362" + }, + { + "path": "backend/src/modelforge_api/services/lifecycle_serving.py", + "bytes": 22476, + "sha256": "caad43564696fe96c464b383ee3773a8dd32e51ef7953bfd806da66932a56902" + }, { "path": "backend/src/modelforge_api/services/lifecycle.py", - "bytes": 82097, - "sha256": "5f8efef25c1136a83bada4789d8c87b7469b9b70443be367fd3aab8f55445120" + "bytes": 94034, + "sha256": "ae8e35b1620006b733d5feb0986e46ed9469617b87e78e17f0ae72a9c04b5ab9" }, { "path": "backend/src/modelforge_api/services/manifest_registry.py", - "bytes": 5102, - "sha256": "0b9071e217d383ab02a617c8281b76b4cb207c5fbec9b1dc3a640d702f576d46" + "bytes": 5287, + "sha256": "1a3d61da752391c7f80b1f4a68febe3adf58b5ecd4b01ee475887db0623fcd26" }, { "path": "backend/src/modelforge_api/services/migration_adapters.py", @@ -559,18 +860,33 @@ }, { "path": "backend/src/modelforge_api/services/migration_engine.py", - "bytes": 67305, - "sha256": "09f0cf152a8cc6b22e1a2b7ac337670c85ea1e98d403aa01d90a464c929265d4" + "bytes": 67395, + "sha256": "a3d53fa2189fe3394e456447125300d2495c5838e2cae880c5927f0bc66c314d" + }, + { + "path": "backend/src/modelforge_api/services/model_changes.py", + "bytes": 36063, + "sha256": "c133d3e40838484adc3db0380833ad6ed71da12e3d52bc6f6facbb4667ab74ab" + }, + { + "path": "backend/src/modelforge_api/services/model_update_polling.py", + "bytes": 3356, + "sha256": "e68b69766fd15d104b837b28573d07dcc68c51bcbe61008df1674512a2b3ca85" + }, + { + "path": "backend/src/modelforge_api/services/model_updates.py", + "bytes": 10847, + "sha256": "883b4bd26e9ccf5d3ca6f3c1ccec5357e5a89073de51e650fcb53553fa2190f1" }, { "path": "backend/src/modelforge_api/services/node_agent.py", - "bytes": 31646, - "sha256": "8c3ad2b07827fe310804805de7ae0030e433f0c616182ef27162cc3a210449a0" + "bytes": 32491, + "sha256": "224411fb6489f0a46a2a9a4575017e099e6d4be48718749d1ee86f303772caee" }, { "path": "backend/src/modelforge_api/services/node_decommission.py", - "bytes": 39759, - "sha256": "cfbf38baf9411847121af2a12056fc75f371dff34e6869a9ba65ab846b865096" + "bytes": 48319, + "sha256": "fd35eb02601738c1a7237be9577791ddb67d3053570951316544a1b991f4e55f" }, { "path": "backend/src/modelforge_api/services/node_liveness.py", @@ -579,28 +895,48 @@ }, { "path": "backend/src/modelforge_api/services/observability_polling.py", - "bytes": 1799, - "sha256": "de4885bf874cb890ced5a2d1c2e9663af6dfb7fed3c06201ecff964c232ab2e0" + "bytes": 2341, + "sha256": "82f937067e2f65a12817074838cb4d70002135f88078a86440e4014307783955" }, { "path": "backend/src/modelforge_api/services/observability.py", - "bytes": 101067, - "sha256": "1d4f8fb80ca03c2f83d39048f38a741a5c9dfc968cf66633b0a0a44812756068" + "bytes": 106617, + "sha256": "8df316a3bbf1decc7ba0b1ecb157c43a5f662e6b852c5f454460c1e6497ab5dd" + }, + { + "path": "backend/src/modelforge_api/services/operator_identities.py", + "bytes": 14290, + "sha256": "e24cf34c31737af8a43587d413838cf7aab0e47ff76cf725a12e599acdab424f" + }, + { + "path": "backend/src/modelforge_api/services/operator_password.py", + "bytes": 3486, + "sha256": "1700f0d645c45f2f01d2dd4e5a6d4f618c0a92e85d90c31f287f39037b284292" + }, + { + "path": "backend/src/modelforge_api/services/portfolio_registry.py", + "bytes": 25502, + "sha256": "37b4fd58e7678254afbe1b0a59591e27a38b93b1c4075eb5098b3d54eb916ad1" }, { "path": "backend/src/modelforge_api/services/project_registry.py", - "bytes": 4069, - "sha256": "ff23c84ddc668c3ac0c0ff2cf184ff7abab6807c86e520c5b404bb550c7595a9" + "bytes": 5479, + "sha256": "1bfb2671aa280601c19996137df6145202dc308d473970bd9acf7acc7b554d95" + }, + { + "path": "backend/src/modelforge_api/services/projects.py", + "bytes": 12438, + "sha256": "9520ae38e957467a2801f879c6897ca6500e8eaf022b43b3783f1be24517e514" }, { "path": "backend/src/modelforge_api/services/recovery_crypto.py", - "bytes": 5458, - "sha256": "c2d1125edf389ea1ee99b7ba48d30c53860099ab9f272e1ba12371e30e47e855" + "bytes": 5548, + "sha256": "bfc1184f229b5550a9c484fbba66a395158a2ecd37001aad7bd99f9e896feed4" }, { "path": "backend/src/modelforge_api/services/recovery_fingerprint.py", - "bytes": 25605, - "sha256": "2bb80278269a7d7cf22eb1ce79e32bb70c6947395c24a9b30b77b16fde3fc02e" + "bytes": 111786, + "sha256": "1ab75c04d0a3a79db578560f986839464b189787031ea2bb591e2ce6b9e0e15a" }, { "path": "backend/src/modelforge_api/services/recovery_paths.py", @@ -614,8 +950,8 @@ }, { "path": "backend/src/modelforge_api/services/recovery.py", - "bytes": 135885, - "sha256": "7f4b9d92176ee93f8cb47c7f91795935747fce2c649faddee55df7e1f54a4aa1" + "bytes": 150070, + "sha256": "1ab21146188dab6ccfe08cb8f2b64a1b7383652ba636087b98cd66ab67dc61ac" }, { "path": "backend/src/modelforge_api/services/registry.py", @@ -632,20 +968,40 @@ "bytes": 46557, "sha256": "6a2828d6a9ea4245931b46043837b3954debf87039fc2fe5866fc16432bf3254" }, + { + "path": "backend/src/modelforge_api/services/secret_slots.py", + "bytes": 17099, + "sha256": "7459bdf184d5f1b122fef5e93b8de586fafb3911dc388ad152b125e8e5526d26" + }, + { + "path": "backend/src/modelforge_api/services/selected_model_comparison.py", + "bytes": 31406, + "sha256": "89d414fd5a0488e3b5ed434ca52112a5783db997d72770757989416db28c591d" + }, + { + "path": "backend/src/modelforge_api/services/selected_replacement.py", + "bytes": 9557, + "sha256": "e18605b93b3d28fa3e483eddb8b0ec7409fc14eea693968dd09182b2e25dfa7e" + }, + { + "path": "backend/src/modelforge_api/services/self_check.py", + "bytes": 32087, + "sha256": "3734df9a4dbd03c091f39d45dfbc74958cf0ddb60a68f9b4b00d4154ffd95a7c" + }, { "path": "backend/src/modelforge_api/services/serving_reconciliation.py", - "bytes": 1618, - "sha256": "402ac4c3fac4688f49f2fa6704bcd05683502107d61784e58341c6719f3ab271" + "bytes": 2048, + "sha256": "37c88f45bcc89082b9024e19e116f5df52a78aeea1378cf541be359b3a1a406f" }, { "path": "backend/src/modelforge_api/services/serving.py", - "bytes": 172209, - "sha256": "759f7b436bf47e2c6d57c06163a43315f21c8263f4d231068f21e2f6b9a7ca79" + "bytes": 213572, + "sha256": "ab4e00530c3a9828a42460379515320f79c0dd8948ef439a40ec07ff444a7a14" }, { "path": "backend/src/modelforge_api/services/startup_validation.py", - "bytes": 22792, - "sha256": "9c2f424a6907ffdb1cfee1d975462ea6d197a4f3958014ed36236f224e140eab" + "bytes": 24548, + "sha256": "e46365fa7b493821001dc71536abd3ff60c0dcaf33d822c4fc5896c3695d063e" }, { "path": "backend/src/modelforge_api/services/transient_payloads.py", @@ -654,19 +1010,34 @@ }, { "path": "backend/src/modelforge_api/settings.py", - "bytes": 5495, - "sha256": "ea69ba053fc7f47b8de42ed132f2a81c4796c70d70691f6df3bbfe5064de2618" + "bytes": 13902, + "sha256": "37e48e58494630cdf2a68052cf1fb319310529058ceff39213f954f4d612a3d1" }, { "path": "backend/tests/hardware_fakes.py", "bytes": 6379, "sha256": "2dd22a5b69221c29266a42b3edd4ca2f338aae2c4839a04592507cd5c912153b" }, + { + "path": "backend/tests/http_contract_fakes.py", + "bytes": 1382, + "sha256": "6d917f09b24d736dc66383b28daaaa0377c15514be5b7945bae4f624f5641a4f" + }, + { + "path": "backend/tests/recovery_fixtures.py", + "bytes": 1332, + "sha256": "086c2640d8f708c15f16cf793d743251c41a43e99a32a8709ac0a98af06d3e4d" + }, { "path": "backend/tests/test_acquisition.py", "bytes": 17693, "sha256": "468212ae8778de2ec2bd9f0029d86cc2298127b57a34c72fa1b2e46a3ecd9e83" }, + { + "path": "backend/tests/test_alert_notifications.py", + "bytes": 30761, + "sha256": "055b951d518ef19f5e5b5573cb217cb63a777fb4d1e418ea106b1bc1975c9a52" + }, { "path": "backend/tests/test_api_security_m16.py", "bytes": 16717, @@ -674,28 +1045,83 @@ }, { "path": "backend/tests/test_api.py", - "bytes": 5032, - "sha256": "0364432872f0cf1a545bc413c76e6735c4c72a21cd72936ad8319643cc46ed5e" + "bytes": 9226, + "sha256": "68e28b19dc7797e0eb3a4441635c795cd1eae1f23881022d8df7e2eacd03e661" }, { "path": "backend/tests/test_audit_integrity_rc.py", - "bytes": 31604, - "sha256": "2b5d183f6e4c8c77e62d4bb3a69fde6a85d4a3d0de6a0cc748f6a665bcaca0c7" + "bytes": 31716, + "sha256": "20cc9376ff7bac7a8b7647bc8816f397e0e10996e66873ca0f0eb48abc87d5ed" }, { "path": "backend/tests/test_audit_migration_rc.py", "bytes": 8662, - "sha256": "ffe01f291541a8e569f853c0607556e6958d99d0b6a48984373dbc4563866841" + "sha256": "0b7d012d9189ff006ec6a146b37499902c95676a93c2f224454f49108e0dad29" }, { "path": "backend/tests/test_audit_postgres_boundary_rc.py", - "bytes": 16134, - "sha256": "306c4cc83dbde2bace32cb95a88b622bb8893408313826d76d292ad0217bd6ec" + "bytes": 21131, + "sha256": "55f5b3da542c2c8db6ad60e3e065f0091347974190aee535d75550e88fa8cab9" + }, + { + "path": "backend/tests/test_audit_timeline_api_rc.py", + "bytes": 8239, + "sha256": "ce73060c5e412de4a87f096dac1a8b648ac5ee7f01f15a5782f030dbf10718ac" + }, + { + "path": "backend/tests/test_benchmark_run_recovery_compatibility.py", + "bytes": 9341, + "sha256": "1992bd43aa373ec222391ba4ca38aab40d5edf8083d25f87b68cb4aea396997d" + }, + { + "path": "backend/tests/test_benchmark_runs_api.py", + "bytes": 6738, + "sha256": "28ffc958adae5183baf4ee14b1afbc1e1210d19e679b170176e051319d7e6a75" + }, + { + "path": "backend/tests/test_benchmark_runs_migration.py", + "bytes": 4059, + "sha256": "e90da0b69ae7a04c805332258cf67fd4df28d22199cb12e38735cbfc70729fd8" + }, + { + "path": "backend/tests/test_benchmark_runs_postgres.py", + "bytes": 18898, + "sha256": "6e8238c7419c8d2e7209d21c2f4768ed253da94c2a62d6819b44e44adde3ba3d" + }, + { + "path": "backend/tests/test_benchmark_runs.py", + "bytes": 16302, + "sha256": "67b54406f766daa116f20e39fe7b39bffdb3aa3edabcb6f8f6baa14eaeb89387" + }, + { + "path": "backend/tests/test_catalog_query_performance.py", + "bytes": 19703, + "sha256": "672ea64337efd4f801965ece100a93350339cb5dc308c9dff949d84c3475c26a" }, { "path": "backend/tests/test_concurrency_m16.py", - "bytes": 22626, - "sha256": "a1be3e9048242af57f150af88d193a30193b91ae83ec4dfaab1d6faa2ce24eef" + "bytes": 23466, + "sha256": "9c6dfca3c218fa663d3f1f9bacc7a0195634bc71d65b6ed46cc247ea19816ce5" + }, + { + "path": "backend/tests/test_configuration_catalog.py", + "bytes": 8593, + "sha256": "970662b64b19164ecbbcf6edc5df8cc6cba835c43a85024af5c10d207d2bf0e4" + }, + { + "path": "backend/tests/test_configuration_changes.py", + "bytes": 11529, + "sha256": "1a7cb2636fdceeff203888f11f9ffb7382ef31bfe0e5ff5065f7939d2339d855" + }, + { + "path": "backend/tests/test_configuration_postgres.py", + "bytes": 11205, + "sha256": "983801c3e205ef332bf77057d5b2e9918b2b479636337325058158e36ca7592d" + }, + { + "path": "backend/tests/test_configuration_recovery.py", + "bytes": 3754, + "sha256": "6d90f1b841e98b888296779002af4503cbeddd6aebaaeb2ff169cd0d2f812951" }, { "path": "backend/tests/test_contracts.py", @@ -707,11 +1133,46 @@ "bytes": 14769, "sha256": "8053b435bc0b65f0e2eb3715bd7b32ecd13407bed19dcd782dd6d9b163a57160" }, + { + "path": "backend/tests/test_deployed_recovery_convergence.py", + "bytes": 11082, + "sha256": "8b99fa5ce8e2234a3d51ff29658288351e5fb5591817f3bb357ec58c93e68627" + }, + { + "path": "backend/tests/test_deployed_schema_convergence.py", + "bytes": 10977, + "sha256": "b93eaeabf359c99049d8c743a94707e033d4b2f43dbb1cced28763a46b070af2" + }, + { + "path": "backend/tests/test_dispatch_context.py", + "bytes": 23533, + "sha256": "cde876ed023c340d1d7d4851fbb8dd7b522d7884305699fb6db2e3f0c87b0e3e" + }, + { + "path": "backend/tests/test_evaluation_project_boundary.py", + "bytes": 6259, + "sha256": "2bf8486f3912cccc9ff15731d54f0bd83c6f656791c917667946702faabdc79c" + }, { "path": "backend/tests/test_evaluation.py", "bytes": 25077, "sha256": "2c91cfcb1ba8281159189d73b44627eaa6248b80f0dc4d4ecd01408da61dcdae" }, + { + "path": "backend/tests/test_external_creative_admission.py", + "bytes": 18263, + "sha256": "5ab37397582c4a99720c8817f4182ce54a4b9db863acb73270ad56801cb822b6" + }, + { + "path": "backend/tests/test_external_creative_governance.py", + "bytes": 5000, + "sha256": "12ffbfb3e2da079f3240c977cc0015c897fd5630564a6a27f71f99e71430c0a9" + }, + { + "path": "backend/tests/test_external_creative_postgres.py", + "bytes": 12088, + "sha256": "09f8421a05619fbdb52138de93985490cc851ccb923d2d7a38d3c8a318fe7209" + }, { "path": "backend/tests/test_gpu_scheduler.py", "bytes": 7013, @@ -719,8 +1180,8 @@ }, { "path": "backend/tests/test_hardware_api.py", - "bytes": 4693, - "sha256": "bb9c9c03f7467bfc177f1c617219cbcd01ea516ffaa7a2b548e7e2651ac3466a" + "bytes": 5485, + "sha256": "08f5cfc1a3984465ef2280559ae6452307ff6b80783842f3908a230ec739ec3d" }, { "path": "backend/tests/test_hardware_collectors.py", @@ -739,18 +1200,68 @@ }, { "path": "backend/tests/test_huggingface_provider.py", - "bytes": 4783, - "sha256": "041de3c29a3c4c9c68bd9d141288a11205c41ad4bb7b6e5a350404cbbfea163c" + "bytes": 5479, + "sha256": "5e51f0ad0b0f35d5d6fd10ecf445fc3ac712562325ed7fdbc27c486689532f0c" }, { "path": "backend/tests/test_invariants_m16.py", "bytes": 24602, "sha256": "6695f1dbd20cfb78721e814cbb78d37e8952af8f6a093b03b2d80c51a69999d1" }, + { + "path": "backend/tests/test_lab_execution_api.py", + "bytes": 3824, + "sha256": "f9d4ca32582ca18bb2995e8ee6de3246fa4b17d964207765c0ef4e0cf7fb6d55" + }, + { + "path": "backend/tests/test_lab_execution_postgres.py", + "bytes": 7274, + "sha256": "c765d0bcb305ab72112a781d101aeaa2ec1a18c3e89e256ba885062518e3a6db" + }, + { + "path": "backend/tests/test_lab_execution_races_postgres.py", + "bytes": 8671, + "sha256": "74e731be302e43557d99b22551cfb7c055889c9f41545ebaf90017e585190e9c" + }, + { + "path": "backend/tests/test_lab_execution_recovery_compatibility.py", + "bytes": 7369, + "sha256": "5a48510fbc0ec846b139b46ea081195104375311eaabb9fa84a18b2d77eb5083" + }, + { + "path": "backend/tests/test_lab_execution_runs.py", + "bytes": 22219, + "sha256": "082ec1c386b0ced6503b50a286a1d1f353e5e85084fa0f07bf4e324f91442535" + }, + { + "path": "backend/tests/test_lab_preflight_api.py", + "bytes": 3220, + "sha256": "cc6e0c7fe952c79971f20e8770afcf9120e2dbee8d44d9f4defad93cf47cc3bf" + }, + { + "path": "backend/tests/test_lab_preflight.py", + "bytes": 11415, + "sha256": "1e671aa52f836411e11d3a3322ecf91b9387300a2970da4e3d83082e2779fb94" + }, { "path": "backend/tests/test_lifecycle_m12.py", - "bytes": 39865, - "sha256": "5361d437e9e33e8a58f89b7846f2aebd03c2e6709f1a42778690ad8add23bcb9" + "bytes": 47887, + "sha256": "5f9b18ce9db232a6686c00630afa3ed11c59210c7ab9aa194b646b2ac3516c77" + }, + { + "path": "backend/tests/test_lifecycle_promotion_races_postgres.py", + "bytes": 5511, + "sha256": "8ba43ea3d4d2da7b90abe739991a40697ac56ec260f28d0dea30f2c6396b7197" + }, + { + "path": "backend/tests/test_lifecycle_serving_postgres.py", + "bytes": 5131, + "sha256": "6713738ef015f1fd83e6a73c64af5233c010df23ff25298014f988dbf9dcfee2" + }, + { + "path": "backend/tests/test_lifecycle_serving.py", + "bytes": 23580, + "sha256": "7f8d46a8d3dd1931e5164d804e572c32481f3dce3cb9d460126db0dca6128f51" }, { "path": "backend/tests/test_lifecycle.py", @@ -762,20 +1273,65 @@ "bytes": 2735, "sha256": "5237fa504e80fba4daa9839cd0102abe164392b4550507e6b2dc816283b3d365" }, + { + "path": "backend/tests/test_managed_identities_postgres.py", + "bytes": 18427, + "sha256": "2f8639c4f10b24f554fd0f7ad79b0d653f6c8589f0b605a836dca9ea0e2d7cbe" + }, + { + "path": "backend/tests/test_managed_identities.py", + "bytes": 20009, + "sha256": "48542dad2b257aa52447d9458805de6a3d423b80ec11568e632b01d2e8504742" + }, { "path": "backend/tests/test_manifests.py", - "bytes": 1170, - "sha256": "59b10f85cfeddd4b452f58212d19b00457ed31526601d46061f3969fcaf0aa2d" + "bytes": 2358, + "sha256": "d6882a7469aa0d0ab2795383d7a5d93aeacc83161be29f82d1676a1fa0881894" }, { "path": "backend/tests/test_migration_engine_m13.py", - "bytes": 21030, - "sha256": "9f5a065b6f033b918cd5d3b0b5c0d4ec426be80b508b48e39175d451af893c7e" + "bytes": 22002, + "sha256": "01bbbf0c60c9225f918c0415b29631c559a0ab21531732a0077d7ca67a1922d0" + }, + { + "path": "backend/tests/test_migration_plan_index_api.py", + "bytes": 1235, + "sha256": "2d09813652eba9cfdb8d105f8e930bed59286e493293545e5fc4039bfb910afb" + }, + { + "path": "backend/tests/test_model_change_recovery.py", + "bytes": 4681, + "sha256": "f38b272d8993b13f590c67c7e3b2e54065bc6452e9b0bf5e9f010ce60fe7eb42" + }, + { + "path": "backend/tests/test_model_changes.py", + "bytes": 7758, + "sha256": "366b7be89fb61b0b8b0531f7a983a678c6a1c08a3f53dbb513adf5ccdcf859a3" + }, + { + "path": "backend/tests/test_model_update_polling.py", + "bytes": 3322, + "sha256": "98cfbed44539e4b5963f93cfa29e129140ac519ed61a1c76c806d3e6928cf205" + }, + { + "path": "backend/tests/test_model_update_recovery.py", + "bytes": 5363, + "sha256": "b760175cb9496dd77f1b6f136f597325748cc620cd321446e40d01ad74f31dd6" + }, + { + "path": "backend/tests/test_model_updates_postgres.py", + "bytes": 7857, + "sha256": "231afda3e8fbf8f969684918735da83ee66a2d9e25858eff77dec07d20a5a307" + }, + { + "path": "backend/tests/test_model_updates.py", + "bytes": 16349, + "sha256": "3ce0077037a2ae6e7d19731c50a8963110307376d901501d293476527e560f12" }, { "path": "backend/tests/test_node_agent_api.py", - "bytes": 8885, - "sha256": "dac38351f2ca7d396e016c2a246704e90658cd31b779668c691aa7adb5229442" + "bytes": 9751, + "sha256": "7b7ce950283b95f56d0fa9ba66a6f7cca838163ec499a86b9f4aa69e9516d3c0" }, { "path": "backend/tests/test_node_agent_control_plane.py", @@ -784,18 +1340,28 @@ }, { "path": "backend/tests/test_node_auth_scope_migration.py", - "bytes": 5654, - "sha256": "4603da2147fb34fd1a7cc23f485c34ae4d2e496935b24fe81f6ba16f458c0f2a" + "bytes": 6727, + "sha256": "5450d5f3a63e61fcab219290f9c1494721936f6fdf021fd4a1888f718af45669" }, { "path": "backend/tests/test_node_decommission.py", "bytes": 19196, "sha256": "5cc619ee2e29e2b0775edc41a5b2bed01e2dca83bc20ae2051d2d19e806ca1a0" }, + { + "path": "backend/tests/test_node_lifecycle_postgres.py", + "bytes": 12705, + "sha256": "817433801ca63309281112a814ffc3ed633cac194130589308630a4773b960f2" + }, + { + "path": "backend/tests/test_node_lifecycle.py", + "bytes": 15849, + "sha256": "6e246d8ee3ffe7b271784384326d809dce503a682e9ad25197eea0096cd1cb37" + }, { "path": "backend/tests/test_observability_api_m14.py", - "bytes": 3730, - "sha256": "e95d1d4784e1a7c643b12c3c86c612590d466deb581848f423bbccc72e875516" + "bytes": 15784, + "sha256": "02e8ab30c91cfb800030a16547ddd9c0050fae5af1b2949c81337be7c757b71b" }, { "path": "backend/tests/test_observability_m14.py", @@ -804,38 +1370,108 @@ }, { "path": "backend/tests/test_operator_authorization.py", - "bytes": 45731, - "sha256": "e343cf7feea7a0017958979f37b9e751f7fbb6c3d3e1d03d38834f7d1cfbac32" + "bytes": 47992, + "sha256": "318da084e73801f5feb069e592052fe4bf60895f34f413141aaa18fd57350296" + }, + { + "path": "backend/tests/test_operator_identities.py", + "bytes": 7789, + "sha256": "89a9c1d22665eba89c2f10fd3468d8cba4fc66945ec0216768bebdd178eba3f5" + }, + { + "path": "backend/tests/test_operator_password.py", + "bytes": 12341, + "sha256": "c8874a9448a83979c3a65608d7f42d00ec8f682f61090b7516ea63ee31eb8d8f" }, { "path": "backend/tests/test_packaging_m17.py", - "bytes": 28340, - "sha256": "45fc2a02d1ca7ee0208e946b3d9ff4dd9931ab5e77af7c0bec2f930578091dbd" + "bytes": 28911, + "sha256": "690142eaa73dbc0368906f80de4e398dd8ebbf6cfd0f921f054c619b697d8a25" }, { "path": "backend/tests/test_persistence.py", "bytes": 3666, "sha256": "34bd0f3ba11e5c13ab52c83e8d20a25327c9e3c9a279940ec57ed90803fb791a" }, + { + "path": "backend/tests/test_portfolio_reconciliation.py", + "bytes": 22853, + "sha256": "0d60602a331e9221b999269a7d8a0d7d610b4cb0f3b4830477af7168a15143be" + }, + { + "path": "backend/tests/test_portfolio_review_artifact.py", + "bytes": 5463, + "sha256": "56bb4196088fa5646a2ec528cc58128f2041924094dd3b11a3c868f4030c6f51" + }, + { + "path": "backend/tests/test_project_model_management.py", + "bytes": 2507, + "sha256": "c4f090075daa0f45284e8c448f2b92f82007c84ae8eba5486f3dc9338d7214f1" + }, + { + "path": "backend/tests/test_project_onboarding_postgres.py", + "bytes": 12761, + "sha256": "39e748644bff3c733e3fd925127abb363998b65a6bf08b8c1a6773928b20e1f8" + }, + { + "path": "backend/tests/test_project_onboarding.py", + "bytes": 16416, + "sha256": "41b682beac07032c33ed1f1f11fa0e8a89929f32176f3d3e773084219222b9ac" + }, + { + "path": "backend/tests/test_project_python_client.py", + "bytes": 4968, + "sha256": "05051cc834481ebd1f299f939763c3543894c0eeb255b8863babcea704fde5ce" + }, { "path": "backend/tests/test_project_registry.py", - "bytes": 1486, - "sha256": "523b448e7aad80140e98db245838e5b21fee5aff089ac3e13b002a8ff5fba29a" + "bytes": 5336, + "sha256": "084354692604899bddf7bc404eae540d1bc1b7c6de5fdbada2ba22516d75cd5e" + }, + { + "path": "backend/tests/test_rc_candidate_freeze.py", + "bytes": 8069, + "sha256": "e3924da173b98524ef36e307db5f12ab6c8a4caacc0c1446014e2eca86b87b42" + }, + { + "path": "backend/tests/test_rc_failure_node.py", + "bytes": 4660, + "sha256": "d205046f561010f7ae6e8ff14a876ce7edf426261099956baa4ca6867f0a8fe8" + }, + { + "path": "backend/tests/test_rc_failure_storage_modalities.py", + "bytes": 4493, + "sha256": "f785d5b82ddf4589b281f9e29915ead212e05badcfbece4960904a4d46f3d851" + }, + { + "path": "backend/tests/test_rc_failure_storage.py", + "bytes": 3192, + "sha256": "f73c9f97c83ff81ee78c65185be8adf483a31ead4c3c6a291005f87f0eebd0ef" + }, + { + "path": "backend/tests/test_rc_readiness_program.py", + "bytes": 6136, + "sha256": "556ab9b9a890bd860b0d03986e2bdc0dac79b7a5f1d969ec720ac862988d33ee" }, { "path": "backend/tests/test_recovery_api_m15.py", - "bytes": 12376, - "sha256": "cd2d85d97d23b34b274ed84a34f4edaacd73fb944d9aa88ab7eeae55c4037fd5" + "bytes": 13645, + "sha256": "5e8e20febc2ffbf1ee6050b1dd4d0a69bc301f78b3c6b711a2a714ea69071dad" }, { "path": "backend/tests/test_recovery_audit_integrity_rc.py", - "bytes": 28396, - "sha256": "fd8f2f13e033db37ca4162202d45553660339cfb0f442920f2f9c9e1734fc552" + "bytes": 30878, + "sha256": "2aaefad8bf90e433c6db842202ca89cbf28ef30bbe04e068125eab3d304b278e" + }, + { + "path": "backend/tests/test_recovery_audit_order_postgres.py", + "bytes": 2389, + "sha256": "0eea13d849418fe97e357ae12d9983ca8a1ce6445e23170e7b8ac35d728a66db" }, { "path": "backend/tests/test_recovery_m15.py", - "bytes": 47808, - "sha256": "1c615f20cf3ce4cc883faee3fe0e5aec69a7b15d89fed7ac8873fe50eab8a29e" + "bytes": 56644, + "sha256": "2a790145c066f6350047f3ffc5407356993e2770e59cc624297234f10714efe9" }, { "path": "backend/tests/test_recovery_security_m15.py", @@ -844,18 +1480,18 @@ }, { "path": "backend/tests/test_registry.py", - "bytes": 13156, - "sha256": "0d9fbc3a9007bead038e2133304c851fb0b3971f25721c2948dd5fa942930d8e" + "bytes": 13313, + "sha256": "e314cec8f6e3bf45ff746000784d796e48d294ee8c2f0b747a2f12ba4086efa6" }, { "path": "backend/tests/test_release_contract_m17.py", - "bytes": 13793, - "sha256": "3ac7a44bb7af5d836e5d8776684243c2b89a1a284e5d8e30c9f222474fd16729" + "bytes": 14596, + "sha256": "8c75c6c403e17d5a48cce88925b7e933a955c91b270e5575ee1266a94225e8aa" }, { "path": "backend/tests/test_release_provenance_v121.py", - "bytes": 13504, - "sha256": "9bc98f1c37abca1981eaaf8f79eaf4d7285f7f588dd09a9716dfb2acf39a0c98" + "bytes": 13752, + "sha256": "946108f6ef04385c34431eede5ef18e2eb65fe7b01906f7b279003b75bd949ab" }, { "path": "backend/tests/test_request_limits.py", @@ -867,10 +1503,80 @@ "bytes": 32402, "sha256": "cd3e298cbcff1bf8c5fe4b4d8ff59973422cb754cffa2956188da533d48ec725" }, + { + "path": "backend/tests/test_secret_slots_persistence.py", + "bytes": 4902, + "sha256": "1d9edb12ff0bab8f499fed1becb930bca058395469585062c0ba59f9e06aed70" + }, + { + "path": "backend/tests/test_secret_slots_postgres.py", + "bytes": 9976, + "sha256": "64ec7fc485b52bf045c9f25cfdf10da4954408d837d1e833165b876052bb1e22" + }, + { + "path": "backend/tests/test_secret_slots.py", + "bytes": 13500, + "sha256": "6b91f859af017dda9a3e16fc6f45d2ada991b79a19f7ea6d881f311f7936f9e3" + }, + { + "path": "backend/tests/test_security_gate_rc_metadata.py", + "bytes": 1048, + "sha256": "2c50fdbf9753db16d2d3b4fb0cb6a4c36d9b02938b5609f54aa6222b20af9202" + }, + { + "path": "backend/tests/test_selected_comparison_api.py", + "bytes": 6397, + "sha256": "b39fc76a392508b317670824af0bdd39fb7c72a36b0714de899314121ee33b00" + }, + { + "path": "backend/tests/test_selected_comparison_postgres.py", + "bytes": 13272, + "sha256": "56a800a164bd094982ce3645bb9fcfa9bec2895b65cfb41f16eb06b70aa649c6" + }, + { + "path": "backend/tests/test_selected_comparison_recovery.py", + "bytes": 8724, + "sha256": "bd83539ac0c2a567161c32fa589a3b76a82aa83a84eea04c00612a1b652473b1" + }, + { + "path": "backend/tests/test_selected_model_comparison.py", + "bytes": 28618, + "sha256": "535c8c3045acb8374831f679394f0c223e3e6b08596231dc5f884fe2b78c42d0" + }, + { + "path": "backend/tests/test_selected_replacement.py", + "bytes": 6717, + "sha256": "e0010fcdf22d2b4689091163c5135f3a0bfaf60b637dea0ad81c875207d41b29" + }, + { + "path": "backend/tests/test_self_check.py", + "bytes": 20637, + "sha256": "7ffc6c08e4a85991ecb2a6cecc734a3c360c0fc0f1dc6c41dfd31406bfb77f34" + }, + { + "path": "backend/tests/test_serving_external_budget.py", + "bytes": 3910, + "sha256": "4ac8bf52de181d035f9a35fdae930a81fb66cd6aefb8fa8c036c7bf52df8b66b" + }, + { + "path": "backend/tests/test_serving_generation_recovery.py", + "bytes": 7936, + "sha256": "27d581e0657c1429cec2f4ecc4948317903e1de9888bac54744b85db52b692c7" + }, + { + "path": "backend/tests/test_serving_generation.py", + "bytes": 1455, + "sha256": "d27f167d96efcdc584ae5ea01f6823e6fb0b37ebe87156cb8c8841a0cfa952e9" + }, + { + "path": "backend/tests/test_serving_reconciliation_loop.py", + "bytes": 2074, + "sha256": "ecd51f19511c27032ab865c6d1e723c7db347bf45e5ade516a6234a800c8e500" + }, { "path": "backend/tests/test_serving.py", - "bytes": 47117, - "sha256": "f4cd578bb7c279947a19b9c8b567f7e8fc89fdfa776c90363c69acd936200bf0" + "bytes": 54183, + "sha256": "b72031b847967b6608d45638851602a6201cdf25279eb19c18a45b2aef7465e1" }, { "path": "backend/tests/test_settings.py", @@ -879,13 +1585,43 @@ }, { "path": "backend/tests/test_supply_chain_m16.py", - "bytes": 21024, - "sha256": "97001a2cac4d2b5a81554183913298a61fee2149ed532ffd09db6933450409f2" + "bytes": 23388, + "sha256": "730edbfa568832bdf43d6e430475d00d1ad22cebaa970b5e4381882758fa10df" + }, + { + "path": "backend/tests/test_update_preflight.py", + "bytes": 3030, + "sha256": "b987a006751707af2ffbe756d68805630fd5cc489f5aaa282e4290e3aea3b46f" }, { "path": "CHANGELOG.md", - "bytes": 14273, - "sha256": "0ecfe356d27f686afc0d57e72f1f2b23ef98b4149b004e53dd02d4ac8bbdc5a9" + "bytes": 16059, + "sha256": "b18e9cd472100411f8ff4bc3ccab7a63c4e56871f3c912de9f96cca9ad239b0e" + }, + { + "path": "clients/python/modelforge_client.py", + "bytes": 5337, + "sha256": "a7dd4386950462d835ee61d96debdb82dc5a900a769d48a7d9f09d26ae184955" + }, + { + "path": "clients/README.md", + "bytes": 2463, + "sha256": "eb366b3e277317283d397e3c597c848a503e3146b82cf1272946b37504179a20" + }, + { + "path": "clients/typescript/modelforge-client.d.mts", + "bytes": 841, + "sha256": "e2db53ba72a1e4375976c6d503e315236c423e77bf4d9505b171c1ea3983ed37" + }, + { + "path": "clients/typescript/modelforge-client.mjs", + "bytes": 3841, + "sha256": "dd8b9fe225bdd790914d11fdaec44070970c42312d80a73fd8eb80158c703c4c" + }, + { + "path": "clients/typescript/modelforge-client.test.mjs", + "bytes": 3117, + "sha256": "7b152b48e034a44ecff1c5fd385b72b3d978453c32bf43c32a5b8c948b3dccb6" }, { "path": "config/benchmarks/examplevision-card-retrieval-v1.yaml", @@ -907,6 +1643,11 @@ "bytes": 1187, "sha256": "d3feefbab114117e99e341ac3e87b6033cfbd84129a0b4397fbeabbc28bf0046" }, + { + "path": "config/capabilities/creative.resource.v1.yaml", + "bytes": 959, + "sha256": "c322a4b78b07b054804ceabe03db9d924578636f98de36f6e2c2483dafc2113a" + }, { "path": "config/capabilities/document.ocr.v1.yaml", "bytes": 1442, @@ -959,13 +1700,38 @@ }, { "path": "config/public-candidate-unfixed-vulnerabilities.json", - "bytes": 2146, - "sha256": "a5765d3322544372b8fc261e3338dc151bd27aabbd4cc0770e3a017583b0eee3" + "bytes": 149, + "sha256": "30ab2f9f3e893908720a7b615b33bd68b10df79246646c902c871f98f410ac24" + }, + { + "path": "config/release/rc-program.json", + "bytes": 2051, + "sha256": "3d66d5f505e4eddd6f0702c88623ce22b8944fcca549e58f0c02dec8dd8f7044" + }, + { + "path": "config/security/accepted-risks.json", + "bytes": 50, + "sha256": "5c097a68386de41d2978c794dfe3f58abf430dda4fe9242c35d3d8b689fbf2c6" + }, + { + "path": "config/security/build-toolchain.lock", + "bytes": 2286, + "sha256": "d6239937e3cdca9d6489e504246ee2dda2bbbd9c1a5aa847016f7b7efac4f6c2" + }, + { + "path": "config/security/gitleaks-false-positives.json", + "bytes": 1045, + "sha256": "9da827205bbf0cf58b71da108c232b72050a12b471879b87f2825d9f8140d9ea" + }, + { + "path": "config/security/pip-audit-toolchain.lock", + "bytes": 3239, + "sha256": "1e0dbd2142ff0bcbc7e36f4e6586e0979f251f6849d92eedcdccc1c07fe15258" }, { "path": "CONTRIBUTING.md", - "bytes": 1957, - "sha256": "2f14a8540d88bbfcd768ea59d9286ed80a33847e4e1dea88736c0af49cae144d" + "bytes": 2182, + "sha256": "fd028ae94b8319b69df1464078c80a72ba6c314b193c1f37096764ba315ae95e" }, { "path": "deploy/postgres/init/001-modelforge-roles.sql", @@ -974,18 +1740,23 @@ }, { "path": "deploy/postgres/provision-existing-1.2.1.sql", - "bytes": 2882, - "sha256": "f45a21a6293f52c0ae9f224c73db9a81ea3908c256c4b9b8784abfbc5795a76d" + "bytes": 3821, + "sha256": "d7c9b6c9704cb9c6fbc899499c1a09eeaac4a99729bc2b3e1bd40732976330b8" + }, + { + "path": "deploy/validation/Caddyfile", + "bytes": 225, + "sha256": "5b84343907057ca50c51d8ace7457e374745cd2d5dc5daacb71e446219ce1059" }, { "path": "docker-compose.backup.yml", - "bytes": 1830, - "sha256": "c8d2d33c4e8e34ce5fd3e929b39d77ba7cbe1dac708d0e801a3cf690446e2def" + "bytes": 1906, + "sha256": "0ad787aa5694bbe1bfafc7cd2db27ec9ebbaf0ef76df72d5806daef86bcaaaeb" }, { "path": "docker-compose.dr.yml", - "bytes": 4087, - "sha256": "7e3f035ec59bcab083323c0b73150b88dec240cef1eebabe37446f8a5c1aacd0" + "bytes": 4116, + "sha256": "1d94d6a8bb4eb0fb53eec21ca0f70d7599739f161e5b04b70e1f4eac78b2240b" }, { "path": "docker-compose.gpu.yml", @@ -1009,8 +1780,13 @@ }, { "path": "docker-compose.production.yml", - "bytes": 3924, - "sha256": "8762dad33f2f6d238a158dbe2cb298d447ef646e6d4e594fd38eb0d00b54c60c" + "bytes": 3974, + "sha256": "79c0f42c4d82bee33be7b5f09917d6dce90e3d2ae9beaabb088eddc562577a09" + }, + { + "path": "docker-compose.rc-ingress.yml", + "bytes": 627, + "sha256": "9b97720e1629e386b05234bcbe1642d060370349ef294a3729f2790f63584c14" }, { "path": "docker-compose.runtime-worker.private-ca.yml", @@ -1022,10 +1798,15 @@ "bytes": 1902, "sha256": "75b0472a6c7115680cd32b458555570e32e4a6fb28bbb9c1bf1a84d21b7f99a1" }, + { + "path": "docker-compose.validation-tls.yml", + "bytes": 1285, + "sha256": "34c92d09c72e81e1b1922545dfa038bcb4e13a789019945a054ba312fda55694" + }, { "path": "docker-compose.yml", - "bytes": 6878, - "sha256": "4daa43446f5ae49f072b42e62ad445b1dd0b844c64d765c53ab3dadd5713c74e" + "bytes": 8742, + "sha256": "14ca82e8e9bced15663b6f13dbd2bdd34b52614b9a3d3952e836e3ea9c00af06" }, { "path": "docs/architecture/adr/0001-capability-first-routing.md", @@ -1262,6 +2043,66 @@ "bytes": 1600, "sha256": "69a888f0d71226b65de16c496d6679a24721da9f4192e40a2f21617405d1db0c" }, + { + "path": "docs/architecture/adr/0048-transactional-serving-configuration.md", + "bytes": 2414, + "sha256": "f56b0d6eaeafb95f1f6e3ba59c1c25384192020c581cd88a7d0c8439340394e8" + }, + { + "path": "docs/architecture/adr/0049-named-operator-keys.md", + "bytes": 2381, + "sha256": "c45b609cdcd7447570140e5dd2f1894696d1b7c5bdf525e8c7c62979f59689d4" + }, + { + "path": "docs/architecture/adr/0050-project-onboarding-and-credential-overlap.md", + "bytes": 2453, + "sha256": "be095fb147e077886521835817c6b2fce607972bddf872c175dfc505d2f54476" + }, + { + "path": "docs/architecture/adr/0051-manual-canary-observations-are-rehearsal-only.md", + "bytes": 2117, + "sha256": "29efd25c1d9a3303ef9126d56fea93adbdbf2656b214248c45d0547cb6c1724d" + }, + { + "path": "docs/architecture/adr/0052-managed-personal-keys-and-recovery-admission.md", + "bytes": 4600, + "sha256": "0e0fc1eb8d75b90c660c127b8c379e34647cf6dd953fc3b7794b82782291ec8c" + }, + { + "path": "docs/architecture/adr/0053-huggingface-control-plane-secret-slot.md", + "bytes": 4104, + "sha256": "3fcd564e9d4bd25e8af2ac3af19caef737c37d7e4b55fd83c818027184a44547" + }, + { + "path": "docs/architecture/adr/0054-lab-preflight-before-persistent-execution.md", + "bytes": 2642, + "sha256": "1879a723778915881dc98f1a7abf98629f17958a9fa08c49ba236e5478f8bc35" + }, + { + "path": "docs/architecture/adr/0055-selected-model-update-inbox.md", + "bytes": 3087, + "sha256": "591b2aa5b61b6ec862296089c64557df85a21cebe47f5e48b14022f0a110449d" + }, + { + "path": "docs/architecture/adr/0056-selected-model-comparison-intents.md", + "bytes": 6031, + "sha256": "51ae8926e58b4024f1e1b25fb17e6c9e84581861756ac4f42266952afc54bda4" + }, + { + "path": "docs/architecture/adr/0057-deployed-lineage-convergence.md", + "bytes": 3691, + "sha256": "86eeb800e6c302f2a3f69f2b2b40535541a0e6ddf278b9834b084a505cf3aac5" + }, + { + "path": "docs/architecture/adr/0058-approved-serving-transitions.md", + "bytes": 6228, + "sha256": "34320994aedd8e9a1ed4986d52ad249b827d7dc1548d9a419ce548e1662b1ea2" + }, + { + "path": "docs/architecture/adr/0059-isolated-rc-embedding-cutover.md", + "bytes": 3915, + "sha256": "5ab963ae008f985edc24f50fc9a2b5bce838f468cc2b113861854b6e77b2a827" + }, { "path": "docs/architecture/ADVANCED_GPU_SCHEDULER.md", "bytes": 3269, @@ -1284,8 +2125,8 @@ }, { "path": "docs/architecture/API_BLUEPRINT.md", - "bytes": 4171, - "sha256": "8bfaeec866b37097960c7eca977c49a01a0f48b6f03fd457d55542e786937b62" + "bytes": 4776, + "sha256": "6a6c88cf3dea93a43be663c1b46add8adef0e3c741584782f66d44387703f787" }, { "path": "docs/architecture/APPROVAL_POLICIES.md", @@ -1314,8 +2155,8 @@ }, { "path": "docs/architecture/BACKUP_AND_RECOVERY.md", - "bytes": 6786, - "sha256": "2d14580955dcf39eea96db714d100a9a74d599a6301021c6344a46034d109dfa" + "bytes": 7507, + "sha256": "6f8deb5239b2ec03bf1ab935885148478c6d4f1853f387de8d263dc04c2186b4" }, { "path": "docs/architecture/CANARY_AND_TRAFFIC_SHIFT.md", @@ -1489,8 +2330,8 @@ }, { "path": "docs/architecture/NODE_AGENT_PROTOCOL.md", - "bytes": 6614, - "sha256": "07a61ec404e1d5b4c7991d0e2262dad558566080564d0c58bffc0aa2ea5f43a3" + "bytes": 7634, + "sha256": "b54a5eb239051f68a1890035752e8c8ae704fed3bb0a03a8f19af2b8c7f6dcc7" }, { "path": "docs/architecture/NODE_DECOMMISSION.md", @@ -1532,6 +2373,11 @@ "bytes": 5619, "sha256": "a5a7e532d8d9a0718608bae04a0f917772e03afb5812ac63160366a32b70564a" }, + { + "path": "docs/architecture/PRIVATE_MUSE_AUDIO_RESOURCE_CONTROL.md", + "bytes": 3382, + "sha256": "b677e0223ee877da3de6ad886ee7842070614cf30446cb16545b703b6f3bcecb" + }, { "path": "docs/architecture/PROJECT_BINDINGS.md", "bytes": 1535, @@ -1539,8 +2385,8 @@ }, { "path": "docs/architecture/PROJECT_CAPABILITY_INTEGRATIONS.md", - "bytes": 1259, - "sha256": "e1c3911e4e78c238630ede056732ad89f70b41273e20eb6225594fb2ff8871ba" + "bytes": 1633, + "sha256": "a45d99854e996e1c3579940225fa0acb8fe01c8f5b31c322cd354124a9440568" }, { "path": "docs/architecture/PROJECT_FIT_EVALUATION.md", @@ -1549,8 +2395,8 @@ }, { "path": "docs/architecture/PROJECT_INTEGRATIONS.md", - "bytes": 1312, - "sha256": "756d52ae92b60affafc94026b7024f40eb9aff0721a02b1cc9bdb37c7827f516" + "bytes": 1664, + "sha256": "33196654fca26d7a75c6a7d24102efc612041f433555631818f5ee5a2b3d005d" }, { "path": "docs/architecture/PROMOTION_PLANS.md", @@ -1689,13 +2535,13 @@ }, { "path": "docs/COMPATIBILITY.md", - "bytes": 5703, - "sha256": "80af6c8bfcc3caca63fe719d2785c06f30b4bb7fe26c6602526fc66497787b75" + "bytes": 6141, + "sha256": "367b00fa022adacac2ac9f0dbec7dca7ed7d861711da591f8370128e865ba5c8" }, { "path": "docs/CONFIGURATION.md", - "bytes": 19082, - "sha256": "948888c5f2624dccbbf54ca3cf5878e217b43328bee6ee53ff2b3e8592b509a9" + "bytes": 22406, + "sha256": "d84f1411ceda37d80284accd9c0ae662c74a012b8e3be0a038bef301f3c77e48" }, { "path": "docs/FIRST_RUN.md", @@ -1704,13 +2550,13 @@ }, { "path": "docs/INSTALLATION.md", - "bytes": 7713, - "sha256": "fac59f8017eb7e65c63341f487615e6456fc2c6d05cffc4440f507b8cf9ab5d2" + "bytes": 9422, + "sha256": "0bfc4e1c85e204fee586a18d121c1a4a0d534158aa64cf3c037d308319163576" }, { "path": "docs/NODE_AGENT.md", - "bytes": 6145, - "sha256": "25b8f4a85ea0f5d0a128e12427350ef561bd68d0ebede008479c7a4c649b0e71" + "bytes": 8568, + "sha256": "f65969f37f5adb6f6e2e0bec700f67aa3131c6d88c1a42a870bac67f7423916a" }, { "path": "docs/OPERATIONS.md", @@ -1722,6 +2568,11 @@ "bytes": 6042, "sha256": "256fede908692cdfb5c96337708f25b18ef42dd82bd1b84c5589da38f25aa12b" }, + { + "path": "docs/operations/CONFIGURATION_WORKFLOW.md", + "bytes": 3726, + "sha256": "20e6b946bf75966151afcd5d038fd047291bdbba31af21e4e7f2399b990243d5" + }, { "path": "docs/operations/FAILURE_AND_HEALTH_MODEL.md", "bytes": 1410, @@ -1747,10 +2598,30 @@ "bytes": 3600, "sha256": "950f3310dedb50f00ff9de56294f92ced3dbb413fb00205bcdee73030c9f7514" }, + { + "path": "docs/operations/MODEL_UPDATES.md", + "bytes": 2488, + "sha256": "df91f3bc863ea3f344efee532c68603df7ea047e78f1528dd9688868d910bab2" + }, + { + "path": "docs/operations/OPERATOR_ACCESS.md", + "bytes": 7561, + "sha256": "765e8127679f1b060199e817c0cec50021c9d5aa3419f3c3b21e55b29e287e63" + }, + { + "path": "docs/operations/PLATFORM_UPDATE_PREFLIGHT.md", + "bytes": 1554, + "sha256": "f14d48098499e26d5e0835cafa78035a32bf1022b74bed8fe17c757aeced1249" + }, + { + "path": "docs/operations/PROJECT_ONBOARDING.md", + "bytes": 4552, + "sha256": "a477392cba03a402dc9337f578a652f8e25bd9be3a174ccc2bf1e672d61bf855" + }, { "path": "docs/operations/RC_ACCEPTANCE.md", - "bytes": 2419, - "sha256": "a74eab1e570d06de61e62aadd4cc2073282553d0de54f64beb2dc7cd165c3422" + "bytes": 7981, + "sha256": "553e87ccab96759d44e402879e45b1af73ddf000636ada6a141dc6b0d23c706d" }, { "path": "docs/operations/RESIDENCY_MANAGER.md", @@ -1807,10 +2678,15 @@ "bytes": 2325, "sha256": "389feddf825a404ea7f010d0fabff8ca0dcb87f091f167cdd10528c9aa3c938d" }, + { + "path": "docs/operations/RUNBOOK_NODE_LIFECYCLE.md", + "bytes": 2010, + "sha256": "b1c414f5471451156c458df115fe45e6278b3a7b05d09255f23e40f0d7edf156" + }, { "path": "docs/operations/RUNBOOK_NODE_LOSS.md", - "bytes": 2561, - "sha256": "bf8e67789b8a5f57773669312d4fa5e5780ce2ded35e89a1c57ddbe76d3ec297" + "bytes": 3187, + "sha256": "f9cad0b3b1f65087c964a0b951690a3c977044ecf6261d4ec2631835db9ce4de" }, { "path": "docs/operations/RUNBOOK_NODE_OFFLINE.md", @@ -1832,6 +2708,21 @@ "bytes": 2047, "sha256": "fec1460f2637a49927759f7b906dbbab76ea69a735dbc67f857ee06b22a6b9f6" }, + { + "path": "docs/operations/SCHEDULED_BACKUPS.md", + "bytes": 2280, + "sha256": "e498ee3ef956689e904d63b1ed0156a06c75758a7377f4e9a9d5754f0d83725b" + }, + { + "path": "docs/operations/SECRET_LIFECYCLE.md", + "bytes": 3794, + "sha256": "7020e2b6d726d4b54127a917e03e9a7f5b35157295a7fa6e02a932376184dc38" + }, + { + "path": "docs/operations/SELECTED_MODEL_COMPARISONS.md", + "bytes": 8841, + "sha256": "b691668f17bfc959a700cca222a1a1b30e077afaa80ffa0a1dfdfbd97d6b7d91" + }, { "path": "docs/operations/SERVER_AGENT_DEPLOYMENT.md", "bytes": 6154, @@ -1842,6 +2733,11 @@ "bytes": 4808, "sha256": "a8224bbbc43910a2377c3c41614940fcce88c6ed9a998d3e98d4b24ec6f87068" }, + { + "path": "docs/operations/VALIDATION_TLS_TEST_PORT.md", + "bytes": 3617, + "sha256": "a66e931139a120cd8176ed92162b5e038a5a4c7e699b9f213150baf7df2309bf" + }, { "path": "docs/product/MODEL_LIFECYCLE.md", "bytes": 2921, @@ -1849,13 +2745,13 @@ }, { "path": "docs/PROJECT_INTEGRATION.md", - "bytes": 4942, - "sha256": "e12d1c2beabf70970fb55aaaf1aa02769fd8c0fa8193793a0f3c84c8f99e7609" + "bytes": 5133, + "sha256": "468ddec1b058fbc1ecc17bb853396c421c1a5e122cb06f0f3fb3898cb9126d7a" }, { "path": "docs/PUBLICATION.md", - "bytes": 2688, - "sha256": "b64478a3547b2776c45566dddb54595184247f1a584acb0083d277d36d6f0b3d" + "bytes": 3523, + "sha256": "4f794719587124bf3bef20e9ff4ae88d4afe3970945b9b395930a49a53102cee" }, { "path": "docs/RELEASE_NOTES_v1.0.0.md", @@ -1884,13 +2780,38 @@ }, { "path": "docs/RELEASE_NOTES_v1.2.2.md", - "bytes": 2318, - "sha256": "9fc39f703b4126c295b8cd2b0822166c0577307c44475e0e20ba0a9e6d61ad91" + "bytes": 3820, + "sha256": "6c56c13ef7f58d292c9d77c10c4fc7453acefb8f69bb1df58ad1218e25e6b73e" + }, + { + "path": "docs/RELEASE_NOTES_v1.2.3.md", + "bytes": 2359, + "sha256": "184582efb996619c84ad019596f993dd78a04a0f7eb812cd504851fb4da1c584" + }, + { + "path": "docs/RELEASE_NOTES_v1.3.0-rc.1.md", + "bytes": 1798, + "sha256": "7ac793ae81907c16b882bfefcd5f39be937931f546ecd59dd1a6db5400756dab" + }, + { + "path": "docs/screenshots/console-demo.png", + "bytes": 137762, + "sha256": "ed0d6a2aed99c64e950aeb43b7fa4b74f29c2a38a6ddc0f54de074a2db13ac05" + }, + { + "path": "docs/screenshots/models-demo.png", + "bytes": 92426, + "sha256": "5b92ca1863390ff27605c2635cd1b9c3e82d8e3eed6467d6255e3a35629be3b3" + }, + { + "path": "docs/screenshots/README.md", + "bytes": 463, + "sha256": "da570e7443c5ed9da60c45daa62aa80e40a1348a4821812741447d1ae5d5f50d" }, { "path": "docs/SECURITY.md", - "bytes": 4225, - "sha256": "2af6d5e0ac311ef543e55b0ea2d31fd8c4236cbe4619a6873f9acc38976354be" + "bytes": 4371, + "sha256": "b2ef8ef5ca20863f8fe4a7fe7ae5358cce4eaaddee8deffb359bc4d4ad75a907" }, { "path": "docs/security/ADVERSARIAL_API_TESTING.md", @@ -1929,13 +2850,13 @@ }, { "path": "docs/security/SERVICE_AUTHENTICATION.md", - "bytes": 703, - "sha256": "a1a5f7b5caf803683a57d0bd230be6d382864c33e5d2c428dfbd47d800104872" + "bytes": 985, + "sha256": "2f70d75067a142ece7c91ca25750606d457a34b8fad5193c9aec0b1e1f82c33d" }, { "path": "docs/security/SUPPLY_CHAIN_RELEASE_GATE.md", - "bytes": 4524, - "sha256": "26c0e02b466a0246d48363961475e1e9d2714d5fd9bd7f7a2dc8aa16dc0a6f57" + "bytes": 20404, + "sha256": "9ce3ee08883ef6ec42ed4ca05d042bce0e46f34580d443087a0fb2d0e338e4d3" }, { "path": "docs/security/THREAT_MODEL.md", @@ -1949,13 +2870,13 @@ }, { "path": "docs/UNRAID_DEPLOYMENT.md", - "bytes": 3308, - "sha256": "23a759d75df84d0580db26a3f7076cb4f6b7c2650826f063f7925d90b137013d" + "bytes": 3513, + "sha256": "ae1a8c9328a0cc2fb4890f53dd74a697cae2025835a3a324fec4bb3b6672257f" }, { "path": "docs/UPGRADE.md", - "bytes": 10085, - "sha256": "3f90263b13530ba3afd4758912f820e16f2f1a1f5d7a5c74f1659d49cf35d2ee" + "bytes": 14137, + "sha256": "ee71624ea69e7f53a2fe1ca4aa44fea36b3599ed518eda899bb5090c80e5f6ac" }, { "path": "frontend/.dockerignore", @@ -1964,8 +2885,8 @@ }, { "path": "frontend/Dockerfile", - "bytes": 3068, - "sha256": "1fb254d786ff9e5799b4efd8f1e4a33a1d7505f8329224f768ab73fc506e5337" + "bytes": 3273, + "sha256": "2d2553f96da53ef45020188ccb7af7f0f42729263e0b237747e3ae8594eb7dbc" }, { "path": "frontend/index.html", @@ -1974,33 +2895,48 @@ }, { "path": "frontend/nginx.conf", - "bytes": 1808, - "sha256": "2628b4fad935e549f503c31be4503cf93fb6a3a39b81d30740214fa6065d2ae2" + "bytes": 2213, + "sha256": "908161929b932420d26e8ac027ded882a6c5a546b557146fd96ff2630feee931" }, { "path": "frontend/package-lock.json", - "bytes": 83083, - "sha256": "e659484f6917736a18c98990be47f683992f0302276ff808e94052447866f02a" + "bytes": 83093, + "sha256": "08eb405ec1703bfbe8946b37326abe40a4de476e2ccec7295b98d6517be78143" }, { "path": "frontend/package.json", - "bytes": 710, - "sha256": "efded9d3f0458a78a9da5afd971bd63fe2ff11452a346b25eda8892f67601db1" + "bytes": 919, + "sha256": "130446e32ba0e5403cb6e611e895592ccfe3133553611b895d66c5dcefdd3544" + }, + { + "path": "frontend/scripts/accessibility-contrast.mjs", + "bytes": 2325, + "sha256": "510c75ba7ff493fc3f5deca10ec8984d6c03d9a92e47023a71a20824e8513925" }, { "path": "frontend/security-headers.inc.template", - "bytes": 1167, - "sha256": "f2e120277ce59303aa94faca53130600448390d269fb2fac4f2c0254688382f5" + "bytes": 1160, + "sha256": "41f793a6709ac98b02dbfb787c4409d5a3432212aaa8ab32427a6e7eabd32634" }, { "path": "frontend/src/App.test.tsx", - "bytes": 28514, - "sha256": "48e654d1850178778acbe38ec54aec1729d7848925ef4ae73f09c402ada8bcbd" + "bytes": 24459, + "sha256": "53266eadeeda25093ea747ad976f5a83c087d134676bd6a06311d0163eab2e42" }, { "path": "frontend/src/App.tsx", - "bytes": 47369, - "sha256": "92491fe037b1ceaedd01cc6d7724ba952352f2bd6169be0aaf1ac38992abc5f5" + "bytes": 28548, + "sha256": "7362e56b605949a193b1c8675a3d5ce02b34aaf460e4136c8ed99a87848802b2" + }, + { + "path": "frontend/src/benchmark-workspace.css", + "bytes": 15004, + "sha256": "f56881ee37ef0d29ecf1e63e29539248a484d874168220be6a28d878d1af12f2" + }, + { + "path": "frontend/src/components/AdvancedHub.tsx", + "bytes": 2565, + "sha256": "46aa2ce1b87701a9e2563b7d1144f98331c7d4d3e1205feeb9629476e22c27e1" }, { "path": "frontend/src/components/AdvisorWorkspace.test.tsx", @@ -2012,35 +2948,120 @@ "bytes": 3388, "sha256": "068ba7f7f17b81cf927929369257940d674ed495225fedbc485759f62c3b1d94" }, + { + "path": "frontend/src/components/AuditTimelinePanel.test.tsx", + "bytes": 7099, + "sha256": "780d80f44ca341b7398b8bf67aadf23fa55aae35d6ea125dbf7775c5300d5aa1" + }, + { + "path": "frontend/src/components/AuditTimelinePanel.tsx", + "bytes": 8480, + "sha256": "8675198e9800ceced61537e8fcda3072a266dfb77e0d4bec3d4c567e8a41144d" + }, + { + "path": "frontend/src/components/BenchmarkWorkspace.test.tsx", + "bytes": 16923, + "sha256": "1c728f960672e19489f06c3fd23c15e77d1992b863c9e4d9a166cfcf2a25e9f7" + }, + { + "path": "frontend/src/components/BenchmarkWorkspace.tsx", + "bytes": 36326, + "sha256": "9d350f9fdfc2fdf2c28caf3a8725290e2038ec5150a305218e8577991ddf8409" + }, + { + "path": "frontend/src/components/BuildIdentitySummary.test.tsx", + "bytes": 3087, + "sha256": "52e4a278188dd0c365c87e7ea5a93875391c725b5c29f877069dbf33f5a2512b" + }, + { + "path": "frontend/src/components/BuildIdentitySummary.tsx", + "bytes": 2532, + "sha256": "84961845c87c33cacd4b7b6dac3941e46729a445d948822d1dd01e59f41f8e88" + }, + { + "path": "frontend/src/components/CapabilityDetails.model-identity.test.tsx", + "bytes": 2605, + "sha256": "1da75bedb660849a0d6a59d0c042d0553213f7bbb9fa30e87f50bcaa433a56cc" + }, + { + "path": "frontend/src/components/CapabilityDetails.test.tsx", + "bytes": 11698, + "sha256": "35a684ecb62053944a52a6b330de44e5cdcc48bb7f0959711ac079dd88c454e5" + }, + { + "path": "frontend/src/components/CapabilityDetails.tsx", + "bytes": 12206, + "sha256": "e60df025b4777e6c0420e401531380f3e0f5c307d2565f72dacad9e0681df5d9" + }, { "path": "frontend/src/components/CapabilityWorkspace.test.tsx", - "bytes": 13522, - "sha256": "9568afeea15762f2872bcec1880fbfc0e76131d323e35c5bed8551073952d6a5" + "bytes": 23703, + "sha256": "755276d604fe1cb3d3e82bfaef5eac1bb4dc844e101b034897789c0f1d5d10a6" }, { "path": "frontend/src/components/CapabilityWorkspace.tsx", - "bytes": 32801, - "sha256": "901a2d2f0a3b3830e09891f951209e366f206e6b4ac2723523aac3054db8d578" + "bytes": 28871, + "sha256": "66c1ffebc63f17aa40aab04a8d6e4cbbd8db1515b1c88b46f8f3c2d666bd0c15" }, { "path": "frontend/src/components/CommandCenter.tsx", - "bytes": 18557, - "sha256": "31f028cc0f3aa9c4ce238751f448a4c9e6215e23cc34006c03470480fd28444e" + "bytes": 14752, + "sha256": "2871c0bd8c0c414a96a373096fc53d8d4bb80eeb9e7e508657878c8dc8c30881" + }, + { + "path": "frontend/src/components/CommandPalette.test.tsx", + "bytes": 13314, + "sha256": "a3348855d73bc5c1a089dce3e2cfc61497245f8203f9551476c3df3bd6bae7a9" }, { "path": "frontend/src/components/CommandPalette.tsx", - "bytes": 4775, - "sha256": "b0f8fddf900af56c9a133691daae33f31f37b754d277458aa2942c50653b2a55" + "bytes": 15601, + "sha256": "ffdc4aecf52ac9a3ee39f38f456c634f0fb6142d3427c61796fbfb91328e3d68" + }, + { + "path": "frontend/src/components/ConfigurationEditor.test.tsx", + "bytes": 8718, + "sha256": "613e79751130dffa65bde55bab80115cd18c0cb88b0b556220483fec7f0ec797" + }, + { + "path": "frontend/src/components/ConfigurationEditor.tsx", + "bytes": 9433, + "sha256": "94a92ee6ecbfcae6f93e7c0a865f054eb1f7ab51ead9a28b615c756c8f4116e6" + }, + { + "path": "frontend/src/components/configurationValidation.ts", + "bytes": 1652, + "sha256": "41dc487b0f2c6360da1937f8155df5659e5744f675636229215527b0f136fb85" + }, + { + "path": "frontend/src/components/ConfigurationWorkspace.test.tsx", + "bytes": 4654, + "sha256": "885169dada3e962893c166e26241fb8399173df65135209ad6bd44975a231095" + }, + { + "path": "frontend/src/components/ConfigurationWorkspace.tsx", + "bytes": 8379, + "sha256": "b792ea7a6757f65c8fa2a6654655fb7594596d24506934615c6b0487f3f57654" + }, + { + "path": "frontend/src/components/CreativeResourcePanel.test.tsx", + "bytes": 9411, + "sha256": "9fb62f769792fa9c4f7bd08695ac21e5e7a09095c7777b9669b900006e485661" + }, + { + "path": "frontend/src/components/CreativeResourcePanel.tsx", + "bytes": 12486, + "sha256": "5ef9cb12df112489514d1bc0308cfd646a868fdd74209ed7622a8009608639c1" }, { "path": "frontend/src/components/DiscoverWorkspace.test.tsx", - "bytes": 7992, - "sha256": "a45d171db2fa5a6d3a636391d23e30f5ba15cf95b4e276af95a3bc0d079e70f9" + "bytes": 26737, + "sha256": "1d034f202600655ca9184f523b90bb42cefc91557d77dabb74e07bab8bf61e9e" }, { "path": "frontend/src/components/DiscoverWorkspace.tsx", - "bytes": 14277, - "sha256": "86f24515aa7ea6d5a213ab6083c46268f37416c2eaa81cefe7ed1a1e5822a8b5" + "bytes": 26318, + "sha256": "70731b43ed6e7f33c1a4226ef6656d5c829f3ee66ddf0bf415ae88bc0499a62a" }, { "path": "frontend/src/components/EvaluationWorkspace.test.tsx", @@ -2052,15 +3073,70 @@ "bytes": 17225, "sha256": "385b268d888054dfbaa95da80bb8486d447fd58bf623a903be66a1ee9fba47b8" }, + { + "path": "frontend/src/components/GpuResidencyDrawer.test.tsx", + "bytes": 3719, + "sha256": "92ac43b61862050e98cfc82aeb950ed34e0842003a1dccdbec0755018cf7c950" + }, + { + "path": "frontend/src/components/GpuResidencyDrawer.tsx", + "bytes": 13841, + "sha256": "9ddb903dd36c399e65af21e0eef3774076f2fb42f6105bc645a12498ebde6a9f" + }, + { + "path": "frontend/src/components/HardwareWorkspace.test.tsx", + "bytes": 43720, + "sha256": "6e909bd4c058972d35b9466569b48aa5c99cb8374fb223cd1cbfb07955fc541d" + }, + { + "path": "frontend/src/components/HardwareWorkspace.tsx", + "bytes": 30017, + "sha256": "15265c79f7c1248c9bd88246367b73cd6c472c4c201f979159d29da6cdbfc5b4" + }, + { + "path": "frontend/src/components/LabExecutionWorkspace.test.tsx", + "bytes": 18044, + "sha256": "00479b310f6681e093694388434c673e8ce073c17601a4e90bfb0f3e44c581f4" + }, + { + "path": "frontend/src/components/LabExecutionWorkspace.tsx", + "bytes": 43038, + "sha256": "6475c696c79b4a52c87e30275afbfaa46be5cd80aa5b0ea62dcaa69be54cb4c4" + }, + { + "path": "frontend/src/components/LabPreflightWorkspace.test.tsx", + "bytes": 16872, + "sha256": "3098ed99e14e5766ebdf9bf6567bab2cdb5930b18f3f49ad5d25abe9b3152c96" + }, + { + "path": "frontend/src/components/LabPreflightWorkspace.tsx", + "bytes": 19068, + "sha256": "bca70158b1ec39f260441136a89322d0f91a47e6cef13aef086bf953c5cc991f" + }, + { + "path": "frontend/src/components/LabWorkspace.tsx", + "bytes": 1356, + "sha256": "50c3f88940d4675a2f9919e67fac84757e832ce52ea4386c92db70d4ca8be595" + }, { "path": "frontend/src/components/LifecycleWorkspace.test.tsx", - "bytes": 6253, - "sha256": "199d1f5bed4a488f89fdcd0df5f2ad7b7494bf5834fc57ce7ebf3ddef4ee3215" + "bytes": 7482, + "sha256": "04d8392e3ddec590bce005a529baa9a5ca30fe4e2548262abcf641ee0fc91ea4" }, { "path": "frontend/src/components/LifecycleWorkspace.tsx", - "bytes": 23613, - "sha256": "95b9e37d5fe658b8edcb7b85dddd5917e3a3dd83862cdb40a3a967eef5205920" + "bytes": 23212, + "sha256": "70ead98c0f4a6717d8c2a201fbe67ca80f50bd21c95527ce7d590f8876058cb8" + }, + { + "path": "frontend/src/components/ManagedIdentitiesPanel.test.tsx", + "bytes": 13707, + "sha256": "ea006d654e332476b608ff0bc1c9f5d9f0ef768e04aceaf0bf60fdfdd6567fa7" + }, + { + "path": "frontend/src/components/ManagedIdentitiesPanel.tsx", + "bytes": 12981, + "sha256": "340ad93452d539ecc8e913f176d094c699130796bf29afd23be57bd95a892640" }, { "path": "frontend/src/components/MetricCard.tsx", @@ -2069,54 +3145,194 @@ }, { "path": "frontend/src/components/MigrationWorkspace.test.tsx", - "bytes": 4676, - "sha256": "bad5355c9862a72081d7d48064bcc7bae7717bbf1ac5b82742bfa674519e2c66" + "bytes": 12960, + "sha256": "d487217efb8aa2c6c496dc77e6915c54dbe91a74143acb7fbb7031acd9d41cca" }, { "path": "frontend/src/components/MigrationWorkspace.tsx", - "bytes": 11098, - "sha256": "880960af19c090f458a7e68a43e46a992fdfebf8c212dfd94c2b2ee718d117ee" + "bytes": 19656, + "sha256": "6638fc382ebe65f8e12bc797758a026e051c6956090ba2976f67d979da191236" + }, + { + "path": "frontend/src/components/ModelChangeCasePanel.test.tsx", + "bytes": 1212, + "sha256": "cf42fdbcdfd57be57e0e5f3f2a3494504eff960b8cc94fea30185eeef86fb63e" + }, + { + "path": "frontend/src/components/ModelChangeCasePanel.tsx", + "bytes": 13685, + "sha256": "b3aae29968901a04d22b1f55971e86c9c1aae4a329534218c08df1c11972d61e" + }, + { + "path": "frontend/src/components/ModelsRegistry.project-management.test.tsx", + "bytes": 4137, + "sha256": "8b89df88d93871214e1df96ebc97a410c07eefc4190226728ca0199f60845c56" }, { "path": "frontend/src/components/ModelsRegistry.tsx", - "bytes": 28410, - "sha256": "1078c8a8391521319ae9218442942a402c261f16961eef02f79e46295b19e795" + "bytes": 20343, + "sha256": "a2db52b6957c351dd399990078bb0b4c6acab75ee3748c28582e85dfb56d46bf" + }, + { + "path": "frontend/src/components/ModelUpdateAttention.test.tsx", + "bytes": 1811, + "sha256": "766ecd745cc0a9d75d1f5cbed599965101b8ae9573ba2619e3d1c3c6b3ffc236" + }, + { + "path": "frontend/src/components/ModelUpdateAttention.tsx", + "bytes": 2242, + "sha256": "2ca74d93cb920383ef954cc50dcc953aebce42fa75938057609a2206a3196a40" + }, + { + "path": "frontend/src/components/ModelUpdatesPanel.test.tsx", + "bytes": 12649, + "sha256": "61aa87717fdbc929ef31e06f6c91ac4f952abd65048fdcffe42dd17733905be1" + }, + { + "path": "frontend/src/components/ModelUpdatesPanel.tsx", + "bytes": 17380, + "sha256": "8c029209808f80d9ec1672b25c62b118946923724155f7c89332a1d73bf2deef" + }, + { + "path": "frontend/src/components/NodeEnrollmentWizard.tsx", + "bytes": 38499, + "sha256": "b5466469b57f7e8c2b581c8358b9d304d5c9a977e6388455c179ae15ccd1af3f" + }, + { + "path": "frontend/src/components/OperationsCockpit.test.tsx", + "bytes": 8189, + "sha256": "8a66c5934fec2ddd2de51f34bf1ad21719ca9c52077b85ce741d0aa116f63493" + }, + { + "path": "frontend/src/components/OperationsCockpit.tsx", + "bytes": 11480, + "sha256": "34cc3db7630e258391dd8f70abf24490e9df74b9ad0b87ad6bdae4c1b251220a" }, { "path": "frontend/src/components/OperationsWorkspace.test.tsx", - "bytes": 5663, - "sha256": "0164c5fdcda15d3ee309e3d5245da678271e138088b4e2edf7fabf53d0dfc775" + "bytes": 19072, + "sha256": "34469ce7460bee8f5cde92c00a757229de1a8db61cc07dddaf8290e37249ca6c" }, { "path": "frontend/src/components/OperationsWorkspace.tsx", - "bytes": 16110, - "sha256": "9a2f756f49c670386449bf7052825dadb7640764dca3b6d974385319668fe6ce" + "bytes": 20772, + "sha256": "ec9c66e9e404a8a6e29571f36b5a9d84e50aead3d06899109a5024e9b1b2fc35" + }, + { + "path": "frontend/src/components/PortfolioWorkspace.test.tsx", + "bytes": 5975, + "sha256": "ae539c2c5e53766bf52558b94a6ce0c88847b99fa6685f7479a793278a26ae56" + }, + { + "path": "frontend/src/components/PortfolioWorkspace.tsx", + "bytes": 9360, + "sha256": "4377088bc8716476c63eaf95d888c9731b7b09d0444987e101dc5916f0f081fb" + }, + { + "path": "frontend/src/components/ProjectFirstWorkspace.tsx", + "bytes": 17048, + "sha256": "4472c09eb33c94209dd42d271598da046a8bdffe1706fd89a59cf78304702390" + }, + { + "path": "frontend/src/components/ProjectOnboardingWizard.tsx", + "bytes": 11011, + "sha256": "745109ed6319b962e3e1bd3187c24ed686703850cd94af3961f9a92258b025ec" + }, + { + "path": "frontend/src/components/ProjectsWorkspace.model-management.test.tsx", + "bytes": 3505, + "sha256": "e7c21f76b9e34b19b8fd46cd71a4c385d6a416e637b36ba5c49b068b35561b36" + }, + { + "path": "frontend/src/components/ProjectsWorkspace.test.tsx", + "bytes": 8823, + "sha256": "65a55a6abe0b40a1eefae6f6723c8e2433ee271d445e07c30f38f766c6d5807c" + }, + { + "path": "frontend/src/components/ProjectsWorkspace.tsx", + "bytes": 23828, + "sha256": "0d8de83937450668cb0207f404420de4e1fe8a705b1dbd546c6bfb3f8a845d2f" + }, + { + "path": "frontend/src/components/PromotionGate.test.tsx", + "bytes": 10210, + "sha256": "87c042d83c44f985dc1189a9a494fe49e948dc606222d7b7e94af11ad9239684" + }, + { + "path": "frontend/src/components/PromotionGate.tsx", + "bytes": 8577, + "sha256": "3aa1212dcbab187cd897f78cd12a0d5932ecaaa72286978d2db262790e349924" }, { "path": "frontend/src/components/RecoveryWorkspace.test.tsx", - "bytes": 17790, - "sha256": "f03c603258204571c1abbf9b1fababe1e48dcf3e6f189ad4e081c4cfce82ce17" + "bytes": 22432, + "sha256": "6661bd328ec8aab6a550b016372bf570affc9e6f85d917bea4980d3a93615891" }, { "path": "frontend/src/components/RecoveryWorkspace.tsx", - "bytes": 19633, - "sha256": "8f8073992684882f8c0513d595181ef0e0935a7dd09604f697a4f4cb11b7ad16" + "bytes": 23115, + "sha256": "9b724361773efbce6ae4141a0c7e665494207112858c1d2cf712884bce44583a" }, { "path": "frontend/src/components/RuntimeWorkspace.test.tsx", - "bytes": 7647, - "sha256": "1c73ef3a23011362d93b1ce7bc5d823ab82560f18951d008d41cc870c8b3c482" + "bytes": 19196, + "sha256": "6e9aa5b3526ee733c2980f6fb501d6c6c84b4fd5571b163f1e3f429f2ee2856b" }, { "path": "frontend/src/components/RuntimeWorkspace.tsx", - "bytes": 11977, - "sha256": "4d7c5a352d200529c1a8124848d24b010f5539e760d89b5ed9e4beda43ff8312" + "bytes": 16631, + "sha256": "ba39634e337b3b5718cc70f9baa4128af34e7dbb57536c61a36ddf7a42695b48" + }, + { + "path": "frontend/src/components/SafetyDialog.test.tsx", + "bytes": 1960, + "sha256": "66a69967036a496e7a8e7886aa518b01333eea938f40d880a416127fc6755fa5" }, { "path": "frontend/src/components/SafetyDialog.tsx", "bytes": 4599, "sha256": "6e521026287cb79964b4ea01d60a01a4499ec7c8a25e6f833c420ab63c85fc40" }, + { + "path": "frontend/src/components/SecretSlotsPanel.test.tsx", + "bytes": 7227, + "sha256": "8a348668a879c96344a29ca5d14f2b12e95539d9e940ff6f92685630b5e7b289" + }, + { + "path": "frontend/src/components/SecretSlotsPanel.tsx", + "bytes": 8539, + "sha256": "dc00ba573d50cfd2537971b3d358fde663bc69743b2540b73cd139cc671cdd2a" + }, + { + "path": "frontend/src/components/SelectedModelComparisonsPanel.test.tsx", + "bytes": 7786, + "sha256": "41c34ee9badf7d1e83d42027b08f2160fc0e32d321ec237bddd1b9b14dfd0e4b" + }, + { + "path": "frontend/src/components/SelectedModelComparisonsPanel.tsx", + "bytes": 12980, + "sha256": "c5f208fbacaf9c560611f0cab3a8e9a1807a05b11f1b530267a81b7a4b4541e3" + }, + { + "path": "frontend/src/components/SelectedModelReplacementPanel.test.tsx", + "bytes": 4553, + "sha256": "e2d901207f5f2a7502bb19849c07f23d65fcabf566589ebd3336ac50e2c66d3f" + }, + { + "path": "frontend/src/components/SelectedModelReplacementPanel.tsx", + "bytes": 4484, + "sha256": "a8638b99da6a5de220377031d80aebb86f93a7fffda0b7299407c0ddc0e0966e" + }, + { + "path": "frontend/src/components/SelfCheckPanel.test.tsx", + "bytes": 8618, + "sha256": "2911b120cf3a6abf4f7ac1ae4c5c1b22a39a00f0eeb0e441f8bb1facf0703ff1" + }, + { + "path": "frontend/src/components/SelfCheckPanel.tsx", + "bytes": 5286, + "sha256": "10fd0118439b702321be0aed687a4135e80879425aa3c43ab186c8c2869c1736" + }, { "path": "frontend/src/components/TabList.test.tsx", "bytes": 3382, @@ -2127,6 +3343,31 @@ "bytes": 2418, "sha256": "7a8d9de39066d74f737dce5c49c88621b161a86c12871dc223a5acb4178519f6" }, + { + "path": "frontend/src/components/TroubleshootingPanel.test.tsx", + "bytes": 5111, + "sha256": "ac6cf899bd0e10342022aba9841dfd7ddeb6ba16d503c0e23e89610433a8ee22" + }, + { + "path": "frontend/src/components/TroubleshootingPanel.tsx", + "bytes": 7337, + "sha256": "cde75d0f716d74e62b71d14ffdc78ad0cdffb2f42eb63aeb8ab00ae6d7d3f4fd" + }, + { + "path": "frontend/src/components/UpdatePreflightPanel.tsx", + "bytes": 5629, + "sha256": "d7daf9ddd57f3e46fd1a4a7ffd39058f2c74b7e1897300b7699cc2b9a91ddef9" + }, + { + "path": "frontend/src/components/WorkspaceLoadBoundary.test.tsx", + "bytes": 3324, + "sha256": "b38ac7de44c4ee216e7c8105f623c78d8962df6637b0a0c74af1c88d11757905" + }, + { + "path": "frontend/src/components/WorkspaceLoadBoundary.tsx", + "bytes": 1320, + "sha256": "b190a7b3a947d8f9ed4e8c5ff30ad94c5cb3e9f5774a40e74fe4a526e1776ae2" + }, { "path": "frontend/src/lib/api.origin.test.ts", "bytes": 2105, @@ -2134,28 +3375,98 @@ }, { "path": "frontend/src/lib/api.test.ts", - "bytes": 5557, - "sha256": "4c2afdc96da88608c8d59c885f6fce52a6d01da12eed52b669ed8771663733c2" + "bytes": 21299, + "sha256": "0eccf8f7386c63decfdd4a6c5f3ec7008e6a5e236c79d093e54d5ff8d3fbdbdd" }, { "path": "frontend/src/lib/api.ts", - "bytes": 27782, - "sha256": "a3f52e4edb4e11af93376ea6d27436e0febe162f7afea08bfce1c0c045fc9dcc" + "bytes": 53206, + "sha256": "c39df3e5742e4c266ebf050e4afd538c214ffc3b4053b1c112af7362b4a90d8e" + }, + { + "path": "frontend/src/lib/build-identity.test.ts", + "bytes": 1372, + "sha256": "0be13e961c30add5d045bcf9fc7406761ec684d982bf1c86c0993446bc6dc893" + }, + { + "path": "frontend/src/lib/build-identity.ts", + "bytes": 1834, + "sha256": "0ed8997e10f757664c688c7e9555bdd720703b4ba073e1df934370668bcf2268" + }, + { + "path": "frontend/src/lib/model-management.test.ts", + "bytes": 4891, + "sha256": "431bd194cccddaf0a8df55d1e0d2f024081db63d09942a65d2f274778a5940ae" + }, + { + "path": "frontend/src/lib/model-management.ts", + "bytes": 1959, + "sha256": "b9fc6d1cfba3721fec7d8d1b6d78ef2a852ed22e18358b23c1e86737deeeb92b" + }, + { + "path": "frontend/src/lib/project-integration.ts", + "bytes": 2184, + "sha256": "954b0833c2d1e78d369cad90450600d69c28750cea1f47272a2b992cc995673b" + }, + { + "path": "frontend/src/lib/troubleshooting.test.ts", + "bytes": 12043, + "sha256": "f78e58800b1d1af215b6b0836cc88782f3faedd02a14239b15bf1cba10a7d6d0" + }, + { + "path": "frontend/src/lib/troubleshooting.ts", + "bytes": 22890, + "sha256": "75c38b8fd286042694a37955952c466b589b4d1ded9dc197bc693bdc8b15eb29" + }, + { + "path": "frontend/src/lib/user-language.ts", + "bytes": 6345, + "sha256": "0e3d634f07b2b7d6a6632282d75df3b63e31c007de584e2e7a4434e38dfdaa42" }, { "path": "frontend/src/main.tsx", - "bytes": 239, - "sha256": "a0d8cf21a990ece4cf836c1a252e0e63e1ffc326ee7f87efce1725b63ef7c8e7" + "bytes": 389, + "sha256": "0706ec06720e6db8c63f3df7b30416f061270efd13bdab8d51c7f2f54c40ba5b" + }, + { + "path": "frontend/src/model-management-types.ts", + "bytes": 2876, + "sha256": "3f7c5a7f62f07ef9af60bba99b4acc53dcf7a76beac0a252876a8cbfd654c81f" + }, + { + "path": "frontend/src/navigation.test.ts", + "bytes": 1748, + "sha256": "2e2f0a1218c7f5dc83c3230abfbbfaf81f501017ea1827e264e2abe4e1de5499" }, { "path": "frontend/src/navigation.ts", - "bytes": 5470, - "sha256": "1edefd5cd5b837cc61148e9cba27659a197a4aaf650af789e36a22c6753ac5d8" + "bytes": 7420, + "sha256": "4e4e4257dd656ad6cbf5a0208f48a7224838912ec37307a1784b339b7abe4d35" + }, + { + "path": "frontend/src/product-ux.css", + "bytes": 14428, + "sha256": "dcc655f2c20505f53126476d8d431ebc9d15a733ea5ecf7181723c90dd10bbd3" + }, + { + "path": "frontend/src/project-wizard.css", + "bytes": 4646, + "sha256": "bcfed2ef2001c6862adca7633b2c1df7107d24c471a2ac54d1e86b77820caad8" + }, + { + "path": "frontend/src/simple-workflows.css", + "bytes": 23661, + "sha256": "b773696b1f7050454a348c5d57dd20ece4371494da4dbe4aabfa27cbc3b0f98c" + }, + { + "path": "frontend/src/simple.css", + "bytes": 10565, + "sha256": "354ebbd7cea46fad093e5367767f4ec021eaea1744ea16ebb8e19a93bc34a46a" }, { "path": "frontend/src/styles.css", - "bytes": 98483, - "sha256": "8f607ef346b09b7c77d9618a09a6a6ed8d3471cadc27e5f5e5c92ed9f6bb23fa" + "bytes": 125852, + "sha256": "ca04602b000c36b0148de2d8ceb7ba6415b3ce3dafad788ffb25dbb9dd311c5c" }, { "path": "frontend/src/test/setup.ts", @@ -2164,13 +3475,13 @@ }, { "path": "frontend/src/types.ts", - "bytes": 43701, - "sha256": "efb34f6a6cf5dd9aeee4bd72ef3e9fcd3e3894ea55b8166db82006bd0dfaf873" + "bytes": 65025, + "sha256": "866011f26fccb4c185dedc8522132ebbc555f7d5056e4bf9a020d56e741a6262" }, { "path": "frontend/src/vite-env.d.ts", - "bytes": 38, - "sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5" + "bytes": 387, + "sha256": "edf8c4f302aa857945780ec02c2b063c4b5c530a950395b8a9d15d755f541fe8" }, { "path": "frontend/tsconfig.json", @@ -2179,8 +3490,8 @@ }, { "path": "frontend/vite.config.ts", - "bytes": 1019, - "sha256": "39196ecbd0c830b1bced59602aa4ab3ee6c52f38b547b3648ac584591ae03e26" + "bytes": 3027, + "sha256": "0f85bafc1a3033d574aafe0e4e2832ed1aaa5c31826a356764dc70500f1d03f3" }, { "path": "LICENSE", @@ -2189,18 +3500,18 @@ }, { "path": "node-agent/Dockerfile", - "bytes": 2269, - "sha256": "1357b603034e01caea72d4e758123719abc89a76e3054c4c361326efbef8c3f0" + "bytes": 5038, + "sha256": "2e94100a8bc3bd5040e9410cfd5a6082a138369cbaa4928bf5bd016afe7eed96" }, { "path": "node-agent/pyproject.toml", - "bytes": 1421, - "sha256": "c83a2c79a0c5f68edea89a6e9b60760da8453f54a8eaadfe164871533def275a" + "bytes": 1440, + "sha256": "6e7a73b4b211ae5f933fcfa8432b33f46aff1a9fdcaf142cdfc88446fc52f47a" }, { "path": "node-agent/src/modelforge_node_agent/__init__.py", - "bytes": 22, - "sha256": "bae7f854db53340f777cc86802eafd61a7f368a2459dccd8f87d524824ae4554" + "bytes": 27, + "sha256": "59619b05de8ed2b0cac4b2976b43a676fbac3d4c90f194a691aae3686eab96da" }, { "path": "node-agent/src/modelforge_node_agent/acquisition.py", @@ -2229,8 +3540,8 @@ }, { "path": "node-agent/src/modelforge_node_agent/state.py", - "bytes": 1645, - "sha256": "4091878754fc04ea51489f17a333887a070d7a98fc57cc852818c97d2ce175a4" + "bytes": 2106, + "sha256": "2931aa6d619b32002e3a3513eb442258cbafa527757538687e2786474c3bac8d" }, { "path": "node-agent/src/modelforge_node_agent/transport.py", @@ -2244,8 +3555,8 @@ }, { "path": "node-agent/tests/test_agent.py", - "bytes": 14180, - "sha256": "b888aa2e95d1a41173fd48e9c6c25db91e21592fabb899eba2338f16f11a932f" + "bytes": 15088, + "sha256": "9843205bd900b9037bac8d6d9c40913260c544df7d852f4810c16ba6d04fa3ab" }, { "path": "node-agent/tests/test_main.py", @@ -2259,23 +3570,23 @@ }, { "path": "README.md", - "bytes": 6078, - "sha256": "b1004ea4aa619be307b28aa77bfa06b832c8653fa6c690146b79e93a7706122c" + "bytes": 7383, + "sha256": "4c59c74659ade42f194b16ec8d9db7849e12e42c4cc29729af3b90657c104f94" }, { "path": "runtime-worker/Dockerfile", - "bytes": 1714, - "sha256": "e521bba10dcce424068eba51f4b8af97bf2ca4a01d403b69b264dddc930bd225" + "bytes": 2370, + "sha256": "aa3d92ce95d33633dae74327428cf40e414484fa4a2b6a76b2fef99d270e9d74" }, { "path": "runtime-worker/pyproject.toml", - "bytes": 2194, - "sha256": "821a45d7f2318cb73220556c5ceaf81e5ea479601e7a43ebfe20c8ab06aea3d2" + "bytes": 2319, + "sha256": "4eb4ff6b1568d46e66b6f4b4dfdd18a7d2aecae2c612689ab777b4d5c931b581" }, { "path": "runtime-worker/src/modelforge_runtime_worker/__init__.py", - "bytes": 22, - "sha256": "bae7f854db53340f777cc86802eafd61a7f368a2459dccd8f87d524824ae4554" + "bytes": 27, + "sha256": "59619b05de8ed2b0cac4b2976b43a676fbac3d4c90f194a691aae3686eab96da" }, { "path": "runtime-worker/src/modelforge_runtime_worker/adapters.py", @@ -2284,8 +3595,8 @@ }, { "path": "runtime-worker/src/modelforge_runtime_worker/contracts.py", - "bytes": 5716, - "sha256": "52a1d931e03253e25ad0df310536f6080ba9cda6d067ec3cd3121ce7d7da7484" + "bytes": 5756, + "sha256": "402517f7a7b93a8c6462a00d74bbb6236b77d7f1960ae9a79f394e00761e2fd9" }, { "path": "runtime-worker/src/modelforge_runtime_worker/main.py", @@ -2299,8 +3610,8 @@ }, { "path": "runtime-worker/src/modelforge_runtime_worker/serving.py", - "bytes": 18445, - "sha256": "17723e0ed89254a26f6f5ef7ce82a7ab05260c419768759415e7e3b02b508708" + "bytes": 18911, + "sha256": "9eabb0bd251989f7cf9fbebcc313e7e05a50572478bb8ebef4d47d5d869d9c86" }, { "path": "runtime-worker/src/modelforge_runtime_worker/settings.py", @@ -2309,18 +3620,18 @@ }, { "path": "runtime-worker/src/modelforge_runtime_worker/transport.py", - "bytes": 2949, - "sha256": "5c57a2e411c44aee6358870f2fc65c12938cb2f2b9cc8bb9fcb35a0dd3127ba3" + "bytes": 3684, + "sha256": "52f13a6a6eda85d11cbbb7efadd26f19280c80b23b90102b48c4a6b9166f68ac" }, { "path": "runtime-worker/src/modelforge_runtime_worker/worker.py", - "bytes": 9185, - "sha256": "bcfd7d63f5ff108fd14d7ea23c6ab42ce6a5e1c6ee44a5d92e258f87787b94a6" + "bytes": 12624, + "sha256": "15f817df260cf3db094bc9d6a508b5346e2ddbcd78f1a5d0d830351cafa747b8" }, { "path": "runtime-worker/tests/test_worker.py", - "bytes": 21438, - "sha256": "a3f4e00f03a206a04b1db2d3a25046dbcd1100a0dc0d62d4aea5c7f13e5e2f89" + "bytes": 24596, + "sha256": "fa67d8c73d132598a55e7becdf9a388505e8bf375991b4a733aa4364b87014d1" }, { "path": "scripts/bootstrap.py", @@ -2329,23 +3640,23 @@ }, { "path": "scripts/check.ps1", - "bytes": 1690, - "sha256": "ca6671659a7349a8caa2bba65e85facbfc8a716028b54198338f4cdbf8286e6f" + "bytes": 3561, + "sha256": "17f952b7f7f197155e3af194ebcc10d3b974b5f643715c5852694a5ee9113749" }, { "path": "scripts/check.sh", - "bytes": 2206, - "sha256": "118ec71a31bb919b71958a477c0b48e0c251b73c12c18eac9fe0be0d1c59b60a" + "bytes": 3380, + "sha256": "b5576ac74e1b4902a51c38da88c4811ac94c582e5588631c79015503ff5fb244" }, { "path": "scripts/export-public-source.mjs", - "bytes": 8885, - "sha256": "e8c3a3311c1528e4da5fdc2f1396dc95d188e26b5031591fc81da88447a658ac" + "bytes": 9540, + "sha256": "bde0c53d4ca1d20dcd4d283215fce82a9b374e45cc4d71d1093742d529d55a44" }, { "path": "scripts/generate_configuration_docs.py", - "bytes": 11151, - "sha256": "0fc3f0a702e68d0721a08b6e9f518ef07b5222f7387b8c5da3b4e1a299526c82" + "bytes": 11439, + "sha256": "42486cd0af5bef01b11d856c000d6369f9dcc946fea3b36312bc02c2b5667130" }, { "path": "scripts/m14_live_rehearsal.py", @@ -2394,29 +3705,64 @@ }, { "path": "scripts/modelforge_scheduled_backup.sh", - "bytes": 2661, - "sha256": "e7b5f8a9ad5b66a3fe97247731a7648aa164561229f257b92514d5f7578ce830" + "bytes": 2758, + "sha256": "0f7ca0ac3abfb9e1cdbc58af5fbbe9c4c2ab6bfa3bb5288f8a7013d7b386ce6c" + }, + { + "path": "scripts/operator_identity.py", + "bytes": 1380, + "sha256": "406b008066db0ba0234725efb8b48f3b0120ef795312ac20cd8e6d3f9cf3f6e6" + }, + { + "path": "scripts/operator_password.py", + "bytes": 677, + "sha256": "27a89c02e022f3d71e5b15c065a63a75fa2f1fa13b4185da4368c1c5a303ec77" }, { "path": "scripts/preflight.py", "bytes": 8997, "sha256": "8e1dedf444f128114c37a097a1e1cf70ed4bd6fb81aa4aa1c24ec322db4a7dac" }, + { + "path": "scripts/rc_isolation_preflight.py", + "bytes": 17813, + "sha256": "03f2a0b6534997cd09493d53b0c6c07cde1c3c2c12b1174a12f059c8d161dcd7" + }, + { + "path": "scripts/rc_readiness.py", + "bytes": 6811, + "sha256": "dd8cdced7f50a1c9926a65e0d203e8035946f58e462699d8bac6a1a6e33c801b" + }, { "path": "scripts/rc_server_acceptance.py", - "bytes": 15359, - "sha256": "0baef82d94137305acb773e9484e5729ae9ce928c352291ef15a70cca991299b" + "bytes": 18350, + "sha256": "6f013ec2c4925876a844c5bfa82da47a6d194e5e627620cbb5e8121742692ef9" + }, + { + "path": "scripts/rc_stack_retention.py", + "bytes": 16137, + "sha256": "fefea79831b84e2b989a74f6d566664815a02719fdd6088790b5d94ff7662677" }, { "path": "scripts/release_build.py", - "bytes": 19029, - "sha256": "1b32834ab4fa2270cf8d10905bb6fd74c2f00503f134878003a46e0ff6cbc20d" + "bytes": 34085, + "sha256": "716680b0dceca744a98b9a94fc529c7ed31fb0ce99861faef032027402987570" }, { "path": "scripts/release_image_acceptance.py", "bytes": 7747, "sha256": "77b2c28c3bd87386715e6aeb6d9503ed1154ccd6bb32d06abcd8a1d141421ddd" }, + { + "path": "scripts/security_gate.py", + "bytes": 164848, + "sha256": "61a865c3985b6b6e8266852fd1ff2d93ec6588afaf830eb6d3eb1ed2bad7c8b9" + }, + { + "path": "scripts/test_modelforge_scheduled_backup_auth.sh", + "bytes": 3449, + "sha256": "77d804f7d3b7512b3bbb4f44a355460f44ee24c115732f1111a0dd224bbfb430" + }, { "path": "scripts/upgrade.py", "bytes": 12360, @@ -2429,8 +3775,8 @@ }, { "path": "scripts/validate-public-source.mjs", - "bytes": 3702, - "sha256": "37ec733fcfe126dd595187d08b775007278216ec7b5f8afaf4e3dc62b41bca18" + "bytes": 4945, + "sha256": "1e936dec9e8648afe65c20b0bc64dd1f468aa9306fd50be8e8f9a0605c4a0ceb" }, { "path": "SECURITY.md", @@ -2439,8 +3785,8 @@ }, { "path": "VERSION", - "bytes": 6, - "sha256": "c62a5cc61ac4525935753f5ec284c0a052a4b1da4b3a3580dce2b7d77a2154ae" + "bytes": 11, + "sha256": "82d60e10e0d6a08ee84288108bcb9b15e933935160a975ccd55136660bf75ad7" } ] } diff --git a/README.md b/README.md index fcbbc9c..2c8ebcb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,26 @@ # ITWorx ModelForge +

One stable capability interface for models running on your own hardware.

+

Console tour · Workflow · Capabilities · Quick start · Architecture

+ ModelForge is a self-hosted control plane for running AI models on hardware you control. Applications ask for a stable capability such as `rag.embedding@1`; ModelForge selects an approved model revision, places it on an eligible compute node, and retains the evidence needed to explain and roll back that decision. -Current release: **1.2.1** +## Console tour + +![ModelForge operator console overview with synthetic model records](docs/screenshots/console-demo.png) + +*Overview: the operator's starting point for model and infrastructure status.* + +![ModelForge model library with two synthetic model records](docs/screenshots/models-demo.png) + +*Model library: inspect registered revisions and their details before planning a deployment.* + +Both images show the actual web console with synthetic API responses. No real operator, model, project or infrastructure data appear in them, and they do not imply a validated deployment. The current console uses Dutch interface labels. + +The current source line is **1.3.0-rc.1**. A source version does not by itself identify a qualified release or a running deployment; use the exact release evidence and deployment revision when operating an instance. The [publication guide](docs/PUBLICATION.md) explains the release boundary. ## What you get @@ -69,13 +84,15 @@ The caller does not need to know the model name, artifact path, runtime or GPU n ## Architecture -```text -Applications -> Capability Gateway -> Scheduler -> typed runtime workers - | ^ - v | - Control Plane <--- outbound node agents - | - PostgreSQL + Redis + verified artifacts +```mermaid +flowchart LR + Apps[Applications] --> Gateway[Capability gateway] + Gateway --> Scheduler[Placement scheduler] + Scheduler --> Workers[Typed runtime workers] + Agents[Outbound node agents] --> Control[Control plane] + Control --> Scheduler + Control --> DB[(PostgreSQL and Redis)] + Control --> Artifacts[Verified model artifacts] ``` Important boundaries: @@ -103,7 +120,7 @@ detail. | Operate and recover it | [Operations](docs/OPERATIONS.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) | | Review security | [Security](docs/SECURITY.md) · [Threat model](docs/security/THREAT_MODEL.md) | | Understand compatibility | [Compatibility](docs/COMPATIBILITY.md) | -| See release changes | [Changelog](CHANGELOG.md) · [1.2.1 release notes](docs/RELEASE_NOTES_v1.2.1.md) | +| See release changes | [Changelog](CHANGELOG.md) · [1.2.2 release notes](docs/RELEASE_NOTES_v1.2.2.md) | ## Repository layout diff --git a/VERSION b/VERSION index 23aa839..bf16dde 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.2 +1.3.0-rc.1 diff --git a/backend/Dockerfile b/backend/Dockerfile index 8155821..fcc1efb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,22 +25,24 @@ LABEL org.opencontainers.image.description="Capability-first local AI ModelOps a LABEL org.opencontainers.image.version="${MODELFORGE_VERSION}" LABEL org.opencontainers.image.revision="${MODELFORGE_COMMIT}" LABEL org.opencontainers.image.created="${MODELFORGE_BUILT_AT}" -LABEL org.opencontainers.image.source="https://git.example.com/example/modelforge.git" +LABEL org.opencontainers.image.source="https://git.example.com/example/modelforge" LABEL org.opencontainers.image.vendor="ITWorx" LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" WORKDIR /app -COPY pyproject.toml ./ -COPY src ./src -COPY alembic.ini ./ -COPY alembic ./alembic -# Upgrade the installer before it resolves anything: the pinned base image ships a pip -# carrying archive-extraction advisories. pip never runs at runtime, but a release image -# should not carry a known-vulnerable installer. -RUN pip install --no-cache-dir --upgrade pip "setuptools>=78.1.1" "msgpack>=1.2.1" \ - && pip install --no-cache-dir . \ +COPY config/security/build-toolchain.lock /tmp/modelforge-build-toolchain.lock +COPY backend/pyproject.toml ./ +COPY backend/src ./src +COPY backend/alembic.ini ./ +COPY backend/alembic ./alembic +# Build tooling is preinstalled from a fully resolved hash lock. Disabling PEP 517 isolation +# makes pip use those exact tools rather than fetching a second, unverified build environment. +RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: \ + --index-url https://pypi.org/simple -r /tmp/modelforge-build-toolchain.lock \ + && python -m pip install --no-cache-dir --no-build-isolation . \ && pip check \ - && python -m pip uninstall --yes pip setuptools + && python -m pip uninstall --yes pip setuptools wheel editables hatchling pathspec pluggy \ + tomlkit trove-classifiers RUN addgroup -S modelforge && adduser -S -G modelforge -h /app modelforge \ && mkdir -p /data/state /data/hf-cache /data/artifacts /data/quarantine \ @@ -49,4 +51,4 @@ RUN addgroup -S modelforge && adduser -S -G modelforge -h /app modelforge \ USER modelforge EXPOSE 8000 -CMD ["uvicorn", "modelforge_api.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "modelforge_api.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"] diff --git a/backend/alembic/versions/20260824_0001_m0_foundation.py b/backend/alembic/versions/20260824_0001_m0_foundation.py index 13ddf2a..345f8e6 100644 --- a/backend/alembic/versions/20260824_0001_m0_foundation.py +++ b/backend/alembic/versions/20260824_0001_m0_foundation.py @@ -19,6 +19,49 @@ def upgrade() -> None: # The declarative metadata is the reviewed M0 schema contract. create_all is # used only from this baseline migration; later changes require explicit ops. Base.metadata.create_all(bind=op.get_bind(), checkfirst=False) + # BenchmarkRun was part of M0. The evolving metadata also contains RC-LAB-02's later + # capability-target, actor/idempotency and environment fields, so rebuild its original + # shape here; migration 0039 adds those fields at the correct historical boundary. + op.drop_table("benchmark_runs") + op.create_table( + "benchmark_runs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("deployment_id", sa.Uuid(), nullable=False), + sa.Column("suite_id", sa.Uuid(), nullable=False), + sa.Column("model_revision_sha", sa.String(length=64), nullable=False), + sa.Column("artifact_sha256", sa.String(length=64), nullable=False), + sa.Column("runtime_type", sa.String(length=32), nullable=False), + sa.Column("runtime_version", sa.String(length=128), nullable=False), + sa.Column("runtime_image_digest", sa.String(length=71), nullable=True), + sa.Column("launch_arguments", sa.JSON(), nullable=False), + sa.Column("suite_revision", sa.String(length=128), nullable=False), + sa.Column("dataset_revision", sa.String(length=255), nullable=False), + sa.Column("environment_fingerprint", sa.JSON(), nullable=False), + sa.Column("environment_digest", sa.String(length=64), nullable=False), + sa.Column("seed", sa.Integer(), nullable=True), + sa.Column("results", sa.JSON(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("comparable", sa.Boolean(), nullable=False), + sa.Column("stale_reason", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["deployment_id"], ["deployments.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["suite_id"], ["benchmark_suites.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_benchmark_runs_deployment_id", "benchmark_runs", ["deployment_id"]) + op.create_index("ix_benchmark_runs_suite_id", "benchmark_runs", ["suite_id"]) + op.create_index("ix_benchmark_runs_artifact_sha256", "benchmark_runs", ["artifact_sha256"]) + op.create_index( + "ix_benchmark_runs_environment_digest", "benchmark_runs", ["environment_digest"] + ) + op.create_index( + "ix_benchmark_runs_deployment_completed", + "benchmark_runs", + ["deployment_id", "completed_at"], + ) + op.drop_constraint("ck_capability_serving_generation", "capability_contracts", type_="check") + op.drop_column("capability_contracts", "serving_generation") # Freeze the original M0 shape even though the declarative metadata evolves. # Remove newer foreign-key columns before dropping the tables they target. for column in ( @@ -36,6 +79,27 @@ def upgrade() -> None: # Later milestone tables are present in the evolving declarative metadata # used to build this baseline. Drop dependants before their M5/M0 targets # so a fresh upgrade still reconstructs each historical revision exactly. + "model_change_cases", + "selected_model_comparisons", + "external_creative_action_attestations", + "external_creative_profile_observations", + "external_creative_resources", + "external_creative_allocations", + "model_update_notices", + "model_update_watches", + "portfolio_repository_observations", + "portfolio_reconciliation_runs", + "platform_secret_activations", + "platform_secret_tests", + "platform_secret_versions", + "platform_secret_state", + "managed_operator_credential_activations", + "managed_operator_credentials", + "managed_operator_identities", + "managed_operator_state", + "configuration_revisions", + "project_onboarding", + "configuration_state", "audit_chain_heads", "node_decommission_operations", # M15 recovery tables come first: artifact_recovery_operations references @@ -50,6 +114,8 @@ def upgrade() -> None: "recovery_asset_records", "recovery_policy_revisions", "incident_timeline_events", + # The M37 outbox restricts deletion of its alert history parent. + "alert_notification_deliveries", "alert_history_events", "operational_alerts", "operational_incidents", @@ -107,6 +173,9 @@ def upgrade() -> None: "project_evaluation_bindings", "serving_gpu_leases", "serving_jobs", + # M38 durable LAB history owns a restrictive reference to gateway_requests; + # drop it before the M0 gateway table while reconstructing the historical baseline. + "lab_execution_runs", "gateway_requests", "residency_allocations", "service_credentials", diff --git a/backend/alembic/versions/20260830_0024_audit_chain_checkpoint.py b/backend/alembic/versions/20260830_0024_audit_chain_checkpoint.py index bc300f8..ec88418 100644 --- a/backend/alembic/versions/20260830_0024_audit_chain_checkpoint.py +++ b/backend/alembic/versions/20260830_0024_audit_chain_checkpoint.py @@ -581,7 +581,7 @@ def _remove_postgres_audit_boundary(connection: Connection) -> None: "drop trigger if exists trg_modelforge_audit_head_owner on public.audit_chain_heads; " "drop trigger if exists trg_modelforge_audit_head_truncate_owner " "on public.audit_chain_heads; " - "drop function if exists modelforge_audit.append_event_v2(" + "drop function if exists modelforge_audit.append_event_v2(" "uuid, timestamptz, text, text, text, text, text, text, text, jsonb, " "bigint, bigint, text, text, bigint, bigint, text); " "drop function if exists modelforge_audit.enforce_owner_mutation(); " diff --git a/backend/alembic/versions/20260907_0025_configuration.py b/backend/alembic/versions/20260907_0025_configuration.py new file mode 100644 index 0000000..e585b45 --- /dev/null +++ b/backend/alembic/versions/20260907_0025_configuration.py @@ -0,0 +1,46 @@ +"""Durable operator configuration and revision history. + +Revision ID: 20260907_0025 +Revises: 20260830_0024 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260907_0025" +down_revision = "20260830_0024" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "configuration_state", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("overrides", sa.JSON(), nullable=False), + sa.CheckConstraint("id = 1"), + sa.CheckConstraint("revision >= 0"), + ) + op.create_table( + "configuration_revisions", + sa.Column("revision", sa.Integer(), primary_key=True), + sa.Column("previous_revision", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor", sa.String(255), nullable=False), + sa.Column("correlation_id", sa.String(255), nullable=False), + sa.Column("overrides", sa.JSON(), nullable=False), + sa.Column("preview_digest", sa.String(64), nullable=False), + ) + if op.get_bind().dialect.name == "postgresql": + op.execute("GRANT SELECT, INSERT, UPDATE ON configuration_state TO modelforge_runtime") + op.execute("REVOKE UPDATE, DELETE, TRUNCATE ON configuration_revisions FROM modelforge_runtime") + op.execute("GRANT SELECT, INSERT ON configuration_revisions TO modelforge_runtime") + + +def downgrade() -> None: + if op.get_bind().execute(sa.text("SELECT count(*) FROM configuration_revisions")).scalar_one(): + raise RuntimeError("Configuration history exists; recover the pre-migration backup instead.") + op.drop_table("configuration_revisions") + op.drop_table("configuration_state") diff --git a/backend/alembic/versions/20260908_0026_project_onboarding.py b/backend/alembic/versions/20260908_0026_project_onboarding.py new file mode 100644 index 0000000..0b669ca --- /dev/null +++ b/backend/alembic/versions/20260908_0026_project_onboarding.py @@ -0,0 +1,51 @@ +"""Project onboarding and bounded credential overlap. + +Revision ID: 20260908_0026 +Revises: 20260907_0025 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260908_0026" +down_revision = "20260907_0025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "project_onboarding", + sa.Column("project_id", sa.Uuid(), sa.ForeignKey("projects.id", ondelete="RESTRICT"), primary_key=True), + sa.Column("owner", sa.String(128), nullable=False), + sa.Column("environment", sa.String(32), nullable=False), + sa.Column("labels", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.add_column("service_credentials", sa.Column("superseded_at", sa.DateTime(timezone=True))) + op.drop_index("uq_active_service_client_credential", table_name="service_credentials") + op.create_index( + "uq_active_service_client_credential", "service_credentials", ["service_client_id"], + unique=True, + postgresql_where=sa.text("revoked_at IS NULL AND superseded_at IS NULL"), + sqlite_where=sa.text("revoked_at IS NULL AND superseded_at IS NULL"), + ) + if op.get_bind().dialect.name == "postgresql": + op.execute("GRANT SELECT, INSERT ON project_onboarding TO modelforge_runtime") + op.execute("REVOKE UPDATE, DELETE, TRUNCATE ON project_onboarding FROM modelforge_runtime") + + +def downgrade() -> None: + connection = op.get_bind() + if connection.execute(sa.text("SELECT count(*) FROM project_onboarding")).scalar_one() or connection.execute( + sa.text("SELECT count(*) FROM service_credentials WHERE superseded_at IS NOT NULL") + ).scalar_one(): + raise RuntimeError("Onboarding or rotation history exists; recover the pre-migration backup instead.") + op.drop_index("uq_active_service_client_credential", table_name="service_credentials") + op.create_index( + "uq_active_service_client_credential", "service_credentials", ["service_client_id"], + unique=True, postgresql_where=sa.text("revoked_at IS NULL"), sqlite_where=sa.text("revoked_at IS NULL"), + ) + op.drop_column("service_credentials", "superseded_at") + op.drop_table("project_onboarding") diff --git a/backend/alembic/versions/20260908_0027_managed_operator_identities.py b/backend/alembic/versions/20260908_0027_managed_operator_identities.py new file mode 100644 index 0000000..d5e19e5 --- /dev/null +++ b/backend/alembic/versions/20260908_0027_managed_operator_identities.py @@ -0,0 +1,60 @@ +"""Managed personal identities with recovery-cleared credential admission. + +Revision ID: 20260908_0027 +Revises: 20260908_0026 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "20260908_0027" +down_revision = "20260908_0026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table("managed_operator_state", + sa.Column("singleton_id", sa.Integer(), primary_key=True), + sa.Column("revision", sa.Integer(), nullable=False), + sa.CheckConstraint("singleton_id = 1", name="ck_managed_operator_singleton"), + sa.CheckConstraint("revision >= 0", name="ck_managed_operator_revision")) + op.execute("INSERT INTO managed_operator_state (singleton_id, revision) VALUES (1, 0)") + op.create_table("managed_operator_identities", + sa.Column("slug", sa.String(63), primary_key=True), + sa.Column("role", sa.String(16), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("role IN ('viewer','operator','admin')", name="ck_managed_operator_role"), + sa.CheckConstraint("version >= 1", name="ck_managed_operator_version")) + op.create_table("managed_operator_credentials", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("identity_slug", sa.String(63), sa.ForeignKey("managed_operator_identities.slug", ondelete="RESTRICT"), nullable=False), + sa.Column("token_sha256", sa.String(64), nullable=False, unique=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True)), + sa.Column("superseded_at", sa.DateTime(timezone=True)), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False)) + op.create_index("ix_managed_operator_credentials_identity_slug", "managed_operator_credentials", ["identity_slug"]) + op.create_index("uq_managed_operator_current", "managed_operator_credentials", ["identity_slug"], unique=True, + postgresql_where=sa.text("revoked_at IS NULL AND superseded_at IS NULL"), + sqlite_where=sa.text("revoked_at IS NULL AND superseded_at IS NULL")) + op.create_table("managed_operator_credential_activations", + sa.Column("credential_id", sa.Uuid(), sa.ForeignKey("managed_operator_credentials.id", ondelete="RESTRICT"), primary_key=True), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=False)) + if op.get_bind().dialect.name == "postgresql": + op.execute("GRANT SELECT, INSERT, UPDATE ON managed_operator_state, managed_operator_identities, managed_operator_credentials TO modelforge_runtime") + op.execute("GRANT SELECT, INSERT, DELETE ON managed_operator_credential_activations TO modelforge_runtime") + op.execute("REVOKE DELETE, TRUNCATE ON managed_operator_state, managed_operator_identities, managed_operator_credentials FROM modelforge_runtime") + + +def downgrade() -> None: + connection = op.get_bind() + if connection.execute(sa.text("SELECT revision FROM managed_operator_state WHERE singleton_id = 1")).scalar_one() != 0 or connection.execute(sa.text("SELECT count(*) FROM managed_operator_identities")).scalar_one(): + raise RuntimeError("Managed identity history exists; recover the pre-migration backup instead.") + op.drop_table("managed_operator_credential_activations") + op.drop_table("managed_operator_credentials") + op.drop_table("managed_operator_identities") + op.drop_table("managed_operator_state") diff --git a/backend/alembic/versions/20260908_0028_platform_secret_slots.py b/backend/alembic/versions/20260908_0028_platform_secret_slots.py new file mode 100644 index 0000000..091fd67 --- /dev/null +++ b/backend/alembic/versions/20260908_0028_platform_secret_slots.py @@ -0,0 +1,56 @@ +"""Encrypted HF control-plane secret history and recovery-cleared admission. + +Revision ID: 20260908_0028 +Revises: 20260908_0027 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "20260908_0028" +down_revision = "20260908_0027" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table("platform_secret_state", + sa.Column("singleton_id", sa.Integer(), primary_key=True), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("initialized", sa.Boolean(), nullable=False), + sa.CheckConstraint("singleton_id = 1", name="ck_secret_state_singleton"), + sa.CheckConstraint("revision >= 0", name="ck_secret_state_revision")) + op.execute("INSERT INTO platform_secret_state (singleton_id, revision, initialized) VALUES (1, 0, false)") + op.create_table("platform_secret_versions", + sa.Column("version", sa.Integer(), primary_key=True, autoincrement=False), + sa.Column("key_id", sa.String(64), nullable=False), + sa.Column("secret_ciphertext", sa.Text(), nullable=False), + sa.Column("secret_nonce", sa.String(32), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True)), + sa.CheckConstraint("version > 0", name="ck_secret_version_positive")) + op.create_table("platform_secret_tests", + sa.Column("version", sa.Integer(), sa.ForeignKey("platform_secret_versions.version", ondelete="RESTRICT"), primary_key=True), + sa.Column("passed", sa.Boolean(), nullable=False), + sa.Column("tested_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("code", sa.String(64), nullable=False)) + op.create_table("platform_secret_activations", + sa.Column("singleton_id", sa.Integer(), primary_key=True), + sa.Column("version", sa.Integer(), sa.ForeignKey("platform_secret_versions.version", ondelete="RESTRICT"), nullable=False), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("singleton_id = 1", name="ck_secret_activation_singleton")) + if op.get_bind().dialect.name == "postgresql": + op.execute("REVOKE ALL ON platform_secret_state, platform_secret_versions, platform_secret_tests, platform_secret_activations FROM modelforge_runtime") + op.execute("GRANT SELECT, UPDATE ON platform_secret_state TO modelforge_runtime") + op.execute("GRANT SELECT, INSERT ON platform_secret_versions TO modelforge_runtime") + op.execute("GRANT UPDATE (revoked_at) ON platform_secret_versions TO modelforge_runtime") + op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON platform_secret_tests, platform_secret_activations TO modelforge_runtime") + + +def downgrade() -> None: + connection = op.get_bind() + state = connection.execute(sa.text("SELECT revision, initialized FROM platform_secret_state WHERE singleton_id=1")).one() + if state.revision != 0 or state.initialized or connection.scalar(sa.text("SELECT count(*) FROM platform_secret_versions")): + raise RuntimeError("Secret slot history exists; recover the pre-migration backup instead.") + for table in ("platform_secret_activations", "platform_secret_tests", "platform_secret_versions", "platform_secret_state"): + op.drop_table(table) diff --git a/backend/alembic/versions/20260909_0029_external_creative_coordination.py b/backend/alembic/versions/20260909_0029_external_creative_coordination.py new file mode 100644 index 0000000..ba77edc --- /dev/null +++ b/backend/alembic/versions/20260909_0029_external_creative_coordination.py @@ -0,0 +1,57 @@ +"""External creative GPU coordination. + +Revision ID: 20260909_0029 +Revises: 20260908_0028 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260909_0029" +down_revision = "20260908_0028" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "external_creative_allocations", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("service_client_id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.Uuid(), nullable=False), + sa.Column("accelerator_id", sa.Uuid()), + sa.Column("request_id", sa.String(128), nullable=False), + sa.Column("idempotency_key", sa.String(128), nullable=False), + sa.Column("request_sha256", sa.String(64), nullable=False), + sa.Column("workload", sa.String(16), nullable=False), + sa.Column("resource_ref", sa.String(255), nullable=False), + sa.Column("provider_resource_id", sa.String(128), nullable=False), + sa.Column("required_vram_bytes", sa.BigInteger(), nullable=False), + sa.Column("priority", sa.String(32), nullable=False), + sa.Column("ttl_seconds", sa.Integer(), nullable=False), + sa.Column("state", sa.String(32), nullable=False), + sa.Column("decision_code", sa.String(64), nullable=False), + sa.Column("decision_evidence", sa.JSON(), nullable=False), + sa.Column("required_actions", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("acquired_at", sa.DateTime(timezone=True)), + sa.Column("heartbeat_at", sa.DateTime(timezone=True)), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("uncertain_at", sa.DateTime(timezone=True)), + sa.Column("released_at", sa.DateTime(timezone=True)), + sa.CheckConstraint("required_vram_bytes > 0", name="ck_external_creative_vram_positive"), + sa.CheckConstraint("ttl_seconds >= 30 AND ttl_seconds <= 3600", name="ck_external_creative_ttl"), + sa.ForeignKeyConstraint(["service_client_id"], ["service_clients.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["accelerator_id"], ["accelerators.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("service_client_id", "idempotency_key", name="uq_external_creative_idempotency"), + ) + for column in ("service_client_id", "project_id", "accelerator_id", "request_id", "state", "expires_at"): + op.create_index(f"ix_external_creative_allocations_{column}", "external_creative_allocations", [column]) + + +def downgrade() -> None: + op.drop_table("external_creative_allocations") diff --git a/backend/alembic/versions/20260909_0030_model_update_inbox.py b/backend/alembic/versions/20260909_0030_model_update_inbox.py new file mode 100644 index 0000000..7e7f784 --- /dev/null +++ b/backend/alembic/versions/20260909_0030_model_update_inbox.py @@ -0,0 +1,43 @@ +"""Explicit selected-model upstream watches and deduplicated revision notices.""" +import sqlalchemy as sa + +from alembic import op + +revision = "20260909_0030" +down_revision = "20260909_0029" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table("model_update_watches", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("model_id", sa.Uuid(), sa.ForeignKey("models.id", ondelete="RESTRICT"), nullable=False), + sa.Column("baseline_revision_id", sa.Uuid(), sa.ForeignKey("model_revisions.id", ondelete="RESTRICT"), nullable=False), + sa.Column("baseline_commit_sha", sa.String(64), nullable=False), + sa.Column("upstream_ref", sa.String(255), nullable=False), + sa.Column("last_checked_at", sa.DateTime(timezone=True)), + sa.Column("last_error_code", sa.String(64)), + sa.Column("check_until", sa.DateTime(timezone=True)), + sa.Column("check_token", sa.Uuid()), + sa.UniqueConstraint("model_id", "upstream_ref", name="uq_model_watch_ref")) + op.create_table("model_update_notices", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("watch_id", sa.Uuid(), sa.ForeignKey("model_update_watches.id", ondelete="RESTRICT"), nullable=False), + sa.Column("model_id", sa.Uuid(), sa.ForeignKey("models.id", ondelete="RESTRICT"), nullable=False), + sa.Column("baseline_commit_sha", sa.String(64), nullable=False), + sa.Column("candidate_revision_id", sa.Uuid(), sa.ForeignKey("model_revisions.id", ondelete="RESTRICT"), nullable=False), + sa.Column("candidate_commit_sha", sa.String(64), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("discovered_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("watch_id", "candidate_commit_sha", name="uq_model_notice_commit"), + sa.CheckConstraint("status IN ('new', 'dismissed')", name="ck_model_notice_status")) + if op.get_bind().dialect.name == "postgresql": + op.execute("GRANT SELECT, INSERT, UPDATE ON model_update_watches, model_update_notices TO modelforge_runtime") + + +def downgrade() -> None: + if op.get_bind().scalar(sa.text("SELECT count(*) FROM model_update_watches")): + raise RuntimeError("Model update history exists; restore the pre-migration backup instead.") + op.drop_table("model_update_notices") + op.drop_table("model_update_watches") diff --git a/backend/alembic/versions/20260909_0031_external_creative_governance.py b/backend/alembic/versions/20260909_0031_external_creative_governance.py new file mode 100644 index 0000000..ca523d2 --- /dev/null +++ b/backend/alembic/versions/20260909_0031_external_creative_governance.py @@ -0,0 +1,114 @@ +"""Govern external creative resources, measurements, and action attestations. + +Revision ID: 20260909_0031 +Revises: 20260909_0030 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260909_0031" +down_revision = "20260909_0030" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "external_creative_resources", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column( + "project_id", + sa.Uuid(), + sa.ForeignKey("projects.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("resource_ref", sa.String(255), nullable=False), + sa.Column("workload", sa.String(16), nullable=False), + sa.Column("provider_resource_id", sa.String(128), nullable=False), + sa.Column("approved", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("configured_vram_bytes", sa.BigInteger(), nullable=False), + sa.Column("measured_peak_vram_bytes", sa.BigInteger()), + sa.Column("measurement_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.UniqueConstraint( + "project_id", + "resource_ref", + "workload", + "provider_resource_id", + name="uq_external_creative_resource_binding", + ), + sa.CheckConstraint("configured_vram_bytes > 0", name="ck_external_resource_vram_positive"), + ) + op.create_index( + "ix_external_creative_resources_project", + "external_creative_resources", + ["project_id", "approved"], + ) + op.create_table( + "external_creative_action_attestations", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column( + "allocation_id", + sa.Uuid(), + sa.ForeignKey("external_creative_allocations.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("action", sa.String(64), nullable=False), + sa.Column("before_vram_bytes", sa.BigInteger(), nullable=False), + sa.Column("after_vram_bytes", sa.BigInteger(), nullable=False), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("validated", sa.Boolean(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.UniqueConstraint( + "allocation_id", "action", name="uq_external_creative_action_attestation" + ), + sa.CheckConstraint( + "before_vram_bytes >= 0 AND after_vram_bytes >= 0", + name="ck_external_action_vram_nonnegative", + ), + ) + op.create_table( + "external_creative_profile_observations", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column( + "allocation_id", + sa.Uuid(), + sa.ForeignKey("external_creative_allocations.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("resource_ref", sa.String(255), nullable=False), + sa.Column("provider_resource_id", sa.String(128), nullable=False), + sa.Column("workload", sa.String(16), nullable=False), + sa.Column("observed_peak_vram_bytes", sa.BigInteger(), nullable=False), + sa.Column("reserved_vram_bytes", sa.BigInteger(), nullable=False), + sa.Column("duration_ms", sa.BigInteger(), nullable=False), + sa.Column("outcome", sa.String(16), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.UniqueConstraint( + "allocation_id", name="uq_external_creative_profile_observation_allocation" + ), + sa.CheckConstraint( + "observed_peak_vram_bytes > 0 AND reserved_vram_bytes > 0", + name="ck_external_profile_vram_positive", + ), + sa.CheckConstraint( + "duration_ms >= 0 AND duration_ms <= 604800000", name="ck_external_profile_duration" + ), + ) + + +def downgrade() -> None: + op.drop_table("external_creative_profile_observations") + op.drop_table("external_creative_action_attestations") + op.drop_index( + "ix_external_creative_resources_project", table_name="external_creative_resources" + ) + op.drop_table("external_creative_resources") diff --git a/backend/alembic/versions/20260909_0032_audio_creative_workloads.py b/backend/alembic/versions/20260909_0032_audio_creative_workloads.py new file mode 100644 index 0000000..5d4f9d7 --- /dev/null +++ b/backend/alembic/versions/20260909_0032_audio_creative_workloads.py @@ -0,0 +1,44 @@ +"""Allow explicit audio and film creative workload names. + +Revision ID: 20260909_0032 +Revises: 20260909_0031 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260909_0032" +down_revision = "20260909_0031" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + for table in ( + "external_creative_allocations", + "external_creative_resources", + "external_creative_profile_observations", + ): + with op.batch_alter_table(table) as batch: + batch.alter_column( + "workload", + existing_type=sa.String(16), + type_=sa.String(32), + existing_nullable=False, + ) + + +def downgrade() -> None: + for table in ( + "external_creative_profile_observations", + "external_creative_resources", + "external_creative_allocations", + ): + with op.batch_alter_table(table) as batch: + batch.alter_column( + "workload", + existing_type=sa.String(32), + type_=sa.String(16), + existing_nullable=False, + ) diff --git a/backend/alembic/versions/20260909_0033_deployed_hub_convergence.py b/backend/alembic/versions/20260909_0033_deployed_hub_convergence.py new file mode 100644 index 0000000..1626721 --- /dev/null +++ b/backend/alembic/versions/20260909_0033_deployed_hub_convergence.py @@ -0,0 +1,154 @@ +"""Persist one explicitly delegated current/candidate comparison per project binding.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260909_0033" +down_revision = "20260909_0032" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "model_update_watches", + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "model_update_watches", + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + ) + op.create_table( + "selected_model_comparisons", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column( + "project_binding_id", + sa.Uuid(), + sa.ForeignKey("project_bindings.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "consumer_client_id", + sa.Uuid(), + sa.ForeignKey("service_clients.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "suite_revision_id", + sa.Uuid(), + sa.ForeignKey("evaluation_suite_revisions.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "current_deployment_id", + sa.Uuid(), + sa.ForeignKey("capability_deployments.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "candidate_deployment_id", + sa.Uuid(), + sa.ForeignKey("capability_deployments.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "candidate_route_id", + sa.Uuid(), + sa.ForeignKey("capability_experiment_routes.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("idempotency_key", sa.String(128), nullable=False), + sa.Column("request_digest", sa.String(64), nullable=False), + sa.Column("actor_id", sa.String(128), nullable=False), + sa.Column("pins", sa.JSON(), nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("current_completed_cases", sa.Integer(), nullable=False, server_default="0"), + sa.Column("candidate_completed_cases", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expected_cases", sa.Integer(), nullable=False), + sa.Column("claim_token_hash", sa.String(64)), + sa.Column("claim_expires_at", sa.DateTime(timezone=True)), + sa.Column("deadline_at", sa.DateTime(timezone=True)), + sa.Column("claimed_at", sa.DateTime(timezone=True)), + sa.Column("cancel_requested_at", sa.DateTime(timezone=True)), + sa.Column("finished_at", sa.DateTime(timezone=True)), + sa.Column("error_code", sa.String(64)), + sa.Column("result_digest", sa.String(64)), + sa.Column( + "baseline_run_id", sa.Uuid(), sa.ForeignKey("evaluation_runs.id", ondelete="RESTRICT") + ), + sa.Column( + "candidate_run_id", sa.Uuid(), sa.ForeignKey("evaluation_runs.id", ondelete="RESTRICT") + ), + sa.Column( + "evaluation_comparison_id", + sa.Uuid(), + sa.ForeignKey("evaluation_comparisons.id", ondelete="RESTRICT"), + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint( + "project_binding_id", "idempotency_key", name="uq_selected_comparison_key" + ), + sa.CheckConstraint( + "status IN ('pending', 'running', 'cancel_requested', 'completed', 'failed', 'cancelled', 'interrupted')", + name="ck_selected_comparison_status", + ), + sa.CheckConstraint("expected_cases BETWEEN 1 AND 100", name="ck_selected_comparison_cases"), + sa.CheckConstraint( + "current_completed_cases BETWEEN 0 AND expected_cases AND candidate_completed_cases BETWEEN 0 AND expected_cases", + name="ck_selected_comparison_progress", + ), + sa.CheckConstraint("version > 0", name="ck_selected_comparison_version"), + sa.CheckConstraint( + "current_deployment_id <> candidate_deployment_id", + name="ck_selected_comparison_distinct_targets", + ), + ) + op.create_index( + "uq_selected_comparison_active_binding", + "selected_model_comparisons", + ["project_binding_id"], + unique=True, + postgresql_where=sa.text("status IN ('pending', 'running', 'cancel_requested')"), + sqlite_where=sa.text("status IN ('pending', 'running', 'cancel_requested')"), + ) + if op.get_bind().dialect.name == "postgresql": + op.execute( + "REVOKE DELETE, TRUNCATE ON model_update_watches, model_update_notices FROM modelforge_runtime" + ) + op.execute( + "GRANT SELECT, INSERT, UPDATE ON model_update_watches, model_update_notices TO modelforge_runtime" + ) + op.execute("REVOKE DELETE, TRUNCATE ON selected_model_comparisons FROM modelforge_runtime") + op.execute( + "GRANT SELECT, INSERT, UPDATE ON selected_model_comparisons TO modelforge_runtime" + ) + + +def downgrade() -> None: + # Earlier deployed migrations have destructive downgrades. Never permit this + # release to cross back into that graph while authoritative history exists. + for table in ( + "selected_model_comparisons", + "model_update_watches", + "model_update_notices", + "external_creative_allocations", + "external_creative_resources", + "external_creative_action_attestations", + "external_creative_profile_observations", + ): + if op.get_bind().scalar(sa.select(sa.func.count()).select_from(sa.table(table))): + raise RuntimeError( + "Control-plane history exists; restore the pre-migration backup instead." + ) + op.drop_index("uq_selected_comparison_active_binding", table_name="selected_model_comparisons") + op.drop_table("selected_model_comparisons") + op.drop_column("model_update_watches", "version") + op.drop_column("model_update_watches", "enabled") + # Deliberately retain the safer runtime privilege restrictions on empty old tables. diff --git a/backend/alembic/versions/20260910_0034_approved_serving_transition.py b/backend/alembic/versions/20260910_0034_approved_serving_transition.py new file mode 100644 index 0000000..6bcddd5 --- /dev/null +++ b/backend/alembic/versions/20260910_0034_approved_serving_transition.py @@ -0,0 +1,28 @@ +"""Fence approved stable serving transitions and rollback against ABA changes.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260910_0034" +down_revision = "20260909_0033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("capability_contracts", sa.Column( + "serving_generation", sa.BigInteger(), nullable=False, server_default="0")) + with op.batch_alter_table("capability_contracts") as batch: + batch.create_check_constraint("ck_capability_serving_generation", "serving_generation >= 0") + + +def downgrade() -> None: + contracts = sa.table("capability_contracts", sa.column("serving_generation")) + if op.get_bind().scalar(sa.select(sa.func.count()).select_from(contracts).where( + contracts.c.serving_generation != 0 + )): + raise RuntimeError("Serving route history exists; restore the pre-migration backup instead.") + with op.batch_alter_table("capability_contracts") as batch: + batch.drop_constraint("ck_capability_serving_generation", type_="check") + batch.drop_column("serving_generation") diff --git a/backend/alembic/versions/20260911_0035_user_model_change_cases.py b/backend/alembic/versions/20260911_0035_user_model_change_cases.py new file mode 100644 index 0000000..168967c --- /dev/null +++ b/backend/alembic/versions/20260911_0035_user_model_change_cases.py @@ -0,0 +1,95 @@ +"""Add the user-facing model replacement case without duplicating lifecycle truth.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260911_0035" +down_revision = "20260910_0034" +branch_labels = None +depends_on = None + + +STATUSES = ( + "searching", + "found", + "source_checked", + "downloaded", + "ready_to_test", + "tested", + "comparison_available", + "migration_required", + "ready_to_activate", + "active", + "checked", + "rolled_back", + "rejected", +) + + +def upgrade() -> None: + op.create_table( + "model_change_cases", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("project_binding_id", sa.Uuid(), nullable=False), + sa.Column("current_deployment_id", sa.Uuid(), nullable=False), + sa.Column("candidate_model_id", sa.Uuid(), nullable=True), + sa.Column("candidate_revision_id", sa.Uuid(), nullable=True), + sa.Column("candidate_artifact_set_id", sa.Uuid(), nullable=True), + sa.Column("candidate_deployment_id", sa.Uuid(), nullable=True), + sa.Column("discovery_assessment_id", sa.Uuid(), nullable=True), + sa.Column("download_plan_id", sa.Uuid(), nullable=True), + sa.Column("artifact_job_id", sa.Uuid(), nullable=True), + sa.Column("selected_comparison_id", sa.Uuid(), nullable=True), + sa.Column("migration_plan_id", sa.Uuid(), nullable=True), + sa.Column("lifecycle_plan_id", sa.Uuid(), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False, server_default="searching"), + sa.Column("status_reason", sa.Text(), nullable=False, server_default=""), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("created_by", sa.String(length=255), nullable=False), + sa.Column("updated_by", sa.String(length=255), nullable=False), + sa.Column("context", sa.JSON(), nullable=False), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("checked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["project_binding_id"], ["project_bindings.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["current_deployment_id"], ["capability_deployments.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["candidate_model_id"], ["models.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["candidate_revision_id"], ["model_revisions.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["candidate_artifact_set_id"], ["artifact_sets.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["candidate_deployment_id"], ["capability_deployments.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["discovery_assessment_id"], ["discovery_candidate_assessments.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["download_plan_id"], ["download_plans.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["artifact_job_id"], ["artifact_jobs.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["selected_comparison_id"], ["selected_model_comparisons.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["migration_plan_id"], ["migration_plans.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["lifecycle_plan_id"], ["lifecycle_promotion_plans.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + sa.CheckConstraint("version >= 1", name="ck_model_change_case_version"), + sa.CheckConstraint( + "status IN (" + ",".join(repr(value) for value in STATUSES) + ")", + name="ck_model_change_case_status", + ), + ) + op.create_index("ix_model_change_cases_binding", "model_change_cases", ["project_binding_id"]) + op.create_index("ix_model_change_cases_status", "model_change_cases", ["status"]) + op.create_index( + "uq_model_change_cases_open_binding", + "model_change_cases", + ["project_binding_id"], + unique=True, + postgresql_where=sa.text("closed_at IS NULL"), + sqlite_where=sa.text("closed_at IS NULL"), + ) + + +def downgrade() -> None: + changes = sa.table("model_change_cases", sa.column("id")) + if op.get_bind().scalar(sa.select(sa.func.count()).select_from(changes)): + raise RuntimeError("Model change cases exist; restore the pre-migration backup instead.") + op.drop_index("uq_model_change_cases_open_binding", table_name="model_change_cases") + op.drop_index("ix_model_change_cases_status", table_name="model_change_cases") + op.drop_index("ix_model_change_cases_binding", table_name="model_change_cases") + op.drop_table("model_change_cases") diff --git a/backend/alembic/versions/20260923_0036_portfolio_repository_reconciliation.py b/backend/alembic/versions/20260923_0036_portfolio_repository_reconciliation.py new file mode 100644 index 0000000..247177f --- /dev/null +++ b/backend/alembic/versions/20260923_0036_portfolio_repository_reconciliation.py @@ -0,0 +1,122 @@ +"""Persist secret-free repository observations and snapshot idempotency.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260923_0036" +down_revision = "20260911_0035" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "portfolio_reconciliation_runs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("source_system", sa.String(length=16), nullable=False), + sa.Column("snapshot_id", sa.String(length=128), nullable=False), + sa.Column("payload_digest", sa.String(length=64), nullable=False), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("complete", sa.Boolean(), nullable=False), + sa.Column("stale_snapshot", sa.Boolean(), nullable=False), + sa.Column("projects_created", sa.Integer(), nullable=False), + sa.Column("repositories_created", sa.Integer(), nullable=False), + sa.Column("repositories_updated", sa.Integer(), nullable=False), + sa.Column("stale_observations", sa.Integer(), nullable=False), + sa.Column("repositories_marked_stale", sa.Integer(), nullable=False), + sa.Column("runtime_observations_updated", sa.Integer(), nullable=False), + sa.Column("head_changes_review_required", sa.Integer(), nullable=False), + sa.Column("head_changes_accepted", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("source_system", "snapshot_id", name="uq_portfolio_snapshot_identity"), + sa.CheckConstraint("length(payload_digest) = 64", name="ck_portfolio_snapshot_digest_length"), + sa.CheckConstraint( + "projects_created >= 0 AND repositories_created >= 0 " + "AND repositories_updated >= 0 AND stale_observations >= 0 " + "AND repositories_marked_stale >= 0 AND runtime_observations_updated >= 0 " + "AND head_changes_review_required >= 0 AND head_changes_accepted >= 0", + name="ck_portfolio_snapshot_counts_nonnegative", + ), + ) + op.create_index( + "ix_portfolio_snapshot_source_observed", + "portfolio_reconciliation_runs", + ["source_system", "complete", "observed_at"], + ) + op.create_table( + "portfolio_repository_observations", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.Uuid(), nullable=True), + sa.Column("project_key", sa.String(length=128), nullable=True), + sa.Column("source_system", sa.String(length=16), nullable=False), + sa.Column("external_repository_id", sa.String(length=128), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=False), + sa.Column("default_branch", sa.String(length=255), nullable=False), + sa.Column("head_sha", sa.String(length=40), nullable=False), + sa.Column("classification", sa.String(length=32), nullable=False), + sa.Column("code_evidence", sa.JSON(), nullable=False), + sa.Column("runtime_evidence", sa.JSON(), nullable=False), + sa.Column("source_observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("source_snapshot_id", sa.String(length=128), nullable=False), + sa.Column("source_snapshot_digest", sa.String(length=64), nullable=False), + sa.Column("pending_head_sha", sa.String(length=40), nullable=True), + sa.Column("pending_default_branch", sa.String(length=255), nullable=True), + sa.Column("pending_project_key", sa.String(length=128), nullable=True), + sa.Column("pending_classification", sa.String(length=32), nullable=True), + sa.Column("pending_code_evidence", sa.JSON(), nullable=True), + sa.Column("pending_observed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("pending_snapshot_id", sa.String(length=128), nullable=True), + sa.Column("pending_snapshot_digest", sa.String(length=64), nullable=True), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("stale_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "source_system", "external_repository_id", name="uq_portfolio_repo_external_id" + ), + sa.UniqueConstraint( + "source_system", "project_key", name="uq_portfolio_source_project_key" + ), + sa.UniqueConstraint("project_id", name="uq_portfolio_repository_project_id"), + sa.CheckConstraint("length(head_sha) = 40", name="ck_portfolio_repo_head_sha_length"), + sa.CheckConstraint( + "pending_head_sha IS NULL OR length(pending_head_sha) = 40", + name="ck_portfolio_repo_pending_sha_length", + ), + sa.CheckConstraint( + "classification IN ('local_model', 'hosted_model', 'gateway', 'mixed', " + "'model_management', 'runtime_observer', 'non_ai', 'unknown')", + name="ck_portfolio_repo_classification", + ), + ) + op.create_index( + "ix_portfolio_repository_observations_project_id", + "portfolio_repository_observations", + ["project_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + runs = sa.table("portfolio_reconciliation_runs", sa.column("id")) + repositories = sa.table("portfolio_repository_observations", sa.column("id")) + if bind.scalar(sa.select(sa.func.count()).select_from(runs)) or bind.scalar( + sa.select(sa.func.count()).select_from(repositories) + ): + raise RuntimeError( + "portfolio reconciliation history exists; restore the pre-migration backup instead." + ) + op.drop_index( + "ix_portfolio_repository_observations_project_id", + table_name="portfolio_repository_observations", + ) + op.drop_table("portfolio_repository_observations") + op.drop_index( + "ix_portfolio_snapshot_source_observed", table_name="portfolio_reconciliation_runs" + ) + op.drop_table("portfolio_reconciliation_runs") diff --git a/backend/alembic/versions/20260923_0037_alert_notification_outbox.py b/backend/alembic/versions/20260923_0037_alert_notification_outbox.py new file mode 100644 index 0000000..ee5e6e9 --- /dev/null +++ b/backend/alembic/versions/20260923_0037_alert_notification_outbox.py @@ -0,0 +1,99 @@ +"""Add durable, single-route operational alert notification outbox.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260923_0037" +down_revision = "20260923_0036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "alert_notification_deliveries", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("alert_history_event_id", sa.Uuid(), nullable=True), + sa.Column("route_key", sa.String(length=64), nullable=False), + sa.Column("idempotency_key", sa.String(length=160), nullable=False), + sa.Column("event_kind", sa.String(length=16), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=True), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("attempt_count", sa.Integer(), nullable=False), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("claim_token_hash", sa.String(length=64), nullable=True), + sa.Column("last_http_status", sa.Integer(), nullable=True), + sa.Column("last_error_code", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "event_kind IN ('FIRING', 'RESOLVED', 'TEST')", + name="ck_alert_notification_event_kind", + ), + sa.CheckConstraint( + "status IN ('PENDING', 'DISPATCHING', 'RETRY_WAIT', 'DELIVERED', 'FAILED', 'HELD_FOR_REVIEW')", + name="ck_alert_notification_status", + ), + sa.CheckConstraint("attempt_count >= 0", name="ck_alert_notification_attempt_count"), + sa.ForeignKeyConstraint( + ["alert_history_event_id"], ["alert_history_events.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("idempotency_key", name="uq_alert_notification_idempotency"), + ) + op.create_index( + "ix_alert_notification_deliveries_alert_history_event_id", + "alert_notification_deliveries", + ["alert_history_event_id"], + ) + op.create_index( + "ix_alert_notification_deliveries_event_kind", + "alert_notification_deliveries", + ["event_kind"], + ) + op.create_index( + "ix_alert_notification_deliveries_severity", + "alert_notification_deliveries", + ["severity"], + ) + op.create_index( + "ix_alert_notification_deliveries_status", + "alert_notification_deliveries", + ["status"], + ) + op.create_index( + "ix_alert_notification_deliveries_next_attempt_at", + "alert_notification_deliveries", + ["next_attempt_at"], + ) + op.create_index( + "ix_alert_notification_deliveries_lease_expires_at", + "alert_notification_deliveries", + ["lease_expires_at"], + ) + op.create_index( + "ix_alert_notification_dispatch_due", + "alert_notification_deliveries", + ["status", "next_attempt_at", "created_at"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + deliveries = sa.table("alert_notification_deliveries", sa.column("id")) + if bind.scalar(sa.select(sa.func.count()).select_from(deliveries)): + raise RuntimeError( + "alert notification history exists; restore the pre-migration backup instead." + ) + op.drop_index("ix_alert_notification_dispatch_due", table_name="alert_notification_deliveries") + op.drop_index("ix_alert_notification_deliveries_lease_expires_at", table_name="alert_notification_deliveries") + op.drop_index("ix_alert_notification_deliveries_next_attempt_at", table_name="alert_notification_deliveries") + op.drop_index("ix_alert_notification_deliveries_status", table_name="alert_notification_deliveries") + op.drop_index("ix_alert_notification_deliveries_severity", table_name="alert_notification_deliveries") + op.drop_index("ix_alert_notification_deliveries_event_kind", table_name="alert_notification_deliveries") + op.drop_index("ix_alert_notification_deliveries_alert_history_event_id", table_name="alert_notification_deliveries") + op.drop_table("alert_notification_deliveries") diff --git a/backend/alembic/versions/20260924_0038_lab_execution_runs.py b/backend/alembic/versions/20260924_0038_lab_execution_runs.py new file mode 100644 index 0000000..17cb4c3 --- /dev/null +++ b/backend/alembic/versions/20260924_0038_lab_execution_runs.py @@ -0,0 +1,104 @@ +"""Persist actor-scoped Model Lab execution identities.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260924_0038" +down_revision = "20260923_0037" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "lab_execution_runs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("actor_subject", sa.String(length=255), nullable=False), + sa.Column("idempotency_key", sa.String(length=128), nullable=False), + sa.Column("request_fingerprint", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=24), server_default="accepted", nullable=False), + sa.Column("capability", sa.String(length=128), server_default="rag.embedding@1", nullable=False), + sa.Column("target_snapshot", sa.JSON(), nullable=False), + sa.Column("input_sha256", sa.String(length=64), nullable=False), + sa.Column("input_count", sa.Integer(), nullable=False), + sa.Column("input_encoded_bytes", sa.Integer(), nullable=False), + sa.Column("payload_reference", sa.String(length=128), nullable=True), + sa.Column("input_payload_expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("gateway_request_id", sa.Uuid(), nullable=False), + sa.Column("timeout_seconds", sa.Integer(), server_default="60", nullable=False), + sa.Column("queue_timeout_seconds", sa.Integer(), server_default="10", nullable=False), + sa.Column("retention_seconds", sa.Integer(), server_default="300", nullable=False), + sa.Column("output_metadata", sa.JSON(), nullable=True), + sa.Column("worker_observations", sa.JSON(), nullable=True), + sa.Column("failure_code", sa.String(length=64), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("dispatch_started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("heartbeat_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancellation_requested_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancellation_acknowledged_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "status IN ('accepted', 'preparing', 'queued', 'running', 'cancel_requested', " + "'completed', 'failed', 'cancelled', 'interrupted')", + name="ck_lab_execution_run_status", + ), + sa.CheckConstraint("input_count BETWEEN 1 AND 8", name="ck_lab_execution_run_input_count"), + sa.CheckConstraint("timeout_seconds BETWEEN 5 AND 300", name="ck_lab_execution_run_timeout"), + sa.CheckConstraint("queue_timeout_seconds BETWEEN 1 AND 60", name="ck_lab_execution_run_queue_timeout"), + sa.CheckConstraint("retention_seconds BETWEEN 60 AND 3600", name="ck_lab_execution_run_retention"), + sa.ForeignKeyConstraint( + ["gateway_request_id"], ["gateway_requests.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("actor_subject", "idempotency_key", name="uq_lab_run_actor_idempotency"), + sa.UniqueConstraint("gateway_request_id", name="uq_lab_execution_run_gateway_request"), + ) + op.create_index("ix_lab_execution_runs_actor_subject", "lab_execution_runs", ["actor_subject"]) + op.create_index("ix_lab_execution_runs_status", "lab_execution_runs", ["status"]) + op.create_index("ix_lab_execution_runs_gateway_request_id", "lab_execution_runs", ["gateway_request_id"]) + op.create_index("ix_lab_execution_runs_actor_status_created", "lab_execution_runs", ["actor_subject", "status", "created_at"]) + op.add_column( + "serving_jobs", + sa.Column("lab_execution_run_id", sa.Uuid(), nullable=True), + ) + op.create_foreign_key( + "fk_serving_jobs_lab_execution_run_id_lab_execution_runs", + "serving_jobs", + "lab_execution_runs", + ["lab_execution_run_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index( + "ix_serving_jobs_lab_execution_run_id", "serving_jobs", ["lab_execution_run_id"] + ) + if op.get_bind().dialect.name == "postgresql": + op.execute( + "REVOKE DELETE, TRUNCATE ON lab_execution_runs FROM modelforge_runtime" + ) + op.execute( + "GRANT SELECT, INSERT, UPDATE ON lab_execution_runs TO modelforge_runtime" + ) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.scalar(sa.select(sa.func.count()).select_from(sa.table("lab_execution_runs", sa.column("id")))): + raise RuntimeError( + "LAB run history exists; restore the pre-migration backup instead of deleting it." + ) + op.drop_index("ix_serving_jobs_lab_execution_run_id", table_name="serving_jobs") + op.drop_constraint( + "fk_serving_jobs_lab_execution_run_id_lab_execution_runs", + "serving_jobs", + type_="foreignkey", + ) + op.drop_column("serving_jobs", "lab_execution_run_id") + op.drop_index("ix_lab_execution_runs_actor_status_created", table_name="lab_execution_runs") + op.drop_index("ix_lab_execution_runs_gateway_request_id", table_name="lab_execution_runs") + op.drop_index("ix_lab_execution_runs_status", table_name="lab_execution_runs") + op.drop_index("ix_lab_execution_runs_actor_subject", table_name="lab_execution_runs") + op.drop_table("lab_execution_runs") diff --git a/backend/alembic/versions/20260924_0039_operator_benchmark_runs.py b/backend/alembic/versions/20260924_0039_operator_benchmark_runs.py new file mode 100644 index 0000000..769c676 --- /dev/null +++ b/backend/alembic/versions/20260924_0039_operator_benchmark_runs.py @@ -0,0 +1,164 @@ +"""Bind persisted benchmark evidence to authenticated capability runs.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260924_0039" +down_revision = "20260924_0038" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if op.get_bind().dialect.name == "sqlite": + # SQLite cannot ALTER TABLE to add the FK/check/unique constraints. Alembic's batch + # recreation preserves historical benchmark rows and their indexes. + with op.batch_alter_table("benchmark_runs", recreate="always") as batch: + batch.alter_column("deployment_id", existing_type=sa.Uuid(), nullable=True) + batch.add_column(sa.Column("capability_deployment_id", sa.Uuid(), nullable=True)) + batch.add_column(sa.Column("target_snapshot", sa.JSON(), nullable=True)) + batch.add_column(sa.Column("actor_subject", sa.String(255), nullable=True)) + batch.add_column(sa.Column("idempotency_key", sa.String(128), nullable=True)) + batch.add_column(sa.Column("request_fingerprint", sa.String(64), nullable=True)) + batch.add_column(sa.Column("failure_code", sa.String(64), nullable=True)) + batch.add_column(sa.Column("cancellation_requested_at", sa.DateTime(timezone=True))) + batch.add_column( + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ) + ) + batch.add_column( + sa.Column("generation", sa.Integer(), server_default="1", nullable=False) + ) + batch.create_foreign_key( + "fk_benchmark_runs_capability_deployment", + "capability_deployments", + ["capability_deployment_id"], + ["id"], + ondelete="RESTRICT", + ) + batch.create_check_constraint( + "ck_benchmark_runs_operator_identity", + "(actor_subject IS NULL AND idempotency_key IS NULL AND " + "request_fingerprint IS NULL AND capability_deployment_id IS NULL) OR " + "(actor_subject IS NOT NULL AND idempotency_key IS NOT NULL AND " + "request_fingerprint IS NOT NULL AND capability_deployment_id IS NOT NULL)", + ) + batch.create_unique_constraint( + "uq_benchmark_runs_actor_idempotency", ["actor_subject", "idempotency_key"] + ) + else: + op.alter_column("benchmark_runs", "deployment_id", existing_type=sa.Uuid(), nullable=True) + op.add_column( + "benchmark_runs", sa.Column("capability_deployment_id", sa.Uuid(), nullable=True) + ) + op.add_column("benchmark_runs", sa.Column("target_snapshot", sa.JSON(), nullable=True)) + op.add_column("benchmark_runs", sa.Column("actor_subject", sa.String(255), nullable=True)) + op.add_column("benchmark_runs", sa.Column("idempotency_key", sa.String(128), nullable=True)) + op.add_column( + "benchmark_runs", sa.Column("request_fingerprint", sa.String(64), nullable=True) + ) + op.add_column("benchmark_runs", sa.Column("failure_code", sa.String(64), nullable=True)) + op.add_column( + "benchmark_runs", sa.Column("cancellation_requested_at", sa.DateTime(timezone=True)) + ) + op.add_column( + "benchmark_runs", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.add_column( + "benchmark_runs", + sa.Column("generation", sa.Integer(), server_default="1", nullable=False), + ) + op.create_foreign_key( + "fk_benchmark_runs_capability_deployment", + "benchmark_runs", + "capability_deployments", + ["capability_deployment_id"], + ["id"], + ondelete="RESTRICT", + ) + op.create_check_constraint( + "ck_benchmark_runs_operator_identity", + "benchmark_runs", + "(actor_subject IS NULL AND idempotency_key IS NULL AND " + "request_fingerprint IS NULL AND capability_deployment_id IS NULL) OR " + "(actor_subject IS NOT NULL AND idempotency_key IS NOT NULL AND " + "request_fingerprint IS NOT NULL AND capability_deployment_id IS NOT NULL)", + ) + op.create_unique_constraint( + "uq_benchmark_runs_actor_idempotency", + "benchmark_runs", + ["actor_subject", "idempotency_key"], + ) + op.create_index("ix_benchmark_runs_actor_subject", "benchmark_runs", ["actor_subject"]) + op.create_index( + "ix_benchmark_runs_capability_deployment_completed", + "benchmark_runs", + ["capability_deployment_id", "completed_at"], + ) + if op.get_bind().dialect.name == "postgresql": + op.execute("REVOKE DELETE, TRUNCATE ON benchmark_runs FROM modelforge_runtime") + op.execute("GRANT SELECT, INSERT, UPDATE ON benchmark_runs TO modelforge_runtime") + + +def downgrade() -> None: + bind = op.get_bind() + benchmark_runs = sa.table("benchmark_runs", sa.column("actor_subject", sa.String())) + operator_history = bind.scalar( + sa.select(sa.func.count()) + .select_from(benchmark_runs) + .where(benchmark_runs.c.actor_subject.is_not(None)) + ) + if operator_history: + raise RuntimeError( + "Operator benchmark history exists; restore the pre-migration backup instead of deleting it." + ) + op.drop_index("ix_benchmark_runs_capability_deployment_completed", table_name="benchmark_runs") + op.drop_index("ix_benchmark_runs_actor_subject", table_name="benchmark_runs") + if op.get_bind().dialect.name == "sqlite": + with op.batch_alter_table("benchmark_runs", recreate="always") as batch: + batch.drop_constraint("uq_benchmark_runs_actor_idempotency", type_="unique") + batch.drop_constraint("ck_benchmark_runs_operator_identity", type_="check") + batch.drop_constraint("fk_benchmark_runs_capability_deployment", type_="foreignkey") + for column in ( + "generation", + "created_at", + "cancellation_requested_at", + "request_fingerprint", + "failure_code", + "idempotency_key", + "actor_subject", + "target_snapshot", + "capability_deployment_id", + ): + batch.drop_column(column) + batch.alter_column("deployment_id", existing_type=sa.Uuid(), nullable=False) + else: + op.drop_constraint("uq_benchmark_runs_actor_idempotency", "benchmark_runs", type_="unique") + op.drop_constraint("ck_benchmark_runs_operator_identity", "benchmark_runs", type_="check") + op.drop_constraint( + "fk_benchmark_runs_capability_deployment", "benchmark_runs", type_="foreignkey" + ) + for column in ( + "generation", + "created_at", + "cancellation_requested_at", + "request_fingerprint", + "failure_code", + "idempotency_key", + "actor_subject", + "target_snapshot", + "capability_deployment_id", + ): + op.drop_column("benchmark_runs", column) + op.alter_column("benchmark_runs", "deployment_id", existing_type=sa.Uuid(), nullable=False) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e9d0b5f..1062970 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,10 +1,12 @@ [build-system] -requires = ["hatchling"] +requires = [ + "hatchling==1.32.4", +] build-backend = "hatchling.build" [project] name = "modelforge-api" -version = "1.2.2" +version = "1.3.0-rc.1" description = "ITWorx ModelForge control-plane API" license = "AGPL-3.0-or-later" requires-python = ">=3.12" @@ -19,6 +21,7 @@ dependencies = [ "cryptography==50.0.1", "redis==6.4.0", "httpx==0.28.1", + "msgpack==1.2.2", "huggingface-hub==1.29.0", "nvidia-ml-py==13.610.43", "psutil==7.2.2", @@ -28,7 +31,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest==8.4.2", + "pytest==9.0.3", "pytest-asyncio==1.4.0", "ruff==0.16.5", "mypy==1.20.2", diff --git a/backend/src/modelforge_api/api/authorization.py b/backend/src/modelforge_api/api/authorization.py index 9b253f7..8bef1e9 100644 --- a/backend/src/modelforge_api/api/authorization.py +++ b/backend/src/modelforge_api/api/authorization.py @@ -1,13 +1,16 @@ from __future__ import annotations +import hashlib import hmac import re from dataclasses import dataclass +from datetime import UTC, datetime from enum import StrEnum from typing import Annotated from fastapi import Depends, Header, HTTPException, Request +from modelforge_api.domain.operator_identity import parse_operator_identities from modelforge_api.settings import Settings, get_settings @@ -35,6 +38,7 @@ class AccessBoundary(StrEnum): _PUBLIC_REQUESTS = { + ("POST", "/api/v1/operator/login"), ("GET", "/"), ("GET", "/api/v1/health/live"), ("GET", "/api/v1/health/ready"), @@ -56,14 +60,18 @@ _NODE_REQUESTS = { ("GET", "/api/v1/agent/serving-jobs/next"), ("POST", "/api/v1/agent/serving-jobs/{job_id}/complete"), ("POST", "/api/v1/agent/serving-jobs/{job_id}/fail"), + ("POST", "/api/v1/agent/serving-jobs/{job_id}/cancellation"), + ("POST", "/api/v1/agent/serving-jobs/{job_id}/cancelled"), ("POST", "/api/v1/agent/serving-state"), } _NODE_PARAMETERIZED_REQUESTS = ( ("POST", re.compile(r"^/api/v1/agent/artifact-jobs/[^/]+/(?:progress|complete|fail)$")), ("POST", re.compile(r"^/api/v1/agent/runtime-probes/[^/]+/(?:progress|complete|fail)$")), - ("POST", re.compile(r"^/api/v1/agent/serving-jobs/[^/]+/(?:complete|fail)$")), + ("POST", re.compile(r"^/api/v1/agent/serving-jobs/[^/]+/(?:complete|fail|cancellation|cancelled)$")), ) _CAPABILITY_REQUIREMENTS = { + ("POST", "/api/v1/capabilities/creative.resource@1/allocations"): "creative.resource@1", + ("POST", "/api/v1/capabilities/creative.resource@1/profiles/observations"): "creative.resource@1", ("POST", "/api/v1/capabilities/rag.embedding@1/invoke"): "rag.embedding@1", ("POST", "/api/v1/capabilities/rag.reranking@1/invoke"): "rag.reranking@1", ("POST", "/api/v1/capabilities/document.ocr@1/invoke"): "document.ocr@1", @@ -72,8 +80,19 @@ _CAPABILITY_REQUIREMENTS = { ("POST", "/api/v1/capability-experiments/{route_key}/invoke"): "rag.embedding@1", ("POST", "/v1/embeddings"): "rag.embedding@1", } +for _capability in ( + "rag.embedding@1", "rag.reranking@1", "document.ocr@1", + "vision.embedding@1", "speech.transcription@1", +): + _CAPABILITY_REQUIREMENTS[("GET", f"/api/v1/capabilities/{_capability}/check")] = _capability _CAPABILITY_CLIENT_REQUESTS = set(_CAPABILITY_REQUIREMENTS) +_CAPABILITY_REQUIREMENTS[("GET", "/api/v1/selected-model-comparisons/assigned")] = "rag.embedding@1" +_CAPABILITY_CLIENT_REQUESTS.add(("GET", "/api/v1/selected-model-comparisons/assigned")) _CAPABILITY_EXPERIMENT_REQUEST = re.compile(r"^/api/v1/capability-experiments/[^/]+/invoke$") +_CREATIVE_ALLOCATION_REQUEST = re.compile(r"^/api/v1/capabilities/creative\.resource@1/allocations/[^/]+(?:/(?:attest|heartbeat|release|uncertain))?$") +_COMPARISON_CONSUMER_REQUEST = re.compile( + r"^/api/v1/selected-model-comparisons/[^/]+/(claim|progress|result|finish)$" +) def access_boundary_for_request( @@ -98,7 +117,12 @@ def access_boundary_for_request( ): return AccessBoundary.NODE if (normalized_method, path) in _CAPABILITY_CLIENT_REQUESTS or ( - normalized_method == "POST" and _CAPABILITY_EXPERIMENT_REQUEST.fullmatch(path) + normalized_method in {"GET", "POST"} and _CREATIVE_ALLOCATION_REQUEST.fullmatch(path) + ) or ( + normalized_method == "POST" and ( + _CAPABILITY_EXPERIMENT_REQUEST.fullmatch(path) + or _COMPARISON_CONSUMER_REQUEST.fullmatch(path) + ) ): return AccessBoundary.CAPABILITY_CLIENT return AccessBoundary.CONTROL_PLANE @@ -111,7 +135,12 @@ def required_capability_for_request(method: str, path: str) -> str | None: requirement = _CAPABILITY_REQUIREMENTS.get((normalized_method, path)) if requirement is not None: return requirement - if normalized_method == "POST" and _CAPABILITY_EXPERIMENT_REQUEST.fullmatch(path): + if normalized_method in {"GET", "POST"} and _CREATIVE_ALLOCATION_REQUEST.fullmatch(path): + return "creative.resource@1" + if normalized_method == "POST" and ( + _CAPABILITY_EXPERIMENT_REQUEST.fullmatch(path) + or _COMPARISON_CONSUMER_REQUEST.fullmatch(path) + ): return "rag.embedding@1" return None @@ -145,28 +174,70 @@ class Principal: subject: str role: PrincipalRole authentication_method: str + credential_id: str | None = None + expires_at: datetime | None = None def permits(self, required: PrincipalRole) -> bool: return _ROLE_RANK[self.role] >= _ROLE_RANK[required] def authenticate_operator_token(settings: Settings, token: str | None) -> Principal: - """Authenticate the backward-compatible operator key as an admin principal. - - The legacy key is the sole human credential in this release. OIDC/session authentication can - add another principal producer later without changing route authorization policy. - """ + """Authenticate a named expiring human key or the legacy bootstrap administrator.""" configured = settings.operator_api_key - if configured is None or not configured.get_secret_value(): + document = settings.operator_identities + identities = parse_operator_identities(document.get_secret_value()) if ( + document is not None and document.get_secret_value() + ) else () + if not identities and (configured is None or not configured.get_secret_value()): raise HTTPException(status_code=503, detail="operator API authentication is not configured") - if token is None or not hmac.compare_digest(token, configured.get_secret_value()): + if token is None or len(token) > 512 or token.startswith(("mfopm_", "mfsvc_", "mfnode_", "mfenroll_")): raise HTTPException(status_code=401, detail="invalid operator credential") - return Principal( - subject="legacy-operator", - role=PrincipalRole.ADMIN, - authentication_method="legacy_admin_token", - ) + if token.startswith("mfop_"): + if not re.fullmatch(r"mfop_[A-Za-z0-9_-]{43}", token): + raise HTTPException(status_code=401, detail="invalid operator credential") + digest = hashlib.sha256(token.encode()).hexdigest() + for identity in identities: + if hmac.compare_digest(digest, identity.token_sha256): + if identity.expires_at <= datetime.now(UTC): + raise HTTPException(status_code=401, detail="invalid operator credential") + return Principal( + subject=identity.subject, + role=PrincipalRole(identity.role), + authentication_method="named_operator_key", + credential_id=identity.credential_id, + expires_at=identity.expires_at, + ) + # The named namespace never falls back to the legacy administrator. + raise HTTPException(status_code=401, detail="invalid operator credential") + if configured is not None and hmac.compare_digest( + token.encode("utf-8"), configured.get_secret_value().encode("utf-8") + ): + return Principal( + subject="legacy-operator", + role=PrincipalRole.ADMIN, + authentication_method="legacy_admin_token", + ) + if configured is not None and token.startswith("mfpw_"): + from modelforge_api.services.operator_password import verify_session + + password_hash = settings.operator_password_hash + if password_hash is not None and verify_session( + token, configured.get_secret_value(), password_hash.get_secret_value() + ): + return Principal( + "password-operator", PrincipalRole.ADMIN, "password_session", + expires_at=datetime.fromtimestamp(int(token[5:].split(".")[1]), UTC), + ) + raise HTTPException(status_code=401, detail="invalid operator credential") + + +def operator_token_from_headers(admin_token: str | None, authorization: str | None) -> str | None: + if admin_token is not None: + return admin_token + if authorization is not None and authorization.startswith("Bearer mfpw_"): + return authorization.removeprefix("Bearer ") + return None def authenticate_operator_principal( @@ -179,7 +250,9 @@ def authenticate_operator_principal( principal = getattr(request.state, "principal", None) if isinstance(principal, Principal): return principal - return authenticate_operator_token(settings, token) + return authenticate_operator_token( + settings, operator_token_from_headers(token, request.headers.get("authorization")) + ) AuthenticatedPrincipal = Annotated[Principal, Depends(authenticate_operator_principal)] @@ -187,7 +260,10 @@ AuthenticatedPrincipal = Annotated[Principal, Depends(authenticate_operator_prin def _require_role(principal: Principal, required: PrincipalRole) -> Principal: if not principal.permits(required): - raise HTTPException(status_code=403, detail="operator role is not authorized") + raise HTTPException( + status_code=403, + detail=f"This action requires the {required.value} role; your role is {principal.role.value}.", + ) return principal diff --git a/backend/src/modelforge_api/api/request_limits.py b/backend/src/modelforge_api/api/request_limits.py index 9308859..31e874a 100644 --- a/backend/src/modelforge_api/api/request_limits.py +++ b/backend/src/modelforge_api/api/request_limits.py @@ -79,7 +79,14 @@ class RequestBodyLimitMiddleware: "details": details or {}, } }, - headers={"X-Correlation-ID": str(correlation_id)}, + headers={ + "X-Correlation-ID": str(correlation_id), + **( + {"Cache-Control": "no-store"} + if scope.get("path") == "/api/v1/operator/login" + else {} + ), + }, ) await response(scope, receive, send) diff --git a/backend/src/modelforge_api/api/routes/acquisition.py b/backend/src/modelforge_api/api/routes/acquisition.py index e8444ed..c90f9fc 100644 --- a/backend/src/modelforge_api/api/routes/acquisition.py +++ b/backend/src/modelforge_api/api/routes/acquisition.py @@ -18,8 +18,8 @@ from modelforge_api.domain.acquisition import ( UpstreamRefreshRequest, UpstreamSnapshotResponse, ) -from modelforge_api.providers.huggingface import OfficialHuggingFaceProvider from modelforge_api.services.acquisition import AcquisitionService +from modelforge_api.services.secret_slots import SecretAwareHuggingFaceProvider from modelforge_api.settings import Settings, get_settings router = APIRouter( @@ -33,9 +33,8 @@ def get_acquisition_service( session: Annotated[Session, Depends(get_session)], settings: Annotated[Settings, Depends(get_settings)], ) -> AcquisitionService: - token = settings.hf_token.get_secret_value() if settings.hf_token else None - provider = OfficialHuggingFaceProvider(token=token, timeout=settings.hf_timeout_seconds) - return AcquisitionService(session, settings, provider) + provider = SecretAwareHuggingFaceProvider(session, settings) + return AcquisitionService(session, settings, provider, credential_configured=provider.credential_configured) Service = Annotated[AcquisitionService, Depends(get_acquisition_service)] diff --git a/backend/src/modelforge_api/api/routes/agent.py b/backend/src/modelforge_api/api/routes/agent.py index d3fbe26..71d205d 100644 --- a/backend/src/modelforge_api/api/routes/agent.py +++ b/backend/src/modelforge_api/api/routes/agent.py @@ -37,6 +37,12 @@ from modelforge_api.domain.node_decommission import ( NodeDecommissionPreview, NodeDecommissionResult, ) +from modelforge_api.domain.node_lifecycle import ( + NodeLifecycleChange, + NodeLifecyclePreview, + NodeLifecyclePreviewRequest, + NodeLifecycleResult, +) from modelforge_api.domain.runtime import ( AgentRuntimeProbeComplete, AgentRuntimeProbeControl, @@ -46,11 +52,11 @@ from modelforge_api.domain.runtime import ( RuntimeProbeResponse, ) from modelforge_api.persistence.models import ComputeNode, NodeCredential -from modelforge_api.providers.huggingface import OfficialHuggingFaceProvider from modelforge_api.services.acquisition import AcquisitionService from modelforge_api.services.node_agent import NodeAgentService, NodeAuthenticationEvidence from modelforge_api.services.node_decommission import NodeDecommissionService from modelforge_api.services.runtime import RuntimeService +from modelforge_api.services.secret_slots import SecretAwareHuggingFaceProvider from modelforge_api.settings import Settings, get_settings router = APIRouter(tags=["node-agent"]) @@ -119,6 +125,7 @@ def list_enrollments(service: Service, _admin: Admin) -> list[EnrollmentTokenSum expires_at=item.expires_at, used_at=item.used_at, revoked_at=item.revoked_at, + enrolled_node_id=str(item.enrolled_node_id) if item.enrolled_node_id is not None else None, ) for item in service.repository.enrollments() ] @@ -141,6 +148,32 @@ def update_node( return ActionResponse() +@router.post( + "/api/v1/admin/hardware/nodes/{node_id}/lifecycle/preview", + response_model=NodeLifecyclePreview, +) +def preview_node_lifecycle( + node_id: uuid.UUID, + request: NodeLifecyclePreviewRequest, + service: DecommissionService, + _admin: Admin, +) -> NodeLifecyclePreview: + return service.preview_enabled_change(node_id, request) + + +@router.post( + "/api/v1/admin/hardware/nodes/{node_id}/lifecycle", + response_model=NodeLifecycleResult, +) +def change_node_lifecycle( + node_id: uuid.UUID, + request: NodeLifecycleChange, + service: DecommissionService, + _admin: Admin, +) -> NodeLifecycleResult: + return service.apply_enabled_change(node_id, request) + + @router.post( "/api/v1/admin/hardware/nodes/{node_id}/decommission/preview", response_model=NodeDecommissionPreview, @@ -213,13 +246,14 @@ def get_agent_acquisition_service( session: Annotated[Session, Depends(get_session)], settings: Annotated[Settings, Depends(get_settings)], ) -> AcquisitionService: - token = settings.hf_token.get_secret_value() if settings.hf_token else None + provider = SecretAwareHuggingFaceProvider(session, settings) return AcquisitionService( session, settings, - OfficialHuggingFaceProvider(token=token, timeout=settings.hf_timeout_seconds), + provider, actor_type="node_agent", actor_id="authenticated-node", + credential_configured=provider.credential_configured, ) diff --git a/backend/src/modelforge_api/api/routes/audit_timeline.py b/backend/src/modelforge_api/api/routes/audit_timeline.py new file mode 100644 index 0000000..1e2d70f --- /dev/null +++ b/backend/src/modelforge_api/api/routes/audit_timeline.py @@ -0,0 +1,37 @@ +"""Admin-only, read-only audit timeline API.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Admin +from modelforge_api.db import get_session +from modelforge_api.domain.audit_timeline import AuditTimelineRequest, AuditTimelineResponse +from modelforge_api.services.audit_timeline import ( + AuditTimelineCursorError, + AuditTimelineService, +) + +router = APIRouter(prefix="/api/v1/admin/audit", tags=["audit"]) + + +def get_service(session: Annotated[Session, Depends(get_session)]) -> AuditTimelineService: + return AuditTimelineService(session) + + +Service = Annotated[AuditTimelineService, Depends(get_service)] + + +@router.get("/events", response_model=AuditTimelineResponse) +def audit_timeline( + query: Annotated[AuditTimelineRequest, Query()], + service: Service, + _admin: Admin, +) -> AuditTimelineResponse: + try: + return service.list_events(query) + except AuditTimelineCursorError as error: + raise HTTPException(status_code=422, detail=str(error)) from None diff --git a/backend/src/modelforge_api/api/routes/catalog.py b/backend/src/modelforge_api/api/routes/catalog.py index 39fb63f..24ed9e5 100644 --- a/backend/src/modelforge_api/api/routes/catalog.py +++ b/backend/src/modelforge_api/api/routes/catalog.py @@ -1,9 +1,13 @@ +from collections import defaultdict +from uuid import UUID + from fastapi import APIRouter, Depends -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import select, tuple_ +from sqlalchemy.orm import Session, aliased, selectinload from modelforge_api.api.authorization import require_viewer from modelforge_api.db import get_session +from modelforge_api.domain.contracts import ProjectBindingManifest from modelforge_api.domain.schemas import ( CapabilityContractResponse, CapabilityEstateResponse, @@ -40,22 +44,31 @@ def list_capabilities( registry: ManifestRegistry = Depends(get_manifest_registry), session: Session = Depends(get_session), ) -> list[CapabilityContractResponse]: - responses = [] - for contract in registry.capabilities(): - stable = session.execute( - select(CapabilityDeployment) + manifests = registry.capabilities() + identities = {(item.capability, item.version) for item in manifests} + stable_by_contract: dict[tuple[str, int], CapabilityDeployment] = {} + if identities: + stable_rows = session.execute( + select(Capability.key, CapabilityContract.version, CapabilityDeployment) .join( CapabilityContract, CapabilityContract.id == CapabilityDeployment.capability_contract_id, ) .join(Capability, Capability.id == CapabilityContract.capability_id) .where( - Capability.key == contract.capability, - CapabilityContract.version == contract.version, + tuple_(Capability.key, CapabilityContract.version).in_(identities), CapabilityDeployment.status == "stable", CapabilityDeployment.production.is_(True), ) - ).scalar_one_or_none() + ) + for key, version, deployment in stable_rows: + identity = (key, version) + if identity in stable_by_contract: + raise ValueError(f"multiple stable deployments for {key}@{version}") + stable_by_contract[identity] = deployment + responses = [] + for contract in manifests: + stable = stable_by_contract.get((contract.capability, contract.version)) responses.append( CapabilityContractResponse( key=contract.capability, @@ -82,45 +95,121 @@ def capability_estate( registry: ManifestRegistry = Depends(get_manifest_registry), session: Session = Depends(get_session), ) -> list[CapabilityEstateResponse]: - responses: list[CapabilityEstateResponse] = [] - for manifest in registry.capabilities(): - contract = session.scalar( - select(CapabilityContract) + manifests = registry.capabilities() + contracts_by_identity: dict[tuple[str, int], CapabilityContract] = {} + identities = [(item.capability, item.version) for item in manifests] + if identities: + for contract, capability_key in session.execute( + select(CapabilityContract, Capability.key) .join(Capability, Capability.id == CapabilityContract.capability_id) - .where(Capability.key == manifest.capability, CapabilityContract.version == manifest.version) + .where(tuple_(Capability.key, CapabilityContract.version).in_(identities)) + ): + contracts_by_identity[capability_key, contract.version] = contract + + contract_ids = [contract.id for contract in contracts_by_identity.values()] + deployments_by_contract: dict[UUID, CapabilityDeployment] = {} + if contract_ids: + deployment_candidate = aliased(CapabilityDeployment) + selected_deployment_id = ( + select(deployment_candidate.id) + .where( + deployment_candidate.capability_contract_id == CapabilityContract.id + ) + .order_by( + deployment_candidate.production.desc(), + deployment_candidate.created_at.desc(), + ) + .limit(1) + .correlate(CapabilityContract) + .scalar_subquery() ) - deployment = None - model = None - revision = None - profile = None - envelope = None - latest_evaluation = None - if contract: - deployment = session.scalar( - select(CapabilityDeployment) - .where(CapabilityDeployment.capability_contract_id == contract.id) - .order_by( - CapabilityDeployment.production.desc(), - CapabilityDeployment.created_at.desc(), - ) - .limit(1) + for selected_deployment in session.scalars( + select(CapabilityDeployment) + .join( + CapabilityContract, + CapabilityContract.id == CapabilityDeployment.capability_contract_id, ) - if deployment: - artifact_set = session.get(ArtifactSet, deployment.artifact_set_id) - revision = session.get(ModelRevision, artifact_set.revision_id) if artifact_set else None - model = session.get(Model, revision.model_id) if revision else None - profile = session.get(RuntimeProfile, deployment.runtime_profile_id) - envelope = session.scalar( - select(CapabilityResourceEnvelope).where( - CapabilityResourceEnvelope.capability_deployment_id == deployment.id - ) + .where( + CapabilityContract.id.in_(contract_ids), + CapabilityDeployment.id == selected_deployment_id, ) - latest_evaluation = session.scalar( - select(CapabilityEvaluationRun) - .where(CapabilityEvaluationRun.capability_deployment_id == deployment.id) - .order_by(CapabilityEvaluationRun.created_at.desc()) - .limit(1) + ): + deployments_by_contract[selected_deployment.capability_contract_id] = selected_deployment + + deployments = list(deployments_by_contract.values()) + deployment_ids = [item.id for item in deployments] + artifact_set_ids = list({item.artifact_set_id for item in deployments}) + artifact_sets = ( + session.scalars(select(ArtifactSet).where(ArtifactSet.id.in_(artifact_set_ids))).all() + if artifact_set_ids + else [] + ) + artifact_sets_by_id = {item.id: item for item in artifact_sets} + revision_ids = list({item.revision_id for item in artifact_sets}) + revisions = ( + session.scalars(select(ModelRevision).where(ModelRevision.id.in_(revision_ids))).all() + if revision_ids + else [] + ) + revisions_by_id = {item.id: item for item in revisions} + model_ids = list({item.model_id for item in revisions}) + models = ( + session.scalars(select(Model).where(Model.id.in_(model_ids))).all() if model_ids else [] + ) + models_by_id = {item.id: item for item in models} + profile_ids = list({item.runtime_profile_id for item in deployments}) + profiles = ( + session.scalars(select(RuntimeProfile).where(RuntimeProfile.id.in_(profile_ids))).all() + if profile_ids + else [] + ) + profiles_by_id = {item.id: item for item in profiles} + envelopes = ( + session.scalars( + select(CapabilityResourceEnvelope).where( + CapabilityResourceEnvelope.capability_deployment_id.in_(deployment_ids) ) + ).all() + if deployment_ids + else [] + ) + envelopes_by_deployment = {item.capability_deployment_id: item for item in envelopes} + latest_evaluations: dict[UUID, CapabilityEvaluationRun] = {} + if deployment_ids: + evaluation_candidate = aliased(CapabilityEvaluationRun) + latest_evaluation_id = ( + select(evaluation_candidate.id) + .where( + evaluation_candidate.capability_deployment_id == CapabilityDeployment.id + ) + .order_by(evaluation_candidate.created_at.desc()) + .limit(1) + .correlate(CapabilityDeployment) + .scalar_subquery() + ) + for evaluation in session.scalars( + select(CapabilityEvaluationRun) + .join( + CapabilityDeployment, + CapabilityDeployment.id == CapabilityEvaluationRun.capability_deployment_id, + ) + .where( + CapabilityDeployment.id.in_(deployment_ids), + CapabilityEvaluationRun.id == latest_evaluation_id, + ) + ): + latest_evaluations[evaluation.capability_deployment_id] = evaluation + + responses: list[CapabilityEstateResponse] = [] + for manifest in manifests: + contract = contracts_by_identity.get((manifest.capability, manifest.version)) + deployment = deployments_by_contract.get(contract.id) if contract else None + artifact_set = artifact_sets_by_id.get(deployment.artifact_set_id) if deployment else None + revision = revisions_by_id.get(artifact_set.revision_id) if artifact_set else None + model = models_by_id.get(revision.model_id) if revision else None + profile = profiles_by_id.get(deployment.runtime_profile_id) if deployment else None + envelope = envelopes_by_deployment.get(deployment.id) if deployment else None + latest_evaluation = latest_evaluations.get(deployment.id) if deployment else None operational_state = ( deployment.status if deployment @@ -157,67 +246,142 @@ def capability_estate( def model_installation_rationale( session: Session = Depends(get_session), ) -> list[ModelInstallationRationaleResponse]: - responses: list[ModelInstallationRationaleResponse] = [] - for model in session.scalars(select(Model).order_by(Model.display_name)).all(): - revisions = session.scalars( - select(ModelRevision).where(ModelRevision.model_id == model.id) - ).all() - revision_ids = [item.id for item in revisions] - sets = ( - session.scalars(select(ArtifactSet).where(ArtifactSet.revision_id.in_(revision_ids))).all() - if revision_ids - else [] + models = session.scalars(select(Model).order_by(Model.display_name)).all() + installed_sets_by_model: dict[UUID, list[ArtifactSet]] = defaultdict(list) + if models: + for artifact_set, model_id in session.execute( + select(ArtifactSet, ModelRevision.model_id) + .join(ModelRevision, ModelRevision.id == ArtifactSet.revision_id) + .join(Model, Model.id == ModelRevision.model_id) + .where(ArtifactSet.availability == "local") + ): + installed_sets_by_model[model_id].append(artifact_set) + + installed_sets = [ + artifact_set + for model in models + for artifact_set in installed_sets_by_model.get(model.id, []) + ] + deployments_by_artifact_set: dict[UUID, list[CapabilityDeployment]] = defaultdict(list) + if installed_sets: + for deployment in session.scalars( + select(CapabilityDeployment) + .join(ArtifactSet, ArtifactSet.id == CapabilityDeployment.artifact_set_id) + .join(ModelRevision, ModelRevision.id == ArtifactSet.revision_id) + .join(Model, Model.id == ModelRevision.model_id) + .where(ArtifactSet.availability == "local") + ): + deployments_by_artifact_set[deployment.artifact_set_id].append(deployment) + + contract_and_capability_by_deployment: dict[ + UUID, tuple[CapabilityContract, Capability] + ] = {} + if installed_sets: + for deployment, contract, capability in session.execute( + select(CapabilityDeployment, CapabilityContract, Capability) + .join(ArtifactSet, ArtifactSet.id == CapabilityDeployment.artifact_set_id) + .join(ModelRevision, ModelRevision.id == ArtifactSet.revision_id) + .join(Model, Model.id == ModelRevision.model_id) + .join( + CapabilityContract, + CapabilityContract.id == CapabilityDeployment.capability_contract_id, + ) + .join(Capability, Capability.id == CapabilityContract.capability_id) + .where(ArtifactSet.availability == "local") + ): + contract_and_capability_by_deployment[deployment.id] = (contract, capability) + + eligible_deployments = ( + select( + CapabilityDeployment.id.label("deployment_id"), + CapabilityDeployment.capability_contract_id.label("contract_id"), ) - installed_sets = [item for item in sets if item.availability == "local"] + .join(ArtifactSet, ArtifactSet.id == CapabilityDeployment.artifact_set_id) + .join(ModelRevision, ModelRevision.id == ArtifactSet.revision_id) + .join(Model, Model.id == ModelRevision.model_id) + .join( + CapabilityContract, + CapabilityContract.id == CapabilityDeployment.capability_contract_id, + ) + .join(Capability, Capability.id == CapabilityContract.capability_id) + .where(ArtifactSet.availability == "local") + .subquery() + ) + eligible_contract_ids = select(eligible_deployments.c.contract_id).distinct() + eligible_deployment_ids = select(eligible_deployments.c.deployment_id) + + project_consumers: dict[UUID, list[str]] = defaultdict(list) + if installed_sets: + for contract_id, project_key in session.execute( + select(ProjectBinding.capability_contract_id, Project.key) + .join(Project, Project.id == ProjectBinding.project_id) + .where( + ProjectBinding.capability_contract_id.in_(eligible_contract_ids), + ProjectBinding.deprecated_at.is_(None), + ) + .distinct() + ): + project_consumers[contract_id].append(project_key) + + active_project_consumers: dict[UUID, list[str]] = defaultdict(list) + if installed_sets: + for contract_id, project_key in session.execute( + select(ProjectBinding.capability_contract_id, Project.key) + .join(Project, Project.id == ProjectBinding.project_id) + .join(ServiceClient, ServiceClient.project_binding_id == ProjectBinding.id) + .where( + ProjectBinding.capability_contract_id.in_(eligible_contract_ids), + ServiceClient.status == "active", + ) + .distinct() + ): + active_project_consumers[contract_id].append(project_key) + + fit_evidence_ids: dict[UUID, list[UUID]] = defaultdict(list) + if installed_sets: + for contract_id, evidence_id in session.execute( + select(ProjectBinding.capability_contract_id, ProjectFitEvidence.id) + .join(ProjectBinding, ProjectBinding.id == ProjectFitEvidence.project_binding_id) + .where(ProjectBinding.capability_contract_id.in_(eligible_contract_ids)) + ): + fit_evidence_ids[contract_id].append(evidence_id) + + evaluation_ids: dict[UUID, list[UUID]] = defaultdict(list) + if installed_sets: + for deployment_id, evaluation_id in session.execute( + select( + CapabilityEvaluationRun.capability_deployment_id, + CapabilityEvaluationRun.id, + ).where( + CapabilityEvaluationRun.capability_deployment_id.in_(eligible_deployment_ids) + ) + ): + evaluation_ids[deployment_id].append(evaluation_id) + + residency_by_deployment = ( + { + item.capability_deployment_id: item + for item in session.scalars( + select(ResidencyAllocation).where( + ResidencyAllocation.capability_deployment_id.in_(eligible_deployment_ids) + ) + ) + } + if installed_sets + else {} + ) + + responses: list[ModelInstallationRationaleResponse] = [] + for model in models: + model_installed_sets = installed_sets_by_model.get(model.id, []) dependencies: list[InstallationDependencyResponse] = [] - for artifact_set in installed_sets: - deployments = session.scalars( - select(CapabilityDeployment).where( - CapabilityDeployment.artifact_set_id == artifact_set.id - ) - ).all() - for deployment in deployments: - contract = session.get(CapabilityContract, deployment.capability_contract_id) - capability = session.get(Capability, contract.capability_id) if contract else None - if not contract or not capability: + for artifact_set in model_installed_sets: + for deployment in deployments_by_artifact_set.get(artifact_set.id, []): + relation = contract_and_capability_by_deployment.get(deployment.id) + if not relation: continue - projects = session.scalars( - select(Project) - .join(ProjectBinding, ProjectBinding.project_id == Project.id) - .where( - ProjectBinding.capability_contract_id == contract.id, - ProjectBinding.deprecated_at.is_(None), - ) - .distinct() - ).all() - active_projects = session.scalars( - select(Project) - .join(ProjectBinding, ProjectBinding.project_id == Project.id) - .join(ServiceClient, ServiceClient.project_binding_id == ProjectBinding.id) - .where( - ProjectBinding.capability_contract_id == contract.id, - ServiceClient.status == "active", - ) - .distinct() - ).all() - project_fit_ids = session.scalars( - select(ProjectFitEvidence.id) - .join( - ProjectBinding, - ProjectBinding.id == ProjectFitEvidence.project_binding_id, - ) - .where(ProjectBinding.capability_contract_id == contract.id) - ).all() - evaluations = session.scalars( - select(CapabilityEvaluationRun).where( - CapabilityEvaluationRun.capability_deployment_id == deployment.id - ) - ).all() - residency = session.scalar( - select(ResidencyAllocation).where( - ResidencyAllocation.capability_deployment_id == deployment.id - ) - ) + contract, capability = relation + residency = residency_by_deployment.get(deployment.id) dependencies.append( InstallationDependencyResponse( capability=capability.key, @@ -225,10 +389,10 @@ def model_installation_rationale( deployment_id=deployment.id, channel=deployment.channel, production=deployment.production, - project_consumers=[item.key for item in projects], - active_project_consumers=[item.key for item in active_projects], - project_fit_evidence_ids=list(project_fit_ids), - evaluation_run_ids=[item.id for item in evaluations], + project_consumers=project_consumers.get(contract.id, []), + active_project_consumers=active_project_consumers.get(contract.id, []), + project_fit_evidence_ids=fit_evidence_ids.get(contract.id, []), + evaluation_run_ids=evaluation_ids.get(deployment.id, []), last_used_at=residency.last_used_at if residency else None, ) ) @@ -242,10 +406,10 @@ def model_installation_rationale( model_id=model.id, display_name=model.display_name, upstream_source=model.upstream_source, - installed=bool(installed_sets), - installed_bytes=sum(item.total_size_bytes for item in installed_sets), + installed=bool(model_installed_sets), + installed_bytes=sum(item.total_size_bytes for item in model_installed_sets), dependencies=dependencies, - can_delete=bool(installed_sets) and not blockers, + can_delete=bool(model_installed_sets) and not blockers, deletion_blockers=blockers, ) ) @@ -255,8 +419,9 @@ def model_installation_rationale( @router.get("/projects", response_model=list[ProjectResponse]) def list_projects( registry: ManifestRegistry = Depends(get_manifest_registry), + session: Session = Depends(get_session), ) -> list[ProjectResponse]: - return [ + declared = [ ProjectResponse( **project.project.model_dump(), notes=project.notes, @@ -271,3 +436,57 @@ def list_projects( ) for project in registry.projects() ] + responses = {project.id: project for project in declared} + projects = session.scalars( + select(Project).options(selectinload(Project.bindings)).order_by(Project.key) + ).all() + contract_ids = { + binding.capability_contract_id + for project in projects + for binding in project.bindings + if binding.deprecated_at is None + } + contracts = ( + { + item.id: item + for item in session.scalars( + select(CapabilityContract).where(CapabilityContract.id.in_(contract_ids)) + ) + } + if contract_ids + else {} + ) + capability_ids = {item.capability_id for item in contracts.values()} + capabilities = ( + { + item.id: item + for item in session.scalars( + select(Capability).where(Capability.id.in_(capability_ids)) + ) + } + if capability_ids + else {} + ) + for item in projects: + bindings = [] + for binding in item.bindings: + if binding.deprecated_at: + continue + contract = contracts.get(binding.capability_contract_id) + capability = capabilities.get(contract.capability_id) if contract else None + if not contract or not capability: + continue + bindings.append(ProjectBindingResponse( + capability=capability.key, contract_version=contract.version, + binding=ProjectBindingManifest.model_validate({ + "contract_version": contract.version, "channel": binding.channel, + "priority": binding.priority, "optional": binding.optional, + "fallback": binding.fallback_policy, "migration_support": binding.migration_support, + "slo": binding.slo, "benchmark_suites": binding.benchmark_requirements, + }), + )) + responses[item.key] = ProjectResponse( + id=item.key, name=item.name, description=item.description, bindings=bindings, + notes=responses[item.key].notes if item.key in responses else [], + ) + return sorted(responses.values(), key=lambda item: item.id) diff --git a/backend/src/modelforge_api/api/routes/configuration.py b/backend/src/modelforge_api/api/routes/configuration.py new file mode 100644 index 0000000..0f06dbd --- /dev/null +++ b/backend/src/modelforge_api/api/routes/configuration.py @@ -0,0 +1,154 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query, Request +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Admin, Operator, Viewer, require_viewer +from modelforge_api.db import get_session +from modelforge_api.domain.configuration import ( + ConfigurationApplied, + ConfigurationApply, + ConfigurationCatalogResponse, + ConfigurationChange, + ConfigurationExport, + ConfigurationImport, + ConfigurationPreview, + ConfigurationProposal, + ConfigurationRevisionResponse, + ConfigurationRollback, +) +from modelforge_api.domain.secret_slots import ( + SecretSlotCatalog, + SecretSlotMetadata, + SecretSlotRevoke, + SecretSlotSet, + SecretSlotVersionRequest, +) +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.configuration import build_configuration_catalog +from modelforge_api.services.configuration_changes import ( + ConfigurationService, + resolved_configuration, +) +from modelforge_api.services.secret_slots import SecretSlotService +from modelforge_api.settings import Settings, get_settings + +router = APIRouter( + prefix="/api/v1/configuration", + tags=["configuration"], + dependencies=[Depends(require_viewer)], +) +Database = Annotated[Session, Depends(get_session)] +RuntimeSettings = Annotated[Settings, Depends(get_settings)] + + +def get_configuration_service( + session: Database, + settings: RuntimeSettings, + principal: Viewer, + request: Request, +) -> ConfigurationService: + return ConfigurationService( + session, + settings, + AuditContext( + actor_type="operator", + actor_id=principal.subject, + correlation_id=request.state.correlation_id, + ), + ) + + +Service = Annotated[ConfigurationService, Depends(get_configuration_service)] + + +@router.get("/settings", response_model=ConfigurationCatalogResponse) +def configuration_settings( + session: Database, + settings: RuntimeSettings, +) -> ConfigurationCatalogResponse: + revision, overrides, effective = resolved_configuration(session, settings) + return build_configuration_catalog( + effective, + revision=revision, + overrides=overrides, + ) + + +@router.post("/preview", response_model=ConfigurationPreview) +def configuration_preview( + body: ConfigurationChange, service: Service, _principal: Operator +) -> ConfigurationPreview: + return service.preview(body) + + +@router.post("/apply", response_model=ConfigurationApplied) +def configuration_apply( + body: ConfigurationApply, service: Service, _principal: Admin +) -> ConfigurationApplied: + return service.apply(body) + + +@router.get("/history", response_model=list[ConfigurationRevisionResponse]) +def configuration_history( + service: Service, limit: Annotated[int, Query(ge=1, le=100)] = 30 +) -> list[ConfigurationRevisionResponse]: + return service.history(limit) + + +@router.post("/revisions/{revision}/preview", response_model=ConfigurationProposal) +def configuration_rollback_preview( + revision: Annotated[int, Path(ge=0)], + body: ConfigurationRollback, + service: Service, + _principal: Operator, +) -> ConfigurationProposal: + change = service.rollback_change(revision, body.expected_revision) + return ConfigurationProposal(change=change, preview=service.preview(change)) + + +@router.get("/export", response_model=ConfigurationExport) +def configuration_export(service: Service) -> ConfigurationExport: + return service.export() + + +@router.post("/import/preview", response_model=ConfigurationProposal) +def configuration_import_preview( + body: ConfigurationImport, + service: Service, + _principal: Operator, +) -> ConfigurationProposal: + change = service.import_change(body) + return ConfigurationProposal(change=change, preview=service.preview(change)) + + +def secret_slot_service(session: Database, settings: RuntimeSettings, principal: Viewer, request: Request) -> SecretSlotService: + return SecretSlotService(session, settings, principal, request.state.correlation_id) + + +Secrets = Annotated[SecretSlotService, Depends(secret_slot_service)] + + +@router.get("/secret-slots", response_model=SecretSlotCatalog) +def secret_slots(service: Secrets) -> SecretSlotCatalog: + return service.catalog() + + +@router.post("/secret-slots/huggingface_control_plane/set", response_model=SecretSlotMetadata) +def secret_slot_set(body: SecretSlotSet, service: Secrets, _admin: Admin) -> SecretSlotMetadata: + return service.set(body) + + +@router.post("/secret-slots/huggingface_control_plane/test", response_model=SecretSlotMetadata) +def secret_slot_test(body: SecretSlotVersionRequest, service: Secrets, _admin: Admin) -> SecretSlotMetadata: + return service.test(body) + + +@router.post("/secret-slots/huggingface_control_plane/activate", response_model=SecretSlotMetadata) +def secret_slot_activate(body: SecretSlotVersionRequest, service: Secrets, _admin: Admin) -> SecretSlotMetadata: + return service.activate(body) + + +@router.post("/secret-slots/huggingface_control_plane/revoke", response_model=SecretSlotMetadata) +def secret_slot_revoke(body: SecretSlotRevoke, service: Secrets, _admin: Admin) -> SecretSlotMetadata: + return service.revoke(body) diff --git a/backend/src/modelforge_api/api/routes/external_creative.py b/backend/src/modelforge_api/api/routes/external_creative.py new file mode 100644 index 0000000..7b41527 --- /dev/null +++ b/backend/src/modelforge_api/api/routes/external_creative.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Header, Request, status + +from modelforge_api.api.authorization import Operator +from modelforge_api.api.routes.serving import Service, _authenticate_capability_client +from modelforge_api.domain.external_creative import ( + ExternalAllocationAction, + ExternalAllocationCreate, + ExternalAllocationList, + ExternalAllocationReconcile, + ExternalAllocationResponse, + ExternalProfileObservation, + ExternalResourceApproval, + ExternalResourceList, + ExternalResourceResponse, +) +from modelforge_api.persistence.models import ServiceClient +from modelforge_api.services.external_creative import ExternalCreativeService + +router = APIRouter(tags=["creative-resource-coordination"]) + + +def creative_client( + request: Request, + service: Service, + authorization: Annotated[str | None, Header(alias="Authorization")] = None, +) -> ServiceClient: + return _authenticate_capability_client(request, service, authorization, "creative.resource@1") + + +Client = Annotated[ServiceClient, Depends(creative_client)] + + +@router.post( + "/api/v1/capabilities/creative.resource@1/allocations", + response_model=ExternalAllocationResponse, + status_code=status.HTTP_201_CREATED, +) +def acquire( + body: ExternalAllocationCreate, service: Service, client: Client +) -> ExternalAllocationResponse: + return ExternalCreativeService(service.session, service).acquire(body, client) + + +@router.get( + "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}", + response_model=ExternalAllocationResponse, +) +def allocation( + allocation_id: uuid.UUID, service: Service, client: Client +) -> ExternalAllocationResponse: + return ExternalCreativeService(service.session, service).get(allocation_id, client) + + +def _action( + allocation_id: uuid.UUID, + body: ExternalAllocationAction, + action: str, + service: Service, + client: ServiceClient, +) -> ExternalAllocationResponse: + return ExternalCreativeService(service.session, service).action( + allocation_id, + body.request_id, + action, + client, + body.observed_peak_vram_bytes, + body.attestation, + ) + + +@router.post( + "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/heartbeat", + response_model=ExternalAllocationResponse, +) +def heartbeat( + allocation_id: uuid.UUID, body: ExternalAllocationAction, service: Service, client: Client +) -> ExternalAllocationResponse: + return _action(allocation_id, body, "heartbeat", service, client) + + +@router.post( + "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/attest", + response_model=ExternalAllocationResponse, +) +def attest( + allocation_id: uuid.UUID, body: ExternalAllocationAction, service: Service, client: Client +) -> ExternalAllocationResponse: + return _action(allocation_id, body, "attest", service, client) + + +@router.post( + "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/release", + response_model=ExternalAllocationResponse, +) +def release( + allocation_id: uuid.UUID, body: ExternalAllocationAction, service: Service, client: Client +) -> ExternalAllocationResponse: + return _action(allocation_id, body, "release", service, client) + + +@router.post( + "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/uncertain", + response_model=ExternalAllocationResponse, +) +def uncertain( + allocation_id: uuid.UUID, body: ExternalAllocationAction, service: Service, client: Client +) -> ExternalAllocationResponse: + return _action(allocation_id, body, "uncertain", service, client) + + +@router.post( + "/api/v1/capabilities/creative.resource@1/profiles/observations", + response_model=ExternalResourceResponse, +) +def observe_profile( + body: ExternalProfileObservation, service: Service, client: Client +) -> ExternalResourceResponse: + return ExternalCreativeService(service.session, service).observe(body, client) + + +@router.put( + "/api/v1/operator/creative-resource/resources", + response_model=ExternalResourceResponse, +) +def configure_resource( + body: ExternalResourceApproval, service: Service, operator: Operator +) -> ExternalResourceResponse: + return ExternalCreativeService(service.session, service).upsert_resource(body, operator.subject) + + +@router.get( + "/api/v1/operator/creative-resource/resources", + response_model=ExternalResourceList, +) +def operator_resources( + service: Service, + _operator: Operator, + project_id: uuid.UUID | None = None, + limit: int = 50, + offset: int = 0, +) -> ExternalResourceList: + return ExternalCreativeService(service.session, service).list_resources( + project_id, + min(max(limit, 1), 200), + max(offset, 0), + ) + + +@router.get( + "/api/v1/operator/creative-resource/allocations", + response_model=ExternalAllocationList, +) +def operator_allocations( + service: Service, + _operator: Operator, + state: str | None = None, + limit: int = 50, + offset: int = 0, +) -> ExternalAllocationList: + if state not in {None, "granted", "queued", "blocked", "uncertain", "released"}: + from fastapi import HTTPException + + raise HTTPException(status_code=422, detail="unsupported allocation state") + return ExternalCreativeService(service.session, service).list_allocations( + state, min(max(limit, 1), 200), max(offset, 0) + ) + + +@router.post( + "/api/v1/operator/creative-resource/allocations/{allocation_id}/reconcile", + response_model=ExternalAllocationResponse, +) +def reconcile_allocation( + allocation_id: uuid.UUID, + body: ExternalAllocationReconcile, + service: Service, + operator: Operator, +) -> ExternalAllocationResponse: + return ExternalCreativeService(service.session, service).reconcile( + allocation_id, + body.action, + body.reason, + operator.subject, + ) diff --git a/backend/src/modelforge_api/api/routes/identity.py b/backend/src/modelforge_api/api/routes/identity.py new file mode 100644 index 0000000..1fbf0d6 --- /dev/null +++ b/backend/src/modelforge_api/api/routes/identity.py @@ -0,0 +1,79 @@ +from datetime import UTC, datetime +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Admin, PrincipalRole, Viewer +from modelforge_api.db import get_session +from modelforge_api.domain.managed_identity import ( + ManagedIdentityChange, + ManagedIdentityCreate, + ManagedIdentityIssued, + ManagedIdentityList, + ManagedIdentityResult, + ManagedIdentityRevoke, + ManagedIdentityRotate, +) +from modelforge_api.services.operator_identities import ManagedIdentityService +from modelforge_api.settings import Settings, get_settings + +router = APIRouter(prefix="/api/v1/operator", tags=["operator-identity"]) + + +class CurrentOperator(BaseModel): + subject: str + role: PrincipalRole + authentication_method: str + credential_id: str | None + credential_expires_at: datetime | None + server_time: datetime + console_lock_after_seconds: Literal[900] = 900 + + +@router.get("/identity", response_model=CurrentOperator) +def current_operator(principal: Viewer) -> CurrentOperator: + return CurrentOperator( + subject=principal.subject, + role=principal.role, + authentication_method=principal.authentication_method, + credential_id=principal.credential_id, + credential_expires_at=principal.expires_at, + server_time=datetime.now(UTC), + ) + + +def managed_identity_service( + session: Annotated[Session, Depends(get_session)], principal: Admin, request: Request, +) -> ManagedIdentityService: + return ManagedIdentityService(session, principal, request.state.correlation_id) + + +Service = Annotated[ManagedIdentityService, Depends(managed_identity_service)] + + +@router.get("/managed-identities", response_model=ManagedIdentityList) +def managed_identities(service: Service, _admin: Admin, + settings: Annotated[Settings, Depends(get_settings)]) -> ManagedIdentityList: + return service.list(settings) + + +@router.post("/managed-identities", response_model=ManagedIdentityIssued, status_code=201) +def create_managed_identity(body: ManagedIdentityCreate, service: Service, _admin: Admin) -> ManagedIdentityIssued: + return service.create(body) + + +@router.post("/managed-identities/{slug}/rotate", response_model=ManagedIdentityIssued) +def rotate_managed_identity(slug: str, body: ManagedIdentityRotate, service: Service, _admin: Admin) -> ManagedIdentityIssued: + return service.rotate(slug, body) + + +@router.patch("/managed-identities/{slug}", response_model=ManagedIdentityResult) +def change_managed_identity(slug: str, body: ManagedIdentityChange, service: Service, _admin: Admin) -> ManagedIdentityResult: + return service.change(slug, body) + + +@router.post("/managed-identities/{slug}/revoke", response_model=ManagedIdentityResult) +def revoke_managed_identity(slug: str, body: ManagedIdentityRevoke, service: Service, _admin: Admin) -> ManagedIdentityResult: + return service.revoke(slug, body) diff --git a/backend/src/modelforge_api/api/routes/lab.py b/backend/src/modelforge_api/api/routes/lab.py new file mode 100644 index 0000000..12bfdcb --- /dev/null +++ b/backend/src/modelforge_api/api/routes/lab.py @@ -0,0 +1,162 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Header, Response, status +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Operator, require_operator +from modelforge_api.api.routes.serving import Service as ServingDependency +from modelforge_api.db import get_session +from modelforge_api.domain.benchmarking import ( + BenchmarkRunResponse, + BenchmarkRunStartRequest, + BenchmarkSuiteResponse, +) +from modelforge_api.domain.lab import ( + LabExecutionRunResponse, + LabExecutionStartRequest, + LabPreflightRequest, + LabPreflightResponse, +) +from modelforge_api.services.benchmark_runs import BenchmarkRunService +from modelforge_api.services.lab import LabPreflightService +from modelforge_api.services.lab_runs import LabRunService +from modelforge_api.settings import Settings, get_settings + +router = APIRouter( + prefix="/api/v1/lab", tags=["model-lab"], dependencies=[Depends(require_operator)] +) + + +def get_lab_service( + session: Annotated[Session, Depends(get_session)], + settings: Annotated[Settings, Depends(get_settings)], +) -> LabPreflightService: + return LabPreflightService(session, settings) + + +def get_lab_run_service(serving: ServingDependency) -> LabRunService: + return LabRunService(serving) + + +def get_benchmark_run_service(serving: ServingDependency) -> BenchmarkRunService: + return BenchmarkRunService(serving) + + +LabRunServiceDependency = Annotated[LabRunService, Depends(get_lab_run_service)] +BenchmarkRunServiceDependency = Annotated[BenchmarkRunService, Depends(get_benchmark_run_service)] + + +@router.post("/preflight", response_model=LabPreflightResponse) +def preflight( + request: LabPreflightRequest, + service: Annotated[LabPreflightService, Depends(get_lab_service)], +) -> LabPreflightResponse: + """Inspect a bounded LAB request without storing input or starting execution.""" + return service.preflight(request) + + +@router.post("/runs", response_model=LabExecutionRunResponse, status_code=status.HTTP_202_ACCEPTED) +def start_run( + request: LabExecutionStartRequest, + response: Response, + service: LabRunServiceDependency, + operator: Operator, + idempotency_key: Annotated[ + str, + Header( + alias="Idempotency-Key", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9._:-]+$" + ), + ], +) -> LabExecutionRunResponse: + response.headers["Cache-Control"] = "no-store" + return service.start(request, actor_subject=operator.subject, idempotency_key=idempotency_key) + + +@router.get("/runs/{run_id}", response_model=LabExecutionRunResponse) +def get_run( + run_id: uuid.UUID, + response: Response, + service: LabRunServiceDependency, + operator: Operator, +) -> LabExecutionRunResponse: + response.headers["Cache-Control"] = "no-store" + return service.get(run_id, actor_subject=operator.subject) + + +@router.post("/runs/{run_id}/cancel", response_model=LabExecutionRunResponse) +def cancel_run( + run_id: uuid.UUID, + response: Response, + service: LabRunServiceDependency, + operator: Operator, +) -> LabExecutionRunResponse: + response.headers["Cache-Control"] = "no-store" + return service.cancel(run_id, actor_subject=operator.subject) + + +@router.get("/benchmark-suites", response_model=list[BenchmarkSuiteResponse]) +def benchmark_suites( + response: Response, + service: BenchmarkRunServiceDependency, +) -> list[BenchmarkSuiteResponse]: + response.headers["Cache-Control"] = "no-store" + return service.suites() + + +@router.get("/benchmark-runs", response_model=list[BenchmarkRunResponse]) +def benchmark_history( + response: Response, + service: BenchmarkRunServiceDependency, + operator: Operator, + limit: int = 50, + cursor: str | None = None, +) -> list[BenchmarkRunResponse]: + response.headers["Cache-Control"] = "no-store" + page = service.list_page(actor_subject=operator.subject, limit=limit, cursor=cursor) + response.headers["X-Next-Cursor"] = page.next_cursor or "" + response.headers["Access-Control-Expose-Headers"] = "X-Next-Cursor" + return page.runs + + +@router.post( + "/benchmark-runs", + response_model=BenchmarkRunResponse, + status_code=status.HTTP_202_ACCEPTED, +) +def start_benchmark_run( + request: BenchmarkRunStartRequest, + response: Response, + service: BenchmarkRunServiceDependency, + operator: Operator, + idempotency_key: Annotated[ + str, + Header( + alias="Idempotency-Key", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9._:-]+$" + ), + ], +) -> BenchmarkRunResponse: + response.headers["Cache-Control"] = "no-store" + return service.start(request, actor_subject=operator.subject, idempotency_key=idempotency_key) + + +@router.get("/benchmark-runs/{run_id}", response_model=BenchmarkRunResponse) +def get_benchmark_run( + run_id: uuid.UUID, + response: Response, + service: BenchmarkRunServiceDependency, + operator: Operator, +) -> BenchmarkRunResponse: + response.headers["Cache-Control"] = "no-store" + return service.get(run_id, actor_subject=operator.subject) + + +@router.post("/benchmark-runs/{run_id}/cancel", response_model=BenchmarkRunResponse) +def cancel_benchmark_run( + run_id: uuid.UUID, + response: Response, + service: BenchmarkRunServiceDependency, + operator: Operator, +) -> BenchmarkRunResponse: + response.headers["Cache-Control"] = "no-store" + return service.cancel(run_id, actor_subject=operator.subject) diff --git a/backend/src/modelforge_api/api/routes/lifecycle.py b/backend/src/modelforge_api/api/routes/lifecycle.py index 95be55a..1d98bc6 100644 --- a/backend/src/modelforge_api/api/routes/lifecycle.py +++ b/backend/src/modelforge_api/api/routes/lifecycle.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from typing import Annotated -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, Query, Request, status from sqlalchemy.orm import Session from modelforge_api.api.authorization import Admin, require_viewer @@ -28,6 +28,7 @@ from modelforge_api.domain.lifecycle_contracts import ( RetentionPolicyCreate, RetentionPolicyResponse, ) +from modelforge_api.services.audit import AuditContext from modelforge_api.services.lifecycle import LifecycleService router = APIRouter( @@ -171,9 +172,13 @@ def operation( @router.post("/operations/{operation_id}/canary", response_model=LifecycleOperationResponse) def observe_canary( - operation_id: uuid.UUID, request: CanaryObservation, service: Service, _admin: Admin + operation_id: uuid.UUID, request: CanaryObservation, service: Service, _admin: Admin, + http_request: Request, ) -> LifecycleOperationResponse: - return service.observe_canary(operation_id, request) + return service.observe_canary(operation_id, request, audit_context=AuditContext( + actor_type="operator", actor_id=_admin.subject, + correlation_id=http_request.state.correlation_id, + )) @router.post("/operations/{operation_id}/commit", response_model=LifecycleOperationResponse) diff --git a/backend/src/modelforge_api/api/routes/migrations.py b/backend/src/modelforge_api/api/routes/migrations.py index 4abf928..d276499 100644 --- a/backend/src/modelforge_api/api/routes/migrations.py +++ b/backend/src/modelforge_api/api/routes/migrations.py @@ -60,8 +60,12 @@ def create_validation_policy( @router.get("/plans", response_model=list[MigrationPlanResponse]) -def plans(service: Service, _admin: Admin) -> list[MigrationPlanResponse]: - return service.plans() +def plans( + service: Service, + _admin: Admin, + limit: Annotated[int, Query(ge=1, le=200)] = 200, +) -> list[MigrationPlanResponse]: + return service.plans(limit=limit) @router.post("/plans", response_model=MigrationPlanResponse, status_code=status.HTTP_201_CREATED) diff --git a/backend/src/modelforge_api/api/routes/model_updates.py b/backend/src/modelforge_api/api/routes/model_updates.py new file mode 100644 index 0000000..82b5240 --- /dev/null +++ b/backend/src/modelforge_api/api/routes/model_updates.py @@ -0,0 +1,80 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Principal, require_operator, require_viewer +from modelforge_api.api.routes.acquisition import get_acquisition_service +from modelforge_api.db import get_session +from modelforge_api.domain.model_updates import ( + ModelUpdateMonitorResponse, + ModelUpdateNoticeResponse, + ModelWatchCreate, + ModelWatchResponse, + ModelWatchUpdate, +) +from modelforge_api.services.acquisition import AcquisitionService +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.model_updates import ModelUpdateService +from modelforge_api.settings import Settings, get_settings + +router = APIRouter(prefix="/api/v1/model-updates", tags=["model-updates"], + dependencies=[Depends(require_viewer)]) + + +def get_model_update_service( + request: Request, + session: Annotated[Session, Depends(get_session)], + acquisition: Annotated[AcquisitionService, Depends(get_acquisition_service)], + principal: Annotated[Principal, Depends(require_viewer)], +) -> ModelUpdateService: + context = AuditContext("operator", principal.subject, + getattr(request.state, "correlation_id", None)) + return ModelUpdateService(session, acquisition, context) + + +Service = Annotated[ModelUpdateService, Depends(get_model_update_service)] +Limit = Annotated[int, Query(ge=1, le=100)] +Offset = Annotated[int, Query(ge=0, le=10000)] + + +@router.get("/monitor", response_model=ModelUpdateMonitorResponse) +def monitor(settings: Annotated[Settings, Depends(get_settings)]) -> ModelUpdateMonitorResponse: + return ModelUpdateMonitorResponse( + enabled=settings.env == "production" and settings.model_update_monitor_enabled, + check_interval_seconds=settings.model_update_check_interval_seconds, + ) + + +@router.get("/watches", response_model=list[ModelWatchResponse]) +def watches(service: Service, limit: Limit = 50, offset: Offset = 0) -> list[ModelWatchResponse]: + return service.watches(limit, offset) + + +@router.post("/watches", response_model=ModelWatchResponse, dependencies=[Depends(require_operator)]) +def create(request: ModelWatchCreate, service: Service) -> ModelWatchResponse: + return service.create(request) + + +@router.post("/watches/{watch_id}/check", response_model=ModelWatchResponse, + dependencies=[Depends(require_operator)]) +def check(watch_id: UUID, service: Service) -> ModelWatchResponse: + return service.check(watch_id) + + +@router.patch("/watches/{watch_id}", response_model=ModelWatchResponse, + dependencies=[Depends(require_operator)]) +def change(watch_id: UUID, request: ModelWatchUpdate, service: Service) -> ModelWatchResponse: + return service.change(watch_id, request) + + +@router.get("/notices", response_model=list[ModelUpdateNoticeResponse]) +def notices(service: Service, limit: Limit = 50, offset: Offset = 0) -> list[ModelUpdateNoticeResponse]: + return service.notices(limit, offset) + + +@router.post("/notices/{notice_id}/dismiss", response_model=ModelUpdateNoticeResponse, + dependencies=[Depends(require_operator)]) +def dismiss(notice_id: UUID, service: Service) -> ModelUpdateNoticeResponse: + return service.dismiss(notice_id) diff --git a/backend/src/modelforge_api/api/routes/observability.py b/backend/src/modelforge_api/api/routes/observability.py index 3d48891..6b30b92 100644 --- a/backend/src/modelforge_api/api/routes/observability.py +++ b/backend/src/modelforge_api/api/routes/observability.py @@ -1,16 +1,21 @@ from __future__ import annotations import uuid -from typing import Annotated, Any +from typing import Annotated, Any, Literal -from fastapi import APIRouter, Depends, Query, Response, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from modelforge_api.api.authorization import Admin, require_viewer from modelforge_api.db import get_session +from modelforge_api.domain.alert_notifications import ( + NotificationDeliveryResponse, + NotificationTestResponse, +) from modelforge_api.domain.observability import ( + ActiveSchedulerLeaseInventoryResponse, AlertAction, AlertHistoryResponse, AlertResponse, @@ -28,12 +33,17 @@ from modelforge_api.domain.observability import ( TrendResponse, metrics, ) +from modelforge_api.domain.self_check import SelfCheckReport from modelforge_api.persistence.models import ( AlertHistoryEvent, IncidentTimelineEvent, OperationalIncident, ) +from modelforge_api.services.alert_notifications import AlertNotificationService +from modelforge_api.services.manifest_registry import ManifestRegistry, get_manifest_registry from modelforge_api.services.observability import ObservabilityService +from modelforge_api.services.self_check import run_self_check +from modelforge_api.settings import Settings, get_settings router = APIRouter(tags=["operations"], dependencies=[Depends(require_viewer)]) @@ -63,6 +73,47 @@ def overview(service: Service, _admin: Admin) -> OperationsOverview: return service.overview() +@router.get("/api/v1/admin/operations/self-check", response_model=SelfCheckReport) +def self_check( + _admin: Admin, + session: Annotated[Session, Depends(get_session)], + settings: Annotated[Settings, Depends(get_settings)], + registry: Annotated[ManifestRegistry, Depends(get_manifest_registry)], +) -> SelfCheckReport: + """Run passive diagnostics only; no repair, write probe, or external action is performed.""" + + return run_self_check(session, settings, registry) + + +@router.get( + "/api/v1/admin/operations/notification-deliveries", + response_model=list[NotificationDeliveryResponse], +) +def notification_deliveries( + session: Annotated[Session, Depends(get_session)], + settings: Annotated[Settings, Depends(get_settings)], + _admin: Admin, + limit: int = Query(default=100, ge=1, le=500), +) -> list[NotificationDeliveryResponse]: + return AlertNotificationService(session, settings).deliveries(limit) + + +@router.post( + "/api/v1/admin/operations/notifications/test", + response_model=NotificationTestResponse, + status_code=status.HTTP_202_ACCEPTED, +) +def queue_notification_test( + session: Annotated[Session, Depends(get_session)], + settings: Annotated[Settings, Depends(get_settings)], + principal: Admin, + request: Request, +) -> NotificationTestResponse: + return AlertNotificationService(session, settings).queue_test( + principal, getattr(request.state, "correlation_id", None) + ) + + @router.get( "/api/v1/admin/operations/slis", response_model=list[SLIDefinitionResponse] ) @@ -185,6 +236,20 @@ def capacity( return service.capacity(limit) +@router.get( + "/api/v1/admin/operations/active-leases", + response_model=ActiveSchedulerLeaseInventoryResponse, +) +def active_scheduler_leases( + service: Service, + _admin: Admin, + limit: int = Query(default=100, ge=1, le=500), + sort_by: Literal["expires_at", "created_at", "priority"] = Query(default="expires_at"), + order: Literal["asc", "desc"] = Query(default="asc"), +) -> ActiveSchedulerLeaseInventoryResponse: + return service.active_scheduler_leases(limit=limit, sort_by=sort_by, order=order) + + @router.post( "/api/v1/admin/operations/capacity/collect", response_model=list[CapacitySnapshotResponse] ) @@ -208,10 +273,20 @@ def apply_retention(service: Service, _admin: Admin) -> dict[str, int]: @router.get("/api/v1/admin/operations/incidents", response_model=list[IncidentResponse]) -def incidents(service: Service, _admin: Admin) -> list[IncidentResponse]: - rows = service.session.scalars( - select(OperationalIncident).order_by(OperationalIncident.last_seen_at.desc()) +def incidents( + service: Service, + _admin: Admin, + limit: int | None = Query(default=None, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> list[IncidentResponse]: + if limit is None and offset: + raise HTTPException(status_code=422, detail="offset requires limit") + statement = select(OperationalIncident).order_by( + OperationalIncident.last_seen_at.desc(), OperationalIncident.id ) + if limit is not None: + statement = statement.offset(offset).limit(limit) + rows = service.session.scalars(statement) return [IncidentResponse.model_validate(item) for item in rows] diff --git a/backend/src/modelforge_api/api/routes/operator_login.py b/backend/src/modelforge_api/api/routes/operator_login.py new file mode 100644 index 0000000..fd55ca8 --- /dev/null +++ b/backend/src/modelforge_api/api/routes/operator_login.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import hashlib +from datetime import UTC, datetime +from typing import Annotated + +import redis +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, ConfigDict, Field, SecretStr + +from modelforge_api.services.operator_password import ( + SESSION_SECONDS, + issue_session, + verify_password, +) +from modelforge_api.settings import Settings, get_settings + +router = APIRouter(prefix="/api/v1/operator", tags=["operator"]) +# One atomic operation across all API processes; keys expire even for failed attempts. +_RATE_SCRIPT = """ +local global = redis.call('INCR', KEYS[1]) +if global == 1 then redis.call('EXPIRE', KEYS[1], 60) end +if global > 30 then return 0 end +local peer = redis.call('INCR', KEYS[2]) +if peer == 1 then redis.call('EXPIRE', KEYS[2], 60) end +if peer > 5 then return 0 end +return 1 +""" + + +def limit_login(request: Request, settings: Settings) -> None: + # Use only the direct socket peer. Forwarded headers do not identify trusted clients. + peer = request.client.host if request.client else "unknown" + peer_digest = hashlib.sha256(peer.encode()).hexdigest() + try: + with redis.Redis.from_url( + settings.redis_url, socket_connect_timeout=2, socket_timeout=2 + ) as client: + accepted = client.eval( + _RATE_SCRIPT, 2, "modelforge:login:global", f"modelforge:login:peer:{peer_digest}" + ) + except (redis.RedisError, OSError, ValueError): + raise HTTPException(503, "Password login is temporarily unavailable") from None + if accepted != 1: + raise HTTPException(429, "Too many login attempts; try again later") + + +class PasswordLogin(BaseModel): + model_config = ConfigDict(extra="forbid") + password: SecretStr = Field(min_length=1, max_length=1024) + + +class PasswordSession(BaseModel): + access_token: str + expires_at: datetime + expires_in: int = SESSION_SECONDS + token_type: str = "bearer" # noqa: S105 - protocol token type, not a secret + + +@router.post("/login", response_model=PasswordSession) +def login( + payload: PasswordLogin, + request: Request, + response: Response, + settings: Annotated[Settings, Depends(get_settings)], +) -> PasswordSession: + # The pre-body middleware applies the shared limiter, including malformed requests. + configured = settings.operator_password_hash + bootstrap = settings.operator_api_key + if configured is None or bootstrap is None or len(bootstrap.get_secret_value()) < 32: + raise HTTPException(503, "Password login is not configured") + try: + valid = verify_password(payload.password.get_secret_value(), configured.get_secret_value()) + except (ValueError, UnicodeError): + raise HTTPException(503, "Password login is not configured") from None + if not valid: + raise HTTPException(401, "Invalid password") + token, expires = issue_session(bootstrap.get_secret_value(), configured.get_secret_value()) + response.headers["Cache-Control"] = "no-store" + return PasswordSession(access_token=token, expires_at=datetime.fromtimestamp(expires, UTC)) diff --git a/backend/src/modelforge_api/api/routes/portfolio.py b/backend/src/modelforge_api/api/routes/portfolio.py new file mode 100644 index 0000000..36d928d --- /dev/null +++ b/backend/src/modelforge_api/api/routes/portfolio.py @@ -0,0 +1,61 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Operator, Viewer, require_viewer +from modelforge_api.db import get_session +from modelforge_api.domain.portfolio import ( + PortfolioInventoryResponse, + PortfolioReconcileRequest, + PortfolioReconcileResponse, +) +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.portfolio_registry import ( + PortfolioRegistryError, + PortfolioRegistryService, +) + +router = APIRouter( + prefix="/api/v1/portfolio", + tags=["portfolio"], + dependencies=[Depends(require_viewer)], +) + + +def get_portfolio_registry_service( + session: Annotated[Session, Depends(get_session)], +) -> PortfolioRegistryService: + return PortfolioRegistryService(session) + + +Service = Annotated[PortfolioRegistryService, Depends(get_portfolio_registry_service)] + + +@router.get("", response_model=PortfolioInventoryResponse) +def portfolio_inventory(service: Service, _viewer: Viewer) -> PortfolioInventoryResponse: + return service.inventory() + + +@router.post( + "/reconcile", + response_model=PortfolioReconcileResponse, +) +def reconcile_portfolio( + body: PortfolioReconcileRequest, + service: Service, + operator: Operator, + request: Request, +) -> PortfolioReconcileResponse: + context = AuditContext( + actor_type="operator", + actor_id=operator.subject, + correlation_id=getattr(request.state, "correlation_id", None), + ) + try: + return service.reconcile(body, context) + except PortfolioRegistryError as exc: + raise HTTPException( + status_code=exc.status_code, + detail={"code": exc.code, "message": exc.message}, + ) from exc diff --git a/backend/src/modelforge_api/api/routes/projects.py b/backend/src/modelforge_api/api/routes/projects.py new file mode 100644 index 0000000..ab618cc --- /dev/null +++ b/backend/src/modelforge_api/api/routes/projects.py @@ -0,0 +1,172 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from modelforge_api.api.authorization import Operator, Viewer +from modelforge_api.api.routes.serving import Service as Serving +from modelforge_api.domain.model_changes import ( + ModelChangeCandidateLink, + ModelChangeCreate, + ModelChangeEvidenceLink, + ModelChangeList, + ModelChangeReject, + ModelChangeResponse, +) +from modelforge_api.domain.projects import ( + ProjectCreate, + ProjectRequirementCreate, + ProjectWorkspaceResponse, +) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.model_changes import ModelChangeError, ModelChangeService +from modelforge_api.services.projects import ProjectService + +router = APIRouter(prefix="/api/v1/project-workspace", tags=["project-onboarding"]) + + +def get_project_service(serving: Serving, principal: Viewer, request: Request) -> ProjectService: + serving.audit = AuditWriter(serving.session, context=AuditContext( + "operator", principal.subject, request.state.correlation_id, + )) + return ProjectService(serving) + + +def get_model_change_service( + serving: Serving, principal: Viewer, request: Request +) -> ModelChangeService: + return ModelChangeService( + serving.session, + AuditContext("operator", principal.subject, request.state.correlation_id), + ) + + +Service = Annotated[ProjectService, Depends(get_project_service)] +ChangeService = Annotated[ModelChangeService, Depends(get_model_change_service)] + + +def _change_error(exc: ModelChangeError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail=exc.message) + + +@router.get("", response_model=list[ProjectWorkspaceResponse]) +def projects( + service: Service, + _viewer: Viewer, + limit: int | None = Query(default=None, ge=1, le=200), + after_key: str | None = Query(default=None, min_length=1, max_length=128), +) -> list[ProjectWorkspaceResponse]: + if after_key is not None and limit is None: + raise HTTPException(status_code=422, detail="after_key requires limit") + return service.list(limit=limit, after_key=after_key) + + +@router.post("", response_model=ProjectWorkspaceResponse, status_code=201) +def create_project(body: ProjectCreate, service: Service, _operator: Operator) -> ProjectWorkspaceResponse: + return service.create(body) + + +@router.get("/changes", response_model=ModelChangeList) +def model_changes( + service: ChangeService, + _viewer: Viewer, + project_key: str | None = None, + status: str | None = None, + project_binding_id: UUID | None = None, + open_only: bool = False, + limit: int = Query(default=100, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> ModelChangeList: + try: + return service.list( + project_key=project_key, + status=status, + project_binding_id=project_binding_id, + open_only=open_only, + limit=limit, + offset=offset, + ) + except ModelChangeError as exc: + raise _change_error(exc) from exc + + +@router.post("/changes", response_model=ModelChangeResponse, status_code=201) +def create_model_change( + body: ModelChangeCreate, service: ChangeService, _operator: Operator +) -> ModelChangeResponse: + try: + return service.create(body) + except ModelChangeError as exc: + raise _change_error(exc) from exc + + +@router.get("/changes/{case_id}", response_model=ModelChangeResponse) +def model_change(case_id: UUID, service: ChangeService, _viewer: Viewer) -> ModelChangeResponse: + try: + return service.get(case_id) + except ModelChangeError as exc: + raise _change_error(exc) from exc + + +@router.put("/changes/{case_id}/candidate", response_model=ModelChangeResponse) +def link_model_change_candidate( + case_id: UUID, + body: ModelChangeCandidateLink, + service: ChangeService, + _operator: Operator, +) -> ModelChangeResponse: + try: + return service.link_candidate(case_id, body) + except ModelChangeError as exc: + raise _change_error(exc) from exc + + +@router.put("/changes/{case_id}/evidence", response_model=ModelChangeResponse) +def link_model_change_evidence( + case_id: UUID, + body: ModelChangeEvidenceLink, + service: ChangeService, + _operator: Operator, +) -> ModelChangeResponse: + try: + return service.link_evidence(case_id, body) + except ModelChangeError as exc: + raise _change_error(exc) from exc + + +@router.post("/changes/{case_id}/refresh", response_model=ModelChangeResponse) +def refresh_model_change( + case_id: UUID, + service: ChangeService, + _operator: Operator, + expected_version: int = Query(ge=1), +) -> ModelChangeResponse: + try: + return service.refresh(case_id, expected_version) + except ModelChangeError as exc: + raise _change_error(exc) from exc + + +@router.post("/changes/{case_id}/reject", response_model=ModelChangeResponse) +def reject_model_change( + case_id: UUID, + body: ModelChangeReject, + service: ChangeService, + _operator: Operator, +) -> ModelChangeResponse: + try: + return service.reject(case_id, body) + except ModelChangeError as exc: + raise _change_error(exc) from exc + + +@router.get("/{key}", response_model=ProjectWorkspaceResponse) +def project(key: str, service: Service, _viewer: Viewer) -> ProjectWorkspaceResponse: + return service.detail(key) + + +@router.post("/{key}/requirements", response_model=ProjectWorkspaceResponse, status_code=201) +def bind_project( + key: str, body: ProjectRequirementCreate, service: Service, _operator: Operator, +) -> ProjectWorkspaceResponse: + return service.bind(key, body) diff --git a/backend/src/modelforge_api/api/routes/recovery.py b/backend/src/modelforge_api/api/routes/recovery.py index a2fc7cd..a9a31f0 100644 --- a/backend/src/modelforge_api/api/routes/recovery.py +++ b/backend/src/modelforge_api/api/routes/recovery.py @@ -26,6 +26,7 @@ from modelforge_api.domain.recovery import ( RecoveryDashboard, RecoveryPolicyCreate, RecoveryPolicyResponse, + RecoveryRetentionPreview, RestoreAdvanceRequest, RestoreOperationEventResponse, RestoreOperationResponse, @@ -116,6 +117,11 @@ def apply_retention(service: Service, _admin: Admin) -> dict[str, int]: return service.apply_retention() +@router.get("/retention/preview", response_model=RecoveryRetentionPreview) +def preview_retention(service: Service, _admin: Admin) -> RecoveryRetentionPreview: + return service.preview_retention() + + @router.get("/restore-plans", response_model=list[RestorePlanResponse]) def restore_plans( service: Service, _admin: Admin, limit: int = Query(default=100, ge=1, le=1000) diff --git a/backend/src/modelforge_api/api/routes/selected_model_comparison.py b/backend/src/modelforge_api/api/routes/selected_model_comparison.py new file mode 100644 index 0000000..f48f577 --- /dev/null +++ b/backend/src/modelforge_api/api/routes/selected_model_comparison.py @@ -0,0 +1,106 @@ +"""Separate operator planning from designated project-consumer execution reports.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, Response, status +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Principal, require_operator, require_viewer +from modelforge_api.api.routes.serving import ( + Service as Serving, +) +from modelforge_api.api.routes.serving import ( + authenticate_rag_embedding_client, +) +from modelforge_api.db import get_session +from modelforge_api.domain.selected_model_comparison import ( + ComparisonCancel, + ComparisonClaim, + ComparisonCreate, + ComparisonFinish, + ComparisonList, + ComparisonProgress, + ComparisonResponse, + ComparisonResult, +) +from modelforge_api.persistence.models import ServiceClient +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.selected_model_comparison import SelectedModelComparisonService + +router = APIRouter(prefix="/api/v1/selected-model-comparisons", tags=["selected-model-comparisons"]) +Client = Annotated[ServiceClient, Depends(authenticate_rag_embedding_client)] +Limit = Annotated[int, Query(ge=1, le=100)] +Offset = Annotated[int, Query(ge=0, le=10000)] + + +def operator_service( + request: Request, + session: Annotated[Session, Depends(get_session)], + principal: Annotated[Principal, Depends(require_viewer)], +) -> SelectedModelComparisonService: + return SelectedModelComparisonService(session, audit_context=AuditContext( + "operator", principal.subject, getattr(request.state, "correlation_id", None), + )) + + +def consumer_service(request: Request, response: Response, serving: Serving, client: Client) -> SelectedModelComparisonService: + response.headers["Cache-Control"] = "no-store" + return SelectedModelComparisonService(serving.session, audit_context=AuditContext( + "service_client", str(client.id), getattr(request.state, "correlation_id", None), + )) + + +OperatorService = Annotated[SelectedModelComparisonService, Depends(operator_service)] +ConsumerService = Annotated[SelectedModelComparisonService, Depends(consumer_service)] + + +@router.get("", response_model=ComparisonList, dependencies=[Depends(require_viewer)]) +def history(service: OperatorService, project_binding_id: UUID | None = None, + limit: Limit = 50, offset: Offset = 0) -> ComparisonList: + return service.list(project_binding_id=project_binding_id, limit=limit, offset=offset) + + +@router.post("", response_model=ComparisonResponse, status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_operator)]) +def create(body: ComparisonCreate, service: OperatorService) -> ComparisonResponse: + return service.create(body) + + +@router.get("/assigned", response_model=ComparisonList) +def assigned(service: ConsumerService, client: Client, limit: Limit = 50) -> ComparisonList: + return service.assigned(client, limit=limit) + + +@router.get("/{comparison_id}", response_model=ComparisonResponse, dependencies=[Depends(require_viewer)]) +def detail(comparison_id: UUID, service: OperatorService) -> ComparisonResponse: + return service.get(comparison_id) + + +@router.post("/{comparison_id}/cancel", response_model=ComparisonResponse, + dependencies=[Depends(require_operator)]) +def cancel(comparison_id: UUID, body: ComparisonCancel, service: OperatorService) -> ComparisonResponse: + return service.cancel(comparison_id, body) + + +@router.post("/{comparison_id}/claim", response_model=ComparisonClaim) +def claim(comparison_id: UUID, service: ConsumerService, client: Client) -> ComparisonClaim: + return service.claim(comparison_id, client) + + +@router.post("/{comparison_id}/progress", response_model=ComparisonResponse) +def progress(comparison_id: UUID, body: ComparisonProgress, service: ConsumerService, + client: Client) -> ComparisonResponse: + return service.progress(comparison_id, body, client) + + +@router.post("/{comparison_id}/result", response_model=ComparisonResponse) +def result(comparison_id: UUID, body: ComparisonResult, service: ConsumerService, + client: Client) -> ComparisonResponse: + return service.result(comparison_id, body, client) + + +@router.post("/{comparison_id}/finish", response_model=ComparisonResponse) +def finish(comparison_id: UUID, body: ComparisonFinish, service: ConsumerService, + client: Client) -> ComparisonResponse: + return service.finish(comparison_id, body, client) diff --git a/backend/src/modelforge_api/api/routes/selected_replacement.py b/backend/src/modelforge_api/api/routes/selected_replacement.py new file mode 100644 index 0000000..2d08697 --- /dev/null +++ b/backend/src/modelforge_api/api/routes/selected_replacement.py @@ -0,0 +1,20 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Response +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import require_viewer +from modelforge_api.db import get_session +from modelforge_api.domain.selected_replacement import ReplacementPreview +from modelforge_api.services.selected_replacement import SelectedReplacementService + +router = APIRouter(prefix="/api/v1/selected-model-comparisons", tags=["selected-model-comparisons"]) + + +@router.get("/{comparison_id}/replacement-preview", response_model=ReplacementPreview, + dependencies=[Depends(require_viewer)]) +def preview(comparison_id: UUID, response: Response, + session: Annotated[Session, Depends(get_session)]) -> ReplacementPreview: + response.headers["Cache-Control"] = "no-store" + return SelectedReplacementService(session).preview(comparison_id) diff --git a/backend/src/modelforge_api/api/routes/serving.py b/backend/src/modelforge_api/api/routes/serving.py index 6f2b83f..bc289dd 100644 --- a/backend/src/modelforge_api/api/routes/serving.py +++ b/backend/src/modelforge_api/api/routes/serving.py @@ -7,10 +7,14 @@ from typing import Annotated from fastapi import APIRouter, Depends, Header, Query, Request, Response, status from sqlalchemy.orm import Session -from modelforge_api.api.authorization import Admin, require_viewer +from modelforge_api.api.authorization import Admin, require_viewer, required_capability_for_request from modelforge_api.api.routes.agent import AgentIdentity from modelforge_api.db import get_session +from modelforge_api.domain.projects import ProjectConnectivityResponse from modelforge_api.domain.serving import ( + AgentServingJobCancellationAck, + AgentServingJobCancellationCheck, + AgentServingJobCancellationStatus, AgentServingJobComplete, AgentServingJobFailure, AgentServingJobLease, @@ -48,6 +52,9 @@ from modelforge_api.domain.serving import ( ServiceClientCreate, ServiceClientCreated, ServiceClientResponse, + ServiceCredentialResponse, + ServiceCredentialRevoke, + ServiceCredentialRotate, SpeechTranscriptionInvokeRequest, SpeechTranscriptionInvokeResponse, StableEmbeddingInvokeResponse, @@ -55,6 +62,7 @@ from modelforge_api.domain.serving import ( VisionEmbeddingInvokeResponse, ) from modelforge_api.persistence.models import ServiceClient +from modelforge_api.services.audit import AuditContext, AuditWriter from modelforge_api.services.manifest_registry import ManifestRegistry, get_manifest_registry from modelforge_api.services.serving import ( CapabilityAuthenticationEvidence, @@ -146,6 +154,60 @@ SpeechTranscriptionClient = Annotated[ ] +def authenticate_check_client( + request: Request, + service: Service, + authorization: Annotated[str | None, Header(alias="Authorization")] = None, +) -> ServiceClient: + capability = required_capability_for_request(request.method, request.url.path) + if capability is None: + raise ServingError(403, "CAPABILITY_NOT_AUTHORIZED", "capability is not registered") + return _authenticate_capability_client(request, service, authorization, capability) + + +@router.get( + "/api/v1/capabilities/rag.embedding@1/check", response_model=ProjectConnectivityResponse +) +@router.get( + "/api/v1/capabilities/rag.reranking@1/check", response_model=ProjectConnectivityResponse +) +@router.get("/api/v1/capabilities/document.ocr@1/check", response_model=ProjectConnectivityResponse) +@router.get( + "/api/v1/capabilities/vision.embedding@1/check", response_model=ProjectConnectivityResponse +) +@router.get( + "/api/v1/capabilities/speech.transcription@1/check", response_model=ProjectConnectivityResponse +) +def check_project_connectivity( + request: Request, + response: Response, + service: Service, + client: Annotated[ServiceClient, Depends(authenticate_check_client)], +) -> ProjectConnectivityResponse: + response.headers["Cache-Control"] = "no-store" + capability = required_capability_for_request(request.method, request.url.path) + if capability is None: + raise ServingError(403, "CAPABILITY_NOT_AUTHORIZED", "capability is not registered") + service.audit = AuditWriter( + service.session, + context=AuditContext( + "service_client", + str(client.id), + request.state.correlation_id, + ), + ) + service.audit.write( + "PROJECT_CONNECTIVITY_CHECKED", "service_client", str(client.id), {"capability": capability} + ) + service.session.commit() + return ProjectConnectivityResponse( + client_id=client.id, + project_key=service.client_response(client).project_key, + capability=capability, + correlation_id=request.state.correlation_id, + ) + + @router.post( "/api/v1/admin/deployment-candidates/{candidate_id}/production-approvals", response_model=ProductionApprovalResponse, @@ -254,11 +316,74 @@ def create_service_client( service: Service, _admin: Admin, response: Response, + http_request: Request, ) -> ServiceClientCreated: response.headers["Cache-Control"] = "no-store" + service.audit = AuditWriter( + service.session, + context=AuditContext( + "operator", + _admin.subject, + http_request.state.correlation_id, + ), + ) return service.create_client(request) +@router.get( + "/api/v1/admin/service-clients/{client_id}/credentials", + response_model=list[ServiceCredentialResponse], +) +def service_credential_history( + client_id: uuid.UUID, service: Service, _admin: Admin +) -> list[ServiceCredentialResponse]: + return service.credential_history(client_id) + + +@router.post( + "/api/v1/admin/service-clients/{client_id}/credentials/rotate", + response_model=ServiceClientCreated, +) +def rotate_project_credential( + client_id: uuid.UUID, + body: ServiceCredentialRotate, + service: Service, + principal: Admin, + request: Request, +) -> ServiceClientCreated: + service.audit = AuditWriter( + service.session, + context=AuditContext( + "operator", + principal.subject, + request.state.correlation_id, + ), + ) + return service.rotate_client_credential(client_id, body) + + +@router.post( + "/api/v1/admin/service-clients/{client_id}/credentials/revoke", + response_model=ServiceClientResponse, +) +def revoke_project_credential( + client_id: uuid.UUID, + body: ServiceCredentialRevoke, + service: Service, + principal: Admin, + request: Request, +) -> ServiceClientResponse: + service.audit = AuditWriter( + service.session, + context=AuditContext( + "operator", + principal.subject, + request.state.correlation_id, + ), + ) + return service.revoke_client_credential(client_id, body) + + @router.get("/api/v1/admin/service-clients", response_model=list[ServiceClientResponse]) def service_clients(service: Service, _admin: Admin) -> list[ServiceClientResponse]: return service.clients() @@ -311,7 +436,7 @@ def record_project_fit_evidence( dependencies=[Depends(require_viewer)], ) def scheduler_overview(service: Service) -> list[SchedulerBudgetResponse]: - return service.scheduler_overview() + return service.scheduler_overview(commit=False, update_pressure=False) @router.get( @@ -569,6 +694,32 @@ def fail_serving_job( return {"status": "accepted"} +@router.post( + "/api/v1/agent/serving-jobs/{job_id}/cancellation", + response_model=AgentServingJobCancellationStatus, +) +def check_serving_job_cancellation( + job_id: uuid.UUID, + request: AgentServingJobCancellationCheck, + service: Service, + identity: AgentIdentity, +) -> AgentServingJobCancellationStatus: + _credential, node = identity + return service.check_lab_cancellation(job_id, node, request) + + +@router.post("/api/v1/agent/serving-jobs/{job_id}/cancelled") +def acknowledge_serving_job_cancellation( + job_id: uuid.UUID, + request: AgentServingJobCancellationAck, + service: Service, + identity: AgentIdentity, +) -> dict[str, str]: + _credential, node = identity + service.acknowledge_lab_cancellation(job_id, node, request) + return {"status": "accepted"} + + @router.post( "/api/v1/agent/serving-state", response_model=AgentServingStateAck, diff --git a/backend/src/modelforge_api/api/routes/update_preflight.py b/backend/src/modelforge_api/api/routes/update_preflight.py new file mode 100644 index 0000000..9237903 --- /dev/null +++ b/backend/src/modelforge_api/api/routes/update_preflight.py @@ -0,0 +1,36 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from modelforge_api.api.authorization import Admin +from modelforge_api.domain.release import build_identity +from modelforge_api.domain.update_preflight import UpdatePreflight, update_preflight +from modelforge_api.settings import Settings, get_settings + +router = APIRouter(prefix="/api/v1/admin/updates", tags=["update-preflight"]) + + +@router.get( + "/preflight", + response_model=UpdatePreflight, +) +def read_update_preflight( + _admin: Admin, + settings: Annotated[Settings, Depends(get_settings)], +) -> UpdatePreflight: + """Report local update evidence; schema is the app build target, not the applied DB revision.""" + + identity = build_identity( + source_commit=settings.build_commit, + built_at=settings.build_timestamp, + image_digest=settings.build_image_digest, + ) + return update_preflight( + version=identity.version, + channel=identity.channel, + schema_revision=identity.schema_revision, + agent_protocol_version=identity.agent_protocol_version, + source_commit=identity.source_commit, + built_at=identity.built_at, + image_digest=identity.image_digest, + ) diff --git a/backend/src/modelforge_api/domain/agent_protocol.py b/backend/src/modelforge_api/domain/agent_protocol.py index bdae529..d469fe9 100644 --- a/backend/src/modelforge_api/domain/agent_protocol.py +++ b/backend/src/modelforge_api/domain/agent_protocol.py @@ -123,6 +123,7 @@ class EnrollmentTokenSummary(AgentProtocolModel): expires_at: datetime used_at: datetime | None revoked_at: datetime | None + enrolled_node_id: str | None = None class NodeManagementUpdate(AgentProtocolModel): diff --git a/backend/src/modelforge_api/domain/alert_notifications.py b/backend/src/modelforge_api/domain/alert_notifications.py new file mode 100644 index 0000000..437c47b --- /dev/null +++ b/backend/src/modelforge_api/domain/alert_notifications.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class NotificationStatus(StrEnum): + PENDING = "PENDING" + DISPATCHING = "DISPATCHING" + RETRY_WAIT = "RETRY_WAIT" + DELIVERED = "DELIVERED" + FAILED = "FAILED" + HELD_FOR_REVIEW = "HELD_FOR_REVIEW" + + +class NotificationKind(StrEnum): + FIRING = "FIRING" + RESOLVED = "RESOLVED" + TEST = "TEST" + + +class NotificationDeliveryResponse(BaseModel): + """Safe operator-facing delivery metadata. Payload and destination are never returned.""" + + model_config = ConfigDict(extra="forbid", from_attributes=True) + + id: UUID + alert_history_event_id: UUID | None + route_key: str + event_kind: NotificationKind + severity: str | None + status: NotificationStatus + attempt_count: int + next_attempt_at: datetime | None + last_attempt_at: datetime | None + delivered_at: datetime | None + last_http_status: int | None + last_error_code: str | None + created_at: datetime + + +class NotificationTestResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + delivery: NotificationDeliveryResponse + message: str diff --git a/backend/src/modelforge_api/domain/audit_timeline.py b/backend/src/modelforge_api/domain/audit_timeline.py new file mode 100644 index 0000000..855aa30 --- /dev/null +++ b/backend/src/modelforge_api/domain/audit_timeline.py @@ -0,0 +1,69 @@ +"""Typed, bounded query and response models for the operator audit timeline.""" + +from __future__ import annotations + +from datetime import timedelta +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +AUDIT_TIMELINE_MAX_PAGE_SIZE = 100 +AUDIT_TIMELINE_MAX_TIME_RANGE = timedelta(days=366) + + +class AuditTimelineRequest(BaseModel): + """Validated filters for one bounded page of audit events. + + Text filters are case-insensitive substring matches. A time filter must specify both ends, + and its window is limited to one year plus one leap day. + """ + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + principal: str | None = Field(default=None, min_length=1, max_length=255) + action: str | None = Field(default=None, min_length=1, max_length=128) + resource: str | None = Field(default=None, min_length=1, max_length=128) + outcome: str | None = Field(default=None, min_length=1, max_length=32) + occurred_after: AwareDatetime | None = None + occurred_before: AwareDatetime | None = None + correlation_id: str | None = Field(default=None, min_length=1, max_length=64) + limit: int = Field(default=50, ge=1, le=AUDIT_TIMELINE_MAX_PAGE_SIZE) + cursor: str | None = Field(default=None, min_length=1, max_length=256) + + @model_validator(mode="after") + def validate_time_window(self) -> AuditTimelineRequest: + if (self.occurred_after is None) != (self.occurred_before is None): + raise ValueError("occurred_after and occurred_before must be supplied together") + if self.occurred_after is not None and self.occurred_before is not None: + if self.occurred_after >= self.occurred_before: + raise ValueError("occurred_after must be earlier than occurred_before") + if self.occurred_before - self.occurred_after > AUDIT_TIMELINE_MAX_TIME_RANGE: + raise ValueError("audit time range exceeds the 366-day maximum") + return self + + +class AuditTimelineEvent(BaseModel): + """Safe event fields returned to the operator console.""" + + model_config = ConfigDict(frozen=True) + + id: UUID + sequence: int = Field(ge=1) + occurred_at: AwareDatetime + correlation_id: str + actor_type: str + actor_id: str + action: str + resource_type: str + resource_id: str | None + outcome: str + + +class AuditTimelineResponse(BaseModel): + """One page of audit events and an opaque keyset cursor for the next page.""" + + model_config = ConfigDict(frozen=True) + + events: list[AuditTimelineEvent] + next_cursor: str | None + limit: int = Field(ge=1, le=AUDIT_TIMELINE_MAX_PAGE_SIZE) diff --git a/backend/src/modelforge_api/domain/benchmarking.py b/backend/src/modelforge_api/domain/benchmarking.py new file mode 100644 index 0000000..0808bbe --- /dev/null +++ b/backend/src/modelforge_api/domain/benchmarking.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import Field + +from modelforge_api.domain.contracts import BenchmarkRunContract, StrictModel +from modelforge_api.domain.lab import LabTarget + + +class BenchmarkSuiteResponse(StrictModel): + id: uuid.UUID + key: str + revision: str + capability: Literal["rag.embedding@1"] + dataset_revision: str + definition: dict[str, Any] + immutable_at: datetime + + +class BenchmarkRunStartRequest(StrictModel): + capability: Literal["rag.embedding@1"] + suite_id: uuid.UUID + target: LabTarget + deployment_id: uuid.UUID + experiment_route_id: uuid.UUID + + +BenchmarkRunStatus = Literal[ + "accepted", + "running", + "cancel_requested", + "completed", + "failed", + "cancelled", + "interrupted", +] + + +class BenchmarkRunProgress(StrictModel): + completed_invocations: int = Field(ge=0) + total_invocations: int = Field(ge=1) + active_case: str | None = None + + +class BenchmarkRunResponse(StrictModel): + id: uuid.UUID + capability: Literal["rag.embedding@1"] + suite_id: uuid.UUID + capability_deployment_id: uuid.UUID + status: BenchmarkRunStatus + contract: BenchmarkRunContract + target_snapshot: dict[str, Any] + progress: BenchmarkRunProgress + results: dict[str, Any] + failure_code: str | None = None + cancellation_requested_at: datetime | None = None + created_at: datetime + completed_at: datetime | None = None + + +class BenchmarkRunHistoryPage(StrictModel): + """A bounded page of actor-scoped benchmark history.""" + + runs: list[BenchmarkRunResponse] + next_cursor: str | None + limit: int = Field(ge=1, le=100) diff --git a/backend/src/modelforge_api/domain/configuration.py b/backend/src/modelforge_api/domain/configuration.py new file mode 100644 index 0000000..36ad841 --- /dev/null +++ b/backend/src/modelforge_api/domain/configuration.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from modelforge_api.domain.configuration_reference import Sensitivity + + +class ConfigurationSource(StrEnum): + """Source that supplied the effective value in Pydantic's unchanged priority order.""" + + PYDANTIC_DEFAULT = "pydantic_default" + INITIALIZATION = "initialization" + PROCESS_ENVIRONMENT = "process_environment" + DOTENV = "dotenv" + SECRETS_FILE = "secrets_file" + RUNTIME_UPDATE = "runtime_update" + OPERATOR_OVERRIDE = "operator_override" + + +class ConfigurationApplicationMode(StrEnum): + """How a setting can take effect in the current product contract.""" + + DYNAMIC = "dynamic" + RESTART_REQUIRED = "restart_required" + + +class ConfigurationScope(StrEnum): + CONTROL_PLANE = "control_plane" + + +class ConfigurationSettingResponse(BaseModel): + key: str + environment_variable: str + description: str + sensitivity: Sensitivity + scope: ConfigurationScope = ConfigurationScope.CONTROL_PLANE + required_in_production: bool + application_mode: ConfigurationApplicationMode + editable: bool = False + configured: bool + source: ConfigurationSource + value: str | int | float | bool | None = None + display_name: str = "" + category: str = "process" + data_type: str = "string" + default_value: str | int | float | bool | None = None + validation_rules: dict[str, str | int | float] = Field(default_factory=dict) + consumer: str | None = None + rollback: str = "Restore deployment configuration and restart the component." + + +class ConfigurationCatalogResponse(BaseModel): + environment: str + read_only: bool = True + settings: list[ConfigurationSettingResponse] = Field(default_factory=list) + revision: int = 0 + overrides: dict[str, int | float] = Field(default_factory=dict) + + +class ConfigurationChange(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + expected_revision: int = Field(ge=0) + # Null removes the operator override and reveals the deployment-resolved value. + changes: dict[str, int | float | None] = Field(min_length=1, max_length=14) + + +class ConfigurationApply(ConfigurationChange): + preview_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + confirm: Literal[True] + + +class ConfigurationDifference(BaseModel): + key: str + before: int | float + after: int | float + source_before: ConfigurationSource + source_after: ConfigurationSource + + +class ConfigurationPreview(BaseModel): + expected_revision: int + preview_digest: str + differences: list[ConfigurationDifference] + affected_components: list[str] + impact: str + rollback: str + overrides: dict[str, int | float] + + +class ConfigurationApplied(BaseModel): + revision: int + status: Literal["accepted_for_new_requests"] = "accepted_for_new_requests" + acknowledged_by: str + preview_digest: str + + +class ConfigurationRevisionResponse(BaseModel): + revision: int + previous_revision: int + created_at: datetime + actor: str + correlation_id: str + overrides: dict[str, int | float] + preview_digest: str + + +class ConfigurationExport(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + format: str + product: Literal["ModelForge"] = "ModelForge" + source_version: str + overrides: dict[str, int | float] = Field(max_length=14) + secret_slots: list[str] = Field(max_length=20) + + +class ConfigurationImport(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + expected_revision: int = Field(ge=0) + document: ConfigurationExport + + +class ConfigurationProposal(BaseModel): + change: ConfigurationChange + preview: ConfigurationPreview + + +class ConfigurationRollback(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + expected_revision: int = Field(ge=0) diff --git a/backend/src/modelforge_api/domain/configuration_policy.py b/backend/src/modelforge_api/domain/configuration_policy.py new file mode 100644 index 0000000..ca337be --- /dev/null +++ b/backend/src/modelforge_api/domain/configuration_policy.py @@ -0,0 +1,14 @@ +"""The closed set of settings with a proven per-request serving consumer.""" + +SERVING_OVERRIDE_KEYS = frozenset( + { + "gateway_max_batch_size", + "gateway_max_input_characters", + "gateway_request_timeout_seconds", + "gateway_queue_timeout_seconds", + "scheduler_telemetry_stale_seconds", + } +) + +CONFIGURATION_FORMAT = "modelforge.serving-configuration.v1" +CONFIGURATION_CONSUMER = "control_plane.serving" diff --git a/backend/src/modelforge_api/domain/configuration_reference.py b/backend/src/modelforge_api/domain/configuration_reference.py index a72a40a..bfcfe24 100644 --- a/backend/src/modelforge_api/domain/configuration_reference.py +++ b/backend/src/modelforge_api/domain/configuration_reference.py @@ -73,14 +73,30 @@ SETTING_DOCS: dict[str, SettingDoc] = { "A wildcard is refused in production because requests are credentialed." ), # ---------------------------------------------------------------- credentials + "operator_identities": _secret( + "Optional JSON array of named human identities with role, credential ID, SHA-256 key " + "digest and timezone-aware expiry. Provision with scripts/operator_identity.py. " + "Replace this deployment setting and recreate the API to rotate or revoke access." + ), + "operator_password_hash": _secret( + "Optional salted scrypt verifier generated by scripts/operator_password.py. Enables " + "password login with 15-minute sessions; requires the separate strong operator API key. " + "Changing either value invalidates password sessions." + ), "operator_api_key": _secret( - "Operator API key guarding every admin route. Generate at least 32 random characters; " - "ModelForge never mints one for you.", - required=True, + "Legacy bootstrap administrator key. Generate at least 32 random characters. " + "Production requires this key or an unexpired named administrator in operator_identities." ), "hf_token": _secret( - "Optional Hugging Face token, used only for acquiring gated repositories. " - "It is never passed to a runtime and never leaves the control plane." + "Optional deployment Hugging Face token for control-plane metadata. Compose may also " + "provision it independently to node-agent downloads; API secret activation does not update nodes." + ), + "secret_slot_root_key": _secret( + "External base64 32-byte AES-256 key for the managed Hugging Face control-plane slot. " + "Keep independent of backup and operator keys; retain it externally for historical restore." + ), + "secret_slot_root_key_id": _p( + "External key identifier bound to encrypted HF slot versions. Replacing a key does not rewrap history." ), "backup_encryption_key": _secret( "Base64 AES-256 key for backup encryption. Without it no backup can be produced, and " @@ -160,6 +176,25 @@ SETTING_DOCS: dict[str, SettingDoc] = { ), "observability_monitor_enabled": _p("Run SLO and alert evaluation in this process."), "observability_poll_interval_seconds": _p("Interval between observability evaluation passes."), + "alert_notifications_enabled": _p("Queue and dispatch the deployment-managed operational alert webhook."), + "alert_webhook_url": _secret( + "Deployment-managed HTTPS notification destination. Its exact hostname must also appear " + "in alert_webhook_allowed_hosts; the URL is never returned by the API." + ), + "alert_webhook_allowed_hosts": _p( + "Comma-separated exact destination host allowlist for the alert webhook. Wildcards and " + "redirects are not accepted." + ), + "alert_webhook_min_severity": _p( + "Minimum alert severity sent to the single webhook route: INFO, WARNING or CRITICAL." + ), + "alert_notification_timeout_seconds": _p("Total timeout for one webhook delivery."), + "alert_notification_max_attempts": _p("Maximum webhook attempts before a delivery fails permanently."), + "alert_notification_batch_size": _p("Maximum outbox rows claimed by one notification dispatch pass."), + "alert_notification_lease_seconds": _p("Lease duration before an interrupted delivery can be reclaimed."), + "alert_notification_retry_base_seconds": _p("Base delay for bounded exponential webhook retries."), + "model_update_monitor_enabled": _p("In production, check explicitly followed model references for changed revisions. Metadata only; never downloads, tests or promotes a model."), + "model_update_check_interval_seconds": _p("Minimum interval between automatic checks of each followed model reference; default one day. Each API process checks at most one due reference per minute."), "recovery_reconciliation_enabled": _p("Reconcile interrupted backups and restores at startup."), # ---------------------------------------------------------------- recovery "backup_pg_dump_path": _p("pg_dump executable. Must match the server major version."), diff --git a/backend/src/modelforge_api/domain/contracts.py b/backend/src/modelforge_api/domain/contracts.py index ee6b3af..9a1050e 100644 --- a/backend/src/modelforge_api/domain/contracts.py +++ b/backend/src/modelforge_api/domain/contracts.py @@ -341,12 +341,12 @@ class BenchmarkEnvironmentFingerprint(StrictModel): launch_arguments: dict[str, Any] cuda_version: str | None driver_version: str | None - accelerator_name: str + accelerator_name: str | None accelerator_uuid: str | None context_length: int | None concurrency: int = Field(ge=1) seed: int | None - operating_system: str + operating_system: str | None python_version: str | None = None @property diff --git a/backend/src/modelforge_api/domain/dispatch_context.py b/backend/src/modelforge_api/domain/dispatch_context.py new file mode 100644 index 0000000..5b27c46 --- /dev/null +++ b/backend/src/modelforge_api/domain/dispatch_context.py @@ -0,0 +1,53 @@ +"""Internal trusted scheduler identity; this is not an authorization or public input contract.""" +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +DispatchPriority = Literal["production", "interactive", "background", "benchmark", "lab"] + + +class DispatchContext(BaseModel): + """Identifiers only, constructed after gateway authentication or operator admission. + + An operator context does not grant permission to run a candidate. The eventual caller must + independently admit the exact target and persist its operation before using this context. + """ + + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + actor_kind: Literal["service_client", "operator"] + actor_id: str = Field(min_length=1, max_length=128) + lease_owner: str = Field(min_length=1, max_length=255) + workload_priority: DispatchPriority + service_client_id: UUID | None = None + operation_id: UUID | None = None + + @model_validator(mode="after") + def coherent_identity(self) -> DispatchContext: + if self.actor_kind == "service_client": + if (self.service_client_id is None or self.actor_id != str(self.service_client_id) + or self.operation_id is not None or self.workload_priority == "lab"): + raise ValueError("service dispatch requires its exact client identity and configured priority") + elif (self.service_client_id is not None or self.operation_id is None + or self.workload_priority != "lab" or self.lease_owner != f"operator:{self.actor_id}"): + raise ValueError("operator dispatch requires a persisted operation and fixed lab priority") + return self + + @classmethod + def for_service_client(cls, client_id: UUID, name: str, priority: str) -> DispatchContext: + """Priority is read from the authenticated persisted client, never the request body.""" + return cls.model_validate({"actor_kind": "service_client", "actor_id": str(client_id), + "lease_owner": name, "service_client_id": client_id, "workload_priority": priority}) + + @classmethod + def for_operator(cls, subject: str, operation_id: UUID) -> DispatchContext: + return cls(actor_kind="operator", actor_id=subject, lease_owner=f"operator:{subject}", + workload_priority="lab", operation_id=operation_id) + + def provenance(self) -> dict[str, str | None]: + return {"actor_kind": self.actor_kind, "actor_id": self.actor_id, + "service_client_id": str(self.service_client_id) if self.service_client_id else None, + "operation_id": str(self.operation_id) if self.operation_id else None, + "workload_priority": self.workload_priority} diff --git a/backend/src/modelforge_api/domain/external_creative.py b/backend/src/modelforge_api/domain/external_creative.py new file mode 100644 index 0000000..d399309 --- /dev/null +++ b/backend/src/modelforge_api/domain/external_creative.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +CreativeWorkload = Literal[ + "image", + "video", + "chat", + "speech_generation", + "voice_clone", + "music_generation", + "sound_generation", + "foley_generation", + "transcription", + "audio_enhancement", + "lip_sync", +] +CreativeAllocationState = Literal["granted", "queued", "blocked", "uncertain", "released"] +CreativeRequiredAction = Literal["release_idle_comfy", "unload_configured_ollama"] + + +class ExternalAllocationCreate(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + request_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") + idempotency_key: str = Field( + min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$" + ) + installation_id: str = Field( + min_length=1, max_length=120, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$" + ) + workload: CreativeWorkload + resource_ref: str = Field( + min_length=1, max_length=255, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$" + ) + provider_resource_id: str = Field( + min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$" + ) + required_vram_bytes: int = Field(gt=0, le=256 * 1024**3) + priority: Literal["production", "interactive", "background", "benchmark"] = "interactive" + ttl_seconds: int = Field(default=600, ge=30, le=3600) + use_measured_profile: bool = True + + +class ExternalAllocationResponse(BaseModel): + allocation_id: uuid.UUID + project_id: uuid.UUID + request_id: str + installation_id: str + workload: CreativeWorkload + resource_ref: str + provider_resource_id: str + required_vram_bytes: int + priority: str + state: CreativeAllocationState + decision_code: str + required_actions: list[CreativeRequiredAction] + accelerator_id: uuid.UUID | None + acquired_at: datetime | None + heartbeat_at: datetime | None + expires_at: datetime | None + uncertain_at: datetime | None + released_at: datetime | None + heartbeat_interval_seconds: int = 30 + decision_evidence: dict[str, Any] + queue_position: int | None = None + + +class ExternalAllocationAction(BaseModel): + model_config = ConfigDict(extra="forbid") + request_id: str = Field(min_length=1, max_length=128) + observed_peak_vram_bytes: int | None = Field(default=None, gt=0, le=256 * 1024**3) + attestation: ExternalActionAttestation | None = None + + +class ExternalResourceApproval(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + project_id: uuid.UUID + resource_ref: str = Field( + min_length=1, max_length=255, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$" + ) + workload: CreativeWorkload + provider_resource_id: str = Field( + min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$" + ) + approved: bool = True + configured_vram_bytes: int = Field(gt=0, le=256 * 1024**3) + + +class ExternalResourceResponse(ExternalResourceApproval): + resource_id: uuid.UUID + measured_peak_vram_bytes: int | None = None + measurement_count: int = 0 + updated_at: datetime + + +class ExternalResourceList(BaseModel): + items: list[ExternalResourceResponse] + total: int + + +class ExternalActionAttestation(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + action: Literal["release_idle_comfy", "unload_configured_ollama"] + observed_before: int = Field(ge=0, le=256 * 1024**3) + observed_after: int = Field(ge=0, le=256 * 1024**3) + completed_at: datetime + + +class ExternalActionAttestationResponse(ExternalActionAttestation): + attestation_id: uuid.UUID + allocation_id: uuid.UUID + validated: bool + + +class ExternalAllocationReconcile(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + action: Literal["confirm_released", "keep_uncertain"] + reason: str = Field(min_length=3, max_length=500) + + +class ExternalAllocationList(BaseModel): + items: list[ExternalAllocationResponse] + total: int + + +class ExternalProfileObservation(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + allocation_id: uuid.UUID + resource_ref: str = Field(min_length=1, max_length=255) + provider_resource_id: str = Field(min_length=1, max_length=128) + workload: CreativeWorkload + observed_peak_vram_bytes: int = Field(gt=0, le=256 * 1024**3) + reserved_vram_bytes: int = Field(gt=0, le=256 * 1024**3) + duration_ms: int = Field(ge=0, le=7 * 24 * 3600 * 1000) + outcome: Literal["succeeded", "failed", "cancelled", "uncertain"] diff --git a/backend/src/modelforge_api/domain/lab.py b/backend/src/modelforge_api/domain/lab.py new file mode 100644 index 0000000..bcdb93c --- /dev/null +++ b/backend/src/modelforge_api/domain/lab.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from modelforge_api.domain.serving import ( + EmbeddingInvokeRequest, + OCRInvokeRequest, + RerankingInvokeRequest, + SpeechTranscriptionInvokeRequest, + VisionEmbeddingInvokeRequest, +) + + +class LabModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class LabTarget(LabModel): + deployment_candidate_id: uuid.UUID + artifact_set_id: uuid.UUID + runtime_profile_id: uuid.UUID + compute_node_id: uuid.UUID + execution_approval_id: uuid.UUID + + +class LabEmbeddingInput(LabModel): + capability: Literal["rag.embedding"] + payload: EmbeddingInvokeRequest + + +class LabRerankingInput(LabModel): + capability: Literal["rag.reranking"] + payload: RerankingInvokeRequest + + +class LabOCRInput(LabModel): + capability: Literal["document.ocr"] + payload: OCRInvokeRequest + + +class LabVisionInput(LabModel): + capability: Literal["vision.embedding"] + payload: VisionEmbeddingInvokeRequest + + +class LabSpeechInput(LabModel): + capability: Literal["speech.transcription"] + payload: SpeechTranscriptionInvokeRequest + + +LabInput = Annotated[ + LabEmbeddingInput | LabRerankingInput | LabOCRInput | LabVisionInput | LabSpeechInput, + Field(discriminator="capability"), +] + + +class LabLimits(LabModel): + timeout_seconds: int = Field(default=60, ge=5, le=300, strict=True) + queue_timeout_seconds: int = Field(default=10, ge=1, le=60, strict=True) + retention_seconds: int = Field(default=300, ge=60, le=3600, strict=True) + max_output_bytes: int = Field(default=65536, ge=1, le=1048576, strict=True) + concurrency: Literal[1] = 1 + + @model_validator(mode="after") + def bounded_queue(self) -> LabLimits: + if self.queue_timeout_seconds > self.timeout_seconds: + raise ValueError("queue timeout cannot exceed total timeout") + return self + + +class LabPreflightRequest(LabModel): + target: LabTarget + input: LabInput + contract_version: Literal[1] = 1 + limits: LabLimits = Field(default_factory=LabLimits) + + +class LabExecutionStartRequest(LabPreflightRequest): + """An explicitly selected embedding experiment; body input is never echoed in status.""" + + deployment_id: uuid.UUID + experiment_route_id: uuid.UUID + + +class LabInputSummary(LabModel): + sha256: str + item_count: int + encoded_bytes: int + + +class LabBinding(LabTarget): + compatibility_assessment_id: uuid.UUID + runtime_probe_id: uuid.UUID + capability: str + contract_version: Literal[1] = 1 + runtime_profile_fingerprint: str + assessment_fingerprint: str + approval_evidence_fingerprint: str + probe_environment_fingerprint: str + accelerator_id: uuid.UUID | None = None + + +class LabPreflightResponse(LabModel): + input_summary: LabInputSummary + binding: LabBinding | None = None + limits: LabLimits + blockers: list[str] + checks_passed: bool + execution_started: Literal[False] = False + permission_granted: Literal[False] = False + + +LabExecutionStatus = Literal[ + "accepted", + "preparing", + "queued", + "running", + "cancel_requested", + "completed", + "failed", + "cancelled", + "interrupted", +] + + +class LabExecutionRunResponse(LabModel): + id: uuid.UUID + capability: Literal["rag.embedding@1"] = "rag.embedding@1" + status: LabExecutionStatus + target_snapshot: dict[str, object] + input_summary: LabInputSummary + output_metadata: dict[str, object] | None = None + worker_observations: dict[str, object] | None = None + failure_code: str | None = None + input_payload_expires_at: datetime + retention_seconds: int = Field(ge=60, le=3600) + created_at: datetime + started_at: datetime | None = None + cancellation_requested_at: datetime | None = None + cancellation_acknowledged_at: datetime | None = None + finished_at: datetime | None = None diff --git a/backend/src/modelforge_api/domain/lifecycle_contracts.py b/backend/src/modelforge_api/domain/lifecycle_contracts.py index df6a3f9..31aed18 100644 --- a/backend/src/modelforge_api/domain/lifecycle_contracts.py +++ b/backend/src/modelforge_api/domain/lifecycle_contracts.py @@ -133,7 +133,31 @@ def assert_deployment_transition( raise ValueError(f"lifecycle transition {current} -> {target} is not allowed") +class ServingTransitionSelection(LifecycleModel): + """Explicit selection; the server derives the complete immutable serving contract.""" + + current_deployment_id: uuid.UUID + candidate_deployment_id: uuid.UUID + production_approval_id: uuid.UUID + expected_route_generation: int = Field(ge=0) + comparison_ids: dict[uuid.UUID, uuid.UUID] = Field(min_length=1, max_length=1000) + + +class ServingTransitionContract(LifecycleModel): + version: Literal[1] = 1 + production_validation: Literal["SATISFIED"] = "SATISFIED" + selection: ServingTransitionSelection + capability_contract_id: uuid.UUID + current: dict[str, Any] + candidate: dict[str, Any] + experiment_routes: dict[str, dict[str, Any]] + bindings: dict[str, dict[str, Any]] + comparisons: dict[str, dict[str, Any]] + production_approval_fingerprint: str + + class LifecycleEvidenceBundle(LifecycleModel): + serving_transition: ServingTransitionSelection | None = None artifact_set_id: uuid.UUID | None = None model_revision_id: uuid.UUID | None = None runtime_profile_id: uuid.UUID | None = None diff --git a/backend/src/modelforge_api/domain/managed_identity.py b/backend/src/modelforge_api/domain/managed_identity.py new file mode 100644 index 0000000..0dee9e2 --- /dev/null +++ b/backend/src/modelforge_api/domain/managed_identity.py @@ -0,0 +1,89 @@ +"""Bounded, secret-safe contracts for the managed personal-key provider.""" +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +Role = Literal["viewer", "operator", "admin"] + + +class ManagedIdentityRequest(BaseModel): + model_config = ConfigDict(extra="forbid", hide_input_in_errors=True, strict=True) + reason: str = Field(min_length=8, max_length=500) + + @field_validator("reason") + @classmethod + def safe_reason(cls, value: str) -> str: + value = value.strip() + if len(value) < 8 or any(prefix in value for prefix in ("mfopm_", "mfop_", "mfsvc_", "mfnode_", "mfenroll_")): + raise ValueError("Use a non-secret explanation of at least eight characters") + return value + + +class ManagedIdentityCreate(ManagedIdentityRequest): + slug: str = Field(pattern=r"^[a-z][a-z0-9-]{0,62}$") + role: Role + expires_in_days: int = Field(ge=1, le=365) + expected_revision: int = Field(ge=0) + + +class ManagedIdentityRotate(ManagedIdentityRequest): + expected_version: int = Field(ge=1) + expires_in_days: int = Field(ge=1, le=365) + overlap_seconds: int = Field(ge=0, le=900) + + +class ManagedIdentityChange(ManagedIdentityRequest): + expected_version: int = Field(ge=1) + role: Role + enabled: bool + + +class ManagedIdentityRevoke(ManagedIdentityRequest): + expected_version: int = Field(ge=1) + confirm_subject: str = Field(max_length=71) + + +class ManagedCredentialMetadata(BaseModel): + id: str + expires_at: datetime + revoked_at: datetime | None + superseded_at: datetime | None + last_used_at: datetime | None + activated: bool + usable: bool + + +class ManagedIdentityMetadata(BaseModel): + subject: str + slug: str + source: Literal["managed"] = "managed" + role: Role + enabled: bool + version: int + created_at: datetime + credentials: list[ManagedCredentialMetadata] + + +class DeploymentIdentityMetadata(BaseModel): + subject: str + role: Role + credential_id: str + expires_at: datetime + source: Literal["deployment"] = "deployment" + + +class ManagedIdentityList(BaseModel): + revision: int + initialized: bool + identities: list[ManagedIdentityMetadata] + deployment_identities: list[DeploymentIdentityMetadata] + + +class ManagedIdentityResult(BaseModel): + revision: int + identity: ManagedIdentityMetadata + + +class ManagedIdentityIssued(ManagedIdentityResult): + one_time_key: str = Field(repr=False) diff --git a/backend/src/modelforge_api/domain/model_changes.py b/backend/src/modelforge_api/domain/model_changes.py new file mode 100644 index 0000000..f8726cb --- /dev/null +++ b/backend/src/modelforge_api/domain/model_changes.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +ModelChangeStatus = Literal[ + "searching", + "found", + "source_checked", + "downloaded", + "ready_to_test", + "tested", + "comparison_available", + "migration_required", + "ready_to_activate", + "active", + "checked", + "rolled_back", + "rejected", +] + + +class Contract(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ModelChangeCreate(Contract): + project_binding_id: UUID + reason: str = Field(default="Model verbeteren", min_length=1, max_length=500) + + +class ModelChangeCandidateLink(Contract): + expected_version: int = Field(ge=1) + candidate_model_id: UUID | None = None + candidate_revision_id: UUID | None = None + candidate_artifact_set_id: UUID | None = None + candidate_deployment_id: UUID | None = None + discovery_assessment_id: UUID | None = None + download_plan_id: UUID | None = None + artifact_job_id: UUID | None = None + + @model_validator(mode="after") + def at_least_one_identity(self) -> ModelChangeCandidateLink: + if not any(( + self.candidate_model_id, + self.candidate_revision_id, + self.candidate_artifact_set_id, + self.candidate_deployment_id, + self.discovery_assessment_id, + self.download_plan_id, + self.artifact_job_id, + )): + raise ValueError("at least one candidate or acquisition identity is required") + return self + + +class ModelChangeEvidenceLink(Contract): + expected_version: int = Field(ge=1) + selected_comparison_id: UUID | None = None + migration_plan_id: UUID | None = None + lifecycle_plan_id: UUID | None = None + + @model_validator(mode="after") + def at_least_one_evidence(self) -> ModelChangeEvidenceLink: + if not any((self.selected_comparison_id, self.migration_plan_id, self.lifecycle_plan_id)): + raise ValueError("at least one evidence reference is required") + return self + + +class ModelChangeReject(Contract): + expected_version: int = Field(ge=1) + reason: str = Field(min_length=3, max_length=1000) + + +class ChangeModelIdentity(Contract): + model_id: UUID | None = None + display_name: str | None = None + revision_id: UUID | None = None + resolved_commit_sha: str | None = None + artifact_set_id: UUID | None = None + deployment_id: UUID | None = None + + +class ChangeAcquisition(Contract): + discovery_assessment_id: UUID | None = None + security_gate: str = "unknown" + license_gate: str = "unknown" + hardware_fit: str = "unknown" + storage_bytes: int | None = None + download_plan_id: UUID | None = None + artifact_job_id: UUID | None = None + download_status: str | None = None + + +class ChangeImpact(Contract): + affected_projects: list[str] = Field(default_factory=list) + affected_project_count: int = 0 + application_change_required: bool = False + migration_required: bool = False + migration_kind: str = "none" + downtime_expected: bool = False + rollback_available: bool = False + blockers: list[str] = Field(default_factory=list) + + +class ChangeProgressStep(Contract): + key: str + label: str + state: Literal["done", "current", "upcoming", "blocked"] + + +class ModelChangeResponse(Contract): + id: UUID + project_binding_id: UUID + project_id: UUID + project_key: str + project_name: str + capability: str + status: ModelChangeStatus + stored_status: ModelChangeStatus + status_reason: str + version: int + current: ChangeModelIdentity + candidate: ChangeModelIdentity | None = None + acquisition: ChangeAcquisition + selected_comparison_id: UUID | None = None + migration_plan_id: UUID | None = None + lifecycle_plan_id: UUID | None = None + impact: ChangeImpact + next_action: str + progress: list[ChangeProgressStep] + created_by: str + updated_by: str + activated_at: datetime | None = None + checked_at: datetime | None = None + closed_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class ModelChangeList(Contract): + items: list[ModelChangeResponse] + total: int diff --git a/backend/src/modelforge_api/domain/model_updates.py b/backend/src/modelforge_api/domain/model_updates.py new file mode 100644 index 0000000..1d14f95 --- /dev/null +++ b/backend/src/modelforge_api/domain/model_updates.py @@ -0,0 +1,51 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class UpdateModel(BaseModel): + model_config = ConfigDict(extra="forbid", from_attributes=True) + + +class ModelWatchCreate(UpdateModel): + model_id: UUID + baseline_revision_id: UUID + upstream_ref: str = Field(default="main", min_length=1, max_length=255, pattern=r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") + + +class ModelWatchResponse(UpdateModel): + id: UUID + model_id: UUID + baseline_revision_id: UUID + baseline_commit_sha: str + upstream_ref: str + last_checked_at: datetime | None + last_error_code: str | None + enabled: bool + version: int + + +class ModelWatchUpdate(UpdateModel): + expected_version: int = Field(ge=1) + expected_baseline_revision_id: UUID + baseline_revision_id: UUID + enabled: bool + reason: str = Field(min_length=1, max_length=500, pattern=r"\S") + + +class ModelUpdateMonitorResponse(UpdateModel): + enabled: bool + check_interval_seconds: int + + +class ModelUpdateNoticeResponse(UpdateModel): + id: UUID + watch_id: UUID + model_id: UUID + baseline_commit_sha: str + candidate_revision_id: UUID + candidate_commit_sha: str + status: Literal["new", "dismissed"] + discovered_at: datetime diff --git a/backend/src/modelforge_api/domain/node_lifecycle.py b/backend/src/modelforge_api/domain/node_lifecycle.py new file mode 100644 index 0000000..7fc2199 --- /dev/null +++ b/backend/src/modelforge_api/domain/node_lifecycle.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import uuid +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class NodeLifecycleModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class NodeLifecyclePreviewRequest(NodeLifecycleModel): + target_enabled: bool + + +class NodeLifecycleChange(NodeLifecycleModel): + target_enabled: bool + expected_generation: int = Field(ge=1) + dependency_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + operator: str = Field(min_length=1, max_length=255) + reason: str = Field(min_length=10, max_length=2000) + confirmation: str = Field(min_length=1, max_length=255) + + +class NodeLifecycleBlocker(NodeLifecycleModel): + code: str + message: str + record_type: str + count: int = Field(ge=1) + resource_ids: list[str] = Field(default_factory=list) + + +class NodeLifecyclePreview(NodeLifecycleModel): + node_id: uuid.UUID + persisted_identity: str + hostname: str + display_name: str + current_enabled: bool + current_liveness: str + target_enabled: bool + node_generation: int = Field(ge=1) + dependency_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + can_apply: bool + blockers: list[NodeLifecycleBlocker] + agent_evidence_after_enable: Literal["unchanged", "awaiting_fresh_authenticated_heartbeat"] + + +class NodeLifecycleResult(NodeLifecycleModel): + node_id: uuid.UUID + enabled: bool + generation: int = Field(ge=1) + liveness: Literal["offline", "disabled"] + agent_evidence_state: Literal["unchanged", "awaiting_fresh_authenticated_heartbeat"] diff --git a/backend/src/modelforge_api/domain/observability.py b/backend/src/modelforge_api/domain/observability.py index f50368f..8eb88e2 100644 --- a/backend/src/modelforge_api/domain/observability.py +++ b/backend/src/modelforge_api/domain/observability.py @@ -363,6 +363,34 @@ class CapacitySnapshotResponse(OperationalModel): freshness_seconds: float +class ActiveSchedulerLeaseResponse(OperationalModel): + lease_id: uuid.UUID + project_id: uuid.UUID | None + node_id: uuid.UUID | None + accelerator_id: uuid.UUID + deployment_id: uuid.UUID + lease_state: Literal["PENDING", "GRANTED", "ACTIVE", "RELEASING"] + workload_status: Literal["QUEUED", "RUNNING", "UNKNOWN"] + priority: str + lease_type: Literal[ + "RESIDENCY_RESERVATION", "REQUEST_EXECUTION", "LOAD_TRANSITION", "UNKNOWN" + ] + reserved_vram_bytes: int + materialized_vram_bytes: int + created_at: datetime + acquired_at: datetime | None + heartbeat_at: datetime | None + expires_at: datetime + + +class ActiveSchedulerLeaseInventoryResponse(OperationalModel): + observed_at: datetime + completeness: Literal["COMPLETE", "TRUNCATED"] + limit: int + returned_count: int + leases: list[ActiveSchedulerLeaseResponse] + + class TrendResponse(OperationalModel): subject: str sample_count: int diff --git a/backend/src/modelforge_api/domain/operator_identity.py b/backend/src/modelforge_api/domain/operator_identity.py new file mode 100644 index 0000000..0fe854b --- /dev/null +++ b/backend/src/modelforge_api/domain/operator_identity.py @@ -0,0 +1,57 @@ +"""Deployment-owned, hash-only human identities; independent of machine credentials.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError + + +class OperatorIdentity(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True) + + credential_id: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$") + subject: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$") + role: Literal["viewer", "operator", "admin"] + token_sha256: str = Field(pattern=r"^[0-9a-f]{64}$", repr=False) + expires_at: AwareDatetime + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate identity field") + result[key] = value + return result + + +@lru_cache(maxsize=8) +def parse_operator_identities(document: str) -> tuple[OperatorIdentity, ...]: + """Bound and validate once per deployment snapshot; never echo rejected input.""" + try: + if len(document) > 65_536: + raise ValueError("Identity document too large") + payload = json.loads(document, object_pairs_hook=_unique_object) + if not isinstance(payload, list) or not 1 <= len(payload) <= 100: + raise ValueError("Identity list required") + identities = TypeAdapter(tuple[OperatorIdentity, ...]).validate_python(payload) + identifiers: set[str] = set() + digests: set[str] = set() + roles: dict[str, str] = {} + for identity in identities: + if ( + identity.subject == "legacy-operator" + or identity.credential_id in identifiers + or identity.token_sha256 in digests + or roles.get(identity.subject, identity.role) != identity.role + ): + raise ValueError("Ambiguous identity configuration") + identifiers.add(identity.credential_id) + digests.add(identity.token_sha256) + roles[identity.subject] = identity.role + return identities + except (ValueError, TypeError, ValidationError, RecursionError) as exc: + raise ValueError("Invalid operator identities; use the documented hash-only format") from exc diff --git a/backend/src/modelforge_api/domain/portfolio.py b/backend/src/modelforge_api/domain/portfolio.py new file mode 100644 index 0000000..9d3fd30 --- /dev/null +++ b/backend/src/modelforge_api/domain/portfolio.py @@ -0,0 +1,197 @@ +"""Typed, secret-free contracts for repository inventory reconciliation.""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + +RepositoryClassification = Literal[ + "local_model", + "hosted_model", + "gateway", + "mixed", + "model_management", + "runtime_observer", + "non_ai", + "unknown", +] +PROJECT_LINK_CLASSIFICATIONS = frozenset({"local_model", "gateway", "mixed"}) + + +class PortfolioCodeEvidence(StrictModel): + """A bounded source-code signal; source text and arbitrary notes are never accepted.""" + + path: str = Field(min_length=1, max_length=512) + signal: Literal[ + "provider_dependency", + "inference_callsite", + "model_configuration", + "capability_client", + "runtime_declaration", + "model_management", + "runtime_observer", + ] + capabilities: list[str] = Field(default_factory=list, max_length=8) + + @field_validator("path") + @classmethod + def validate_repository_relative_path(cls, value: str) -> str: + if ( + value.startswith(("/", "\\")) + or "\\" in value + or ":" in value + or any(ord(character) < 32 for character in value) + or any(part in {"", ".", ".."} for part in value.split("/")) + ): + raise ValueError("path must be a normalized repository-relative file path") + return value + + @field_validator("capabilities") + @classmethod + def validate_capabilities(cls, values: list[str]) -> list[str]: + pattern = re.compile(r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+(?:@[1-9][0-9]*)?$") + if any(not pattern.fullmatch(value) for value in values): + raise ValueError("capabilities must be safe capability identifiers") + if len(values) != len(set(values)): + raise ValueError("capabilities must be unique") + return values + + +class PortfolioRuntimeEvidence(StrictModel): + """A runtime observation separate from source-code classification.""" + + component: str = Field(pattern=r"^[a-z][a-z0-9-]{0,63}$") + state: Literal["running", "stopped", "unknown"] + observed_at: datetime + observation_source: Literal["container_inventory", "health_probe"] + git_revision: str | None = Field(default=None, pattern=r"^(?:[0-9a-f]{7,40})$") + image_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + + @field_validator("observed_at") + @classmethod + def require_timezone(cls, value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("runtime observation time must include a timezone") + return value.astimezone(UTC) + + +class PortfolioRepositoryInput(StrictModel): + external_repository_id: str = Field(pattern=r"^[a-z0-9][a-z0-9._:-]{0,127}$") + project_key: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9-]{0,127}$") + slug: str = Field(min_length=3, max_length=255) + default_branch: str = Field(min_length=1, max_length=255) + head_sha: str = Field(pattern=r"^[0-9a-f]{40}$") + classification: RepositoryClassification + accept_head_update: bool = False + code_evidence: list[PortfolioCodeEvidence] = Field(default_factory=list, max_length=128) + runtime_evidence: list[PortfolioRuntimeEvidence] = Field(default_factory=list, max_length=64) + + @field_validator("slug") + @classmethod + def validate_slug(cls, value: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", value): + raise ValueError("slug must be an owner/repository identifier, not a URL") + return value + + @field_validator("default_branch") + @classmethod + def validate_branch(cls, value: str) -> str: + if ( + not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,254}", value) + or ".." in value + or "//" in value + or value.endswith(("/", ".", ".lock")) + ): + raise ValueError("default branch must be a safe Git branch name") + return value + + @model_validator(mode="after") + def validate_runtime_components(self) -> PortfolioRepositoryInput: + if self.classification in PROJECT_LINK_CLASSIFICATIONS and self.project_key is None: + raise ValueError("project_key is required for a local model consumer or provider") + components = [item.component for item in self.runtime_evidence] + if len(components) != len(set(components)): + raise ValueError("runtime components must be unique within a repository observation") + return self + + +class PortfolioReconcileRequest(StrictModel): + source_system: Literal["gitea"] = "gitea" + snapshot_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + observed_at: datetime + complete: bool = False + repositories: list[PortfolioRepositoryInput] = Field(max_length=256) + + @field_validator("observed_at") + @classmethod + def require_timezone(cls, value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("snapshot observation time must include a timezone") + return value.astimezone(UTC) + + @model_validator(mode="after") + def validate_repository_identity_uniqueness(self) -> PortfolioReconcileRequest: + if not self.repositories and not self.complete: + raise ValueError("an empty inventory is valid only as an explicit complete snapshot") + identities = [item.external_repository_id for item in self.repositories] + if len(identities) != len(set(identities)): + raise ValueError("a snapshot may contain each external repository only once") + return self + + +class PortfolioRepositoryView(StrictModel): + id: str + project_key: str | None + project_name: str | None + project_id: str | None + source_system: str + external_repository_id: str + slug: str + default_branch: str + head_sha: str + classification: RepositoryClassification + code_evidence: list[PortfolioCodeEvidence] + runtime_evidence: list[PortfolioRuntimeEvidence] + observed_at: datetime + snapshot_id: str + snapshot_digest: str + pending_head_sha: str | None + pending_default_branch: str | None + pending_project_key: str | None + pending_classification: RepositoryClassification | None + pending_code_evidence: list[PortfolioCodeEvidence] + pending_observed_at: datetime | None + pending_snapshot_id: str | None + pending_snapshot_digest: str | None + last_seen_at: datetime + stale_at: datetime | None + + +class PortfolioInventoryResponse(StrictModel): + repositories: list[PortfolioRepositoryView] + + +class PortfolioReconcileResponse(StrictModel): + source_system: str + snapshot_id: str + snapshot_digest: str + observed_at: datetime + complete: bool + replayed: bool = False + stale_snapshot: bool = False + projects_created: int = 0 + repositories_created: int = 0 + repositories_updated: int = 0 + stale_observations: int = 0 + repositories_marked_stale: int = 0 + runtime_observations_updated: int = 0 + head_changes_review_required: int = 0 + head_changes_accepted: int = 0 diff --git a/backend/src/modelforge_api/domain/projects.py b/backend/src/modelforge_api/domain/projects.py new file mode 100644 index 0000000..e872aaa --- /dev/null +++ b/backend/src/modelforge_api/domain/projects.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from modelforge_api.domain.serving import ServiceClientResponse + + +class ProjectCreate(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + key: str = Field(pattern=r"^[a-z][a-z0-9-]{0,127}$") + name: str = Field(min_length=1, max_length=255) + description: str = Field(min_length=1, max_length=2000) + owner: str = Field(min_length=1, max_length=128) + environment: Literal["lab", "shadow", "evaluation", "production"] = "lab" + labels: list[str] = Field(default_factory=list, max_length=20) + + +class ProjectConnectivityResponse(BaseModel): + client_id: uuid.UUID + project_key: str | None + capability: str + connected: Literal[True] = True + correlation_id: str + + +class ProjectRequirementCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + capability: str = Field(pattern=r"^[a-z][a-z0-9.]*(?:@[1-9][0-9]*)$", max_length=128) + migration_support: Literal["none", "reindex", "schema"] = "none" + priority: Literal["interactive", "background", "benchmark"] = "interactive" + + +class ProjectServingModelResponse(BaseModel): + """Exact model identity resolved from the stable deployment used by a project requirement.""" + + deployment_id: uuid.UUID + artifact_set_id: uuid.UUID + model_id: uuid.UUID + model_key: str + display_name: str + upstream_source: str + revision_id: uuid.UUID + upstream_revision: str + resolved_commit_sha: str + runtime_profile_id: uuid.UUID + compute_node_id: uuid.UUID + channel: str + production: bool + artifact_availability: str + + +class ProjectRequirementResponse(BaseModel): + id: uuid.UUID + capability: str + channel: str + priority: str + migration_support: str + deprecated: bool + deployment_id: uuid.UUID | None + deployment_health: str + serving_model: ProjectServingModelResponse | None = None + credential_ready: bool + connectivity_checked_at: datetime | None + capability_checked_at: datetime | None + capability_check_request_id: str | None + state: str + blockers: list[str] + + +class ProjectWorkspaceResponse(BaseModel): + id: uuid.UUID + key: str + name: str + description: str + active: bool + owner: str | None + environment: str | None + labels: list[str] + source: Literal["operator", "manifest"] + production_eligible: bool | None + state: str + requirements: list[ProjectRequirementResponse] + clients: list[ServiceClientResponse] diff --git a/backend/src/modelforge_api/domain/recovery.py b/backend/src/modelforge_api/domain/recovery.py index 4da9534..7114725 100644 --- a/backend/src/modelforge_api/domain/recovery.py +++ b/backend/src/modelforge_api/domain/recovery.py @@ -292,6 +292,32 @@ class BackupSetResponse(RecoveryModel): entries: list[BackupManifestEntryResponse] = Field(default_factory=list) +class RecoveryRetentionDecision(RecoveryModel): + backup_id: str + current_state: BackupState + decision: Literal["RETAIN", "EXPIRE"] + reason: Literal[ + "ACTIVE_RESTORE_IN_PROGRESS", + "LEGAL_HOLD", + "MILESTONE", + "MINIMUM_VERIFIED_BACKUPS", + "RETENTION_NOT_REACHED", + "RETENTION_ELIGIBLE", + ] + expires_at: datetime | None + + +class RecoveryRetentionPreview(RecoveryModel): + observed_at: datetime + policy_key: str + policy_revision: int + minimum_verified_backups: int + active_restore_in_progress: bool + retained_backup_ids: list[str] + expiring_backup_ids: list[str] + decisions: list[RecoveryRetentionDecision] + + class RestorePlanCreate(RecoveryModel): backup_set_id: uuid.UUID mode: RestoreMode diff --git a/backend/src/modelforge_api/domain/release.py b/backend/src/modelforge_api/domain/release.py index f7adffd..9f3f2cb 100644 --- a/backend/src/modelforge_api/domain/release.py +++ b/backend/src/modelforge_api/domain/release.py @@ -14,6 +14,7 @@ support is a far better outcome than one that starts and writes rows the next ve from __future__ import annotations +import re from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum @@ -23,7 +24,7 @@ from pathlib import Path #: 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",) +SUPPORTED_SCHEMA_REVISIONS: tuple[str, ...] = ("20260924_0039",) #: The revision a clean installation and a completed upgrade must both arrive at. TARGET_SCHEMA_REVISION = SUPPORTED_SCHEMA_REVISIONS[-1] @@ -35,6 +36,21 @@ SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS: tuple[str, ...] = ( "20260827_0021", "20260828_0022", "20260830_0023", + "20260830_0024", + "20260907_0025", + "20260908_0026", + "20260908_0027", + "20260908_0028", + "20260909_0029", + "20260909_0030", + "20260909_0031", + "20260909_0032", + "20260909_0033", + "20260910_0034", + "20260911_0035", + "20260923_0036", + "20260923_0037", + "20260924_0038", TARGET_SCHEMA_REVISION, ) @@ -47,14 +63,36 @@ 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 +_PEP440_NUMBERED_RC = re.compile( + r"^(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)rc(?P0|[1-9][0-9]*)$" +) + + +def _canonicalize_packaged_version(value: str) -> str: + """Convert the one PEP 440 form produced for a numbered SemVer RC back to SemVer. + + Build backends correctly normalize ``1.3.0-rc.1`` to ``1.3.0rc1`` in installed + distribution metadata. The release contract, image labels, and API intentionally expose the + reviewed SemVer spelling, so installed images must restore it rather than reporting a second + version representation. All other metadata is left untouched and remains subject to the + strict semantic-version validation below. + """ + + match = _PEP440_NUMBERED_RC.fullmatch(value) + if match is None: + return value + return ( + f"{match.group('major')}.{match.group('minor')}.{match.group('patch')}" + f"-rc.{match.group('number')}" + ) + + def _read_version_file() -> str: """Read the repository's VERSION file, falling back to the packaged distribution metadata.""" @@ -68,7 +106,7 @@ def _read_version_file() -> str: from importlib.metadata import PackageNotFoundError, version try: - return version("modelforge-api") + return _canonicalize_packaged_version(version("modelforge-api")) except PackageNotFoundError: # pragma: no cover - only in a broken install return "0.0.0" @@ -89,9 +127,13 @@ class SemanticVersion: @classmethod def parse(cls, value: str) -> SemanticVersion: - core, _, prerelease = value.partition("-") + core, separator, prerelease = value.partition("-") parts = core.split(".") - if len(parts) != 3 or not all(part.isdigit() for part in parts): + if ( + len(parts) != 3 + or not all(part.isdigit() for part in parts) + or (separator and not prerelease) + ): 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) @@ -105,6 +147,26 @@ class SemanticVersion: return self.prerelease is not None +def release_channel_for(version: str) -> str: + """Derive the reported channel from the single product-version source. + + A release candidate must never identify itself as ``stable`` merely because it shares the + executable with the eventual stable release. Unknown prereleases stay visibly non-stable too, + rather than acquiring the RC label without an explicit ``rc.`` version. + """ + + prerelease = SemanticVersion.parse(version).prerelease + if prerelease is None: + return "stable" + if prerelease.startswith("rc.") and prerelease.removeprefix("rc.").isdigit(): + return "rc" + return "prerelease" + + +#: The channel is derived from VERSION rather than maintained as a second, drift-prone constant. +RELEASE_CHANNEL = release_channel_for(PRODUCT_VERSION) + + # --------------------------------------------------------------------------- compatibility diff --git a/backend/src/modelforge_api/domain/secret_slots.py b/backend/src/modelforge_api/domain/secret_slots.py new file mode 100644 index 0000000..51fb73f --- /dev/null +++ b/backend/src/modelforge_api/domain/secret_slots.py @@ -0,0 +1,79 @@ +"""Contracts for the sole supported retrievable platform secret.""" +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator + +SLOT = "huggingface_control_plane" + + +class SecretSlotRequest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True) + expected_revision: int = Field(ge=0) + reason: str = Field(min_length=8, max_length=500) + + @field_validator("reason") + @classmethod + def safe_reason(cls, value: str) -> str: + value = value.strip() + if len(value) < 8 or any(prefix in value for prefix in ("hf_", "mfop", "mfsvc_", "mfnode_", "mfenroll_")): + raise ValueError("Provide a non-secret explanation of at least eight characters") + return value + + +class SecretSlotSet(SecretSlotRequest): + value: SecretStr = Field(min_length=1, max_length=4096) + + @field_validator("value") + @classmethod + def valid_token(cls, value: SecretStr) -> SecretStr: + raw = value.get_secret_value() + if not raw.startswith("hf_") or not raw.isascii() or not all(c.isalnum() or c in "_-" for c in raw) or len(raw) < 8: + raise ValueError("Provide a valid Hugging Face token without whitespace") + return value + + +class SecretSlotVersionRequest(SecretSlotRequest): + version: int = Field(ge=1) + + +class SecretSlotRevoke(SecretSlotRequest): + confirm_slot: Literal["huggingface_control_plane"] + + +class SecretVersionMetadata(BaseModel): + version: int + key_id: str + created_at: datetime + revoked_at: datetime | None + test_status: Literal["untested", "passed", "failed"] + tested_at: datetime | None + test_code: str | None + + +class SecretSlotMetadata(BaseModel): + slot: Literal["huggingface_control_plane"] = "huggingface_control_plane" + revision: int + source: Literal["deployment", "managed"] + state: Literal["deployment", "pending", "active", "revoked", "quarantined"] + configured: bool + consumer: Literal["huggingface_control_plane_metadata"] = "huggingface_control_plane_metadata" + application_mode: Literal["new_requests"] = "new_requests" + root_key_available: bool + candidate_version: int | None + active_version: int | None + versions: list[SecretVersionMetadata] + node_credentials_managed: Literal[False] = False + + +class DeploymentSecretMetadata(BaseModel): + key: str + configured: bool + consumer: str + application_mode: Literal["restart_required"] = "restart_required" + editable: Literal[False] = False + + +class SecretSlotCatalog(BaseModel): + slot: SecretSlotMetadata + deployment_secrets: list[DeploymentSecretMetadata] diff --git a/backend/src/modelforge_api/domain/selected_model_comparison.py b/backend/src/modelforge_api/domain/selected_model_comparison.py new file mode 100644 index 0000000..c38a41f --- /dev/null +++ b/backend/src/modelforge_api/domain/selected_model_comparison.py @@ -0,0 +1,106 @@ +"""Bounded operator intent and designated-consumer evidence contracts.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, SecretStr + +from modelforge_api.domain.evaluation import EvaluationCaseDefinitionResponse, EvaluationRunComplete + + +class ComparisonModel(BaseModel): + model_config = ConfigDict(extra="forbid", from_attributes=True) + + +class ComparisonCreate(ComparisonModel): + project_binding_id: UUID + consumer_client_id: UUID + suite_revision_id: UUID + current_deployment_id: UUID + candidate_deployment_id: UUID + candidate_route_id: UUID + current_route_id: UUID | None = None + corpus_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + retrieval_config_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + current_index_ref: str = Field(min_length=1, max_length=512) + candidate_index_ref: str = Field(min_length=1, max_length=512) + idempotency_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9._:-]+$") + + +class ComparisonEvaluationSummary(ComparisonModel): + comparability: str + eligibility: str + metric_deltas: dict[str, float] + critical_regressions: int + + +class ComparisonResponse(ComparisonModel): + id: UUID + project_binding_id: UUID + consumer_client_id: UUID + suite_revision_id: UUID + current_deployment_id: UUID + candidate_deployment_id: UUID + candidate_route_id: UUID + actor_id: str + pins: dict[str, Any] + pins_digest: str + status: str + version: int + expected_cases: int + current_completed_cases: int + candidate_completed_cases: int + claimed_at: datetime | None + deadline_at: datetime | None + cancel_requested_at: datetime | None + finished_at: datetime | None + error_code: str | None + baseline_run_id: UUID | None + candidate_run_id: UUID | None + evaluation_comparison_id: UUID | None + created_at: datetime + updated_at: datetime + evaluation_summary: ComparisonEvaluationSummary | None = None + production_promotion_performed: Literal[False] = False + + +class ComparisonList(ComparisonModel): + items: list[ComparisonResponse] + total: int + + +class ComparisonCancel(ComparisonModel): + expected_version: int = Field(ge=1) + + +class ComparisonClaim(ComparisonModel): + comparison: ComparisonResponse + claim_token: str + claim_expires_at: datetime + cases: list[EvaluationCaseDefinitionResponse] + + +class ComparisonClaimRequest(ComparisonModel): + claim_token: SecretStr = Field(min_length=32, max_length=128) + + +class ComparisonProgress(ComparisonClaimRequest): + current_completed_cases: int = Field(ge=0, le=100) + candidate_completed_cases: int = Field(ge=0, le=100) + + +class ComparisonResult(ComparisonClaimRequest): + pins_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + consumer_revision_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + current: EvaluationRunComplete + candidate: EvaluationRunComplete + current_gateway_requests: dict[UUID, UUID] = Field(max_length=100) + candidate_gateway_requests: dict[UUID, UUID] = Field(max_length=100) + + +class ComparisonFinish(ComparisonClaimRequest): + outcome: Literal["failed", "cancelled"] + error_code: str | None = Field(default=None, max_length=64, pattern=r"^[A-Z][A-Z0-9_]*$") diff --git a/backend/src/modelforge_api/domain/selected_replacement.py b/backend/src/modelforge_api/domain/selected_replacement.py new file mode 100644 index 0000000..7727fe8 --- /dev/null +++ b/backend/src/modelforge_api/domain/selected_replacement.py @@ -0,0 +1,49 @@ +"""Read-only, advisory inputs to the existing approved lifecycle workflow.""" +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from modelforge_api.domain.lifecycle_contracts import ServingTransitionSelection + + +class PreviewModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ReplacementBlocker(PreviewModel): + code: str + message: str + project_binding_id: UUID | None = None + + +class ReplacementBinding(PreviewModel): + project_binding_id: UUID + project_id: UUID + project_name: str + comparison_id: UUID | None + comparison_status: str | None + eligible: bool + + +class ReplacementApproval(PreviewModel): + id: UUID + approved_by: str + approved_at: datetime + evidence_fingerprint: str + + +class ReplacementPreview(PreviewModel): + comparison_id: UUID + capability_contract_id: UUID | None + current_deployment_id: UUID + candidate_deployment_id: UUID + route_generation: int | None + same_embedding_space: bool + affected_bindings: list[ReplacementBinding] + production_approvals: list[ReplacementApproval] + blockers: list[ReplacementBlocker] + selection: ServingTransitionSelection | None + production_validation_required: Literal["SATISFIED"] = "SATISFIED" + production_promotion_performed: Literal[False] = False diff --git a/backend/src/modelforge_api/domain/self_check.py b/backend/src/modelforge_api/domain/self_check.py new file mode 100644 index 0000000..6de02e4 --- /dev/null +++ b/backend/src/modelforge_api/domain/self_check.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, Field, JsonValue + + +class SelfCheckStatus(StrEnum): + PASS = "pass" # noqa: S105 - diagnostic status, not a credential + WARN = "warn" + FAIL = "fail" + UNKNOWN = "unknown" + + +class SelfCheckKey(StrEnum): + API = "api" + DATABASE = "database" + REDIS = "redis" + MANIFESTS = "manifests" + STORAGE = "storage" + NODES = "nodes" + SCHEDULER = "scheduler" + DEPLOYMENTS = "deployments" + BACKUP = "backup" + CREDENTIALS = "credentials" + CONFIGURATION = "configuration" + RELEASE = "release" + + +class SelfCheckActionCode(StrEnum): + NONE_REQUIRED = "none_required" + REVIEW_API_IDENTITY = "review_api_identity" + CHECK_DATABASE = "check_database" + CHECK_REDIS = "check_redis" + REVIEW_MANIFESTS = "review_manifests" + REVIEW_STORAGE = "review_storage" + REVIEW_NODE_FRESHNESS = "review_node_freshness" + REVIEW_SCHEDULER_INPUTS = "review_scheduler_inputs" + REVIEW_DEPLOYMENTS = "review_deployments" + VERIFY_BACKUP_PAYLOAD = "verify_backup_payload" + REVIEW_CREDENTIALS = "review_credentials" + COMPLETE_CONFIGURATION = "complete_configuration" + VERIFY_RELEASE_IDENTITY = "verify_release_identity" + + +class SelfCheckOverall(StrEnum): + HEALTHY = "healthy" + DEGRADED = "degraded" + UNHEALTHY = "unhealthy" + + +class SelfCheckNextAction(BaseModel): + code: SelfCheckActionCode + summary: str + + +class SelfCheckResult(BaseModel): + key: SelfCheckKey + label: str + status: SelfCheckStatus + summary: str + detail: str | None = None + evidence: dict[str, JsonValue] = Field(default_factory=dict) + next_action: SelfCheckNextAction + + +class SelfCheckReport(BaseModel): + observed_at: datetime + overall: SelfCheckOverall + read_only: bool = True + checks: list[SelfCheckResult] = Field(default_factory=list) diff --git a/backend/src/modelforge_api/domain/serving.py b/backend/src/modelforge_api/domain/serving.py index dd335a8..9c90133 100644 --- a/backend/src/modelforge_api/domain/serving.py +++ b/backend/src/modelforge_api/domain/serving.py @@ -8,7 +8,7 @@ import uuid from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator CAPABILITY_VERSION_KEY = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+@[1-9][0-9]*$") ServingOperation = Literal["load", "invoke", "health", "drain", "unload"] @@ -119,6 +119,7 @@ class ResidencyResponse(ServingModel): class CapabilityDeploymentResponse(ServingModel): id: uuid.UUID + route_generation: int = Field(ge=0) capability: str contract_version: int deployment_candidate_id: uuid.UUID @@ -159,7 +160,8 @@ class ServiceClientCreate(ServingModel): project_key: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9-]*$") integration_environment: Literal["production", "shadow", "evaluation", "lab"] | None = None purpose: str | None = Field(default=None, min_length=3, max_length=500) - credential_expires_at: datetime | None = None + credential_expires_at: AwareDatetime | None = None + project_binding_id: uuid.UUID | None = None @field_validator("allowed_capabilities") @classmethod @@ -176,9 +178,13 @@ class ServiceClientCreate(ServingModel): if any(value is not None for value in project_fields) and not all( value is not None for value in project_fields ): - raise ValueError("project_key, integration_environment and purpose are required together") + raise ValueError( + "project_key, integration_environment and purpose are required together" + ) if self.project_key is not None and len(self.allowed_capabilities) != 1: raise ValueError("a project service identity must bind exactly one capability") + if self.project_binding_id is not None and self.project_key is None: + raise ValueError("an explicit binding requires a project identity") return self @@ -195,6 +201,7 @@ class ServiceClientResponse(ServingModel): integration_environment: str | None purpose: str | None credential_prefix: str | None + credential_id: uuid.UUID | None = None credential_created_at: datetime | None credential_expires_at: datetime | None credential_revoked_at: datetime | None @@ -206,20 +213,35 @@ class ServiceClientCreated(ServiceClientResponse): credential: str +class ServiceCredentialRotate(ServingModel): + expected_credential_id: uuid.UUID + overlap_seconds: int = Field(default=0, strict=True, ge=0, le=86400) + expires_at: AwareDatetime + + +class ServiceCredentialRevoke(ServingModel): + expected_credential_id: uuid.UUID + confirmation: str = Field(min_length=1, max_length=255) + + +class ServiceCredentialResponse(ServingModel): + id: uuid.UUID + secret_prefix: str + created_at: datetime + expires_at: datetime | None + superseded_at: datetime | None + revoked_at: datetime | None + last_used_at: datetime | None + + class ProjectFitEvidenceCreate(ServingModel): project_key: str = Field(pattern=r"^[a-z][a-z0-9-]*$") capability: str = Field(pattern=r"^[a-z][a-z0-9.]*@[1-9][0-9]*$") environment: Literal["production", "shadow", "evaluation", "lab"] - recommendation: Literal[ - "PROMOTION_ELIGIBLE", "KEEP_LAB", "BLOCKED", "REQUIRES_MORE_EVIDENCE" - ] - evidence_class: Literal[ - "OWNER_PHOTO", "PUBLIC_PHYSICAL_CAPTURE", "CATALOG_REFERENCE" - ] + recommendation: Literal["PROMOTION_ELIGIBLE", "KEEP_LAB", "BLOCKED", "REQUIRES_MORE_EVIDENCE"] + evidence_class: Literal["OWNER_PHOTO", "PUBLIC_PHYSICAL_CAPTURE", "CATALOG_REFERENCE"] engineering_integration: Literal["PASS", "INCOMPLETE", "BLOCKED"] - production_validation: Literal[ - "DEFERRED_EXTERNAL_VALIDATION", "REQUIRED", "SATISFIED" - ] + production_validation: Literal["DEFERRED_EXTERNAL_VALIDATION", "REQUIRED", "SATISFIED"] production_action: Literal["NONE"] = "NONE" case_count: int = Field(ge=0, le=1_000_000) metric_values: dict[str, float | int | str | bool | None] @@ -693,6 +715,7 @@ class AgentServingJobLease(ServingModel): top_n: int | None = Field(default=None, ge=1, le=10) modality_payload: dict[str, Any] | None = None request_id: uuid.UUID | None = None + lab_run_id: uuid.UUID | None = None @model_validator(mode="after") def validate_capability_payload(self) -> AgentServingJobLease: @@ -736,6 +759,20 @@ class AgentServingJobFailure(ServingModel): details: dict[str, Any] = Field(default_factory=dict) +class AgentServingJobCancellationCheck(ServingModel): + lease_token: str = Field(min_length=32, max_length=512) + + +class AgentServingJobCancellationStatus(ServingModel): + cancel_requested: bool + + +class AgentServingJobCancellationAck(ServingModel): + lease_token: str = Field(min_length=32, max_length=512) + worker_instance_id: str = Field(min_length=1, max_length=255) + state: Literal["cold", "warm", "busy", "loading", "draining", "unloading", "failed"] + + class AgentResidentState(ServingModel): deployment_id: uuid.UUID artifact_set_id: uuid.UUID diff --git a/backend/src/modelforge_api/domain/update_preflight.py b/backend/src/modelforge_api/domain/update_preflight.py new file mode 100644 index 0000000..54a4c91 --- /dev/null +++ b/backend/src/modelforge_api/domain/update_preflight.py @@ -0,0 +1,115 @@ +"""Read-only evidence contract for planning a ModelForge release update.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal + +from pydantic import BaseModel, Field + +EvidenceStatus = Literal["KNOWN", "UNKNOWN"] + + +class UpdateEvidence(BaseModel): + status: EvidenceStatus + value: str | bool | None = None + reason: str | None = None + + +class CurrentRelease(BaseModel): + version: str + channel: str + schema_revision: str = Field( + description="Schema target advertised by this application build; not the applied database revision." + ) + agent_protocol_version: int + + +class ApprovedRelease(BaseModel): + status: Literal["UNKNOWN"] = "UNKNOWN" + version: str | None = None + notes: UpdateEvidence + schema_revision: UpdateEvidence + + +class BuildProvenance(BaseModel): + status: Literal["AVAILABLE", "UNKNOWN"] + source_commit: str | None = None + built_at: str | None = None + image_digest: str | None = None + reason: str | None = None + + +class UpdateImpact(BaseModel): + schema_impact: UpdateEvidence = Field(alias="schema") + breaking: UpdateEvidence + + +class BackupRequirement(BaseModel): + required: UpdateEvidence + + +class UpdateExecution(BaseModel): + available: Literal[False] = False + control: Literal["HUMAN_CONTROLLED"] = "HUMAN_CONTROLLED" + + +class UpdatePreflight(BaseModel): + observed_at: datetime + status: Literal["INCOMPLETE"] = "INCOMPLETE" + read_only: Literal[True] = True + current: CurrentRelease + approved: ApprovedRelease + impact: UpdateImpact + backup: BackupRequirement + provenance: dict[Literal["current", "approved"], BuildProvenance] + execution: UpdateExecution + + +def update_preflight( + *, + version: str, + channel: str, + schema_revision: str, + agent_protocol_version: int, + source_commit: str | None, + built_at: str | None, + image_digest: str | None, +) -> UpdatePreflight: + """Build a plan view from local release identity without probing or mutating other systems.""" + + provenance_complete = all((source_commit, built_at, image_digest)) + provenance_status: Literal["AVAILABLE", "UNKNOWN"] = "AVAILABLE" if provenance_complete else "UNKNOWN" + unknown_reason = "No approved update metadata is configured; this value is not inferred." + + return UpdatePreflight( + observed_at=datetime.now(UTC), + current=CurrentRelease( + version=version, + channel=channel, + schema_revision=schema_revision, + agent_protocol_version=agent_protocol_version, + ), + approved=ApprovedRelease( + notes=UpdateEvidence(status="UNKNOWN", reason=unknown_reason), + schema_revision=UpdateEvidence(status="UNKNOWN", reason=unknown_reason), + ), + impact=UpdateImpact( + schema=UpdateEvidence(status="UNKNOWN", reason=unknown_reason), + breaking=UpdateEvidence(status="UNKNOWN", reason=unknown_reason), + ), + backup=BackupRequirement( + required=UpdateEvidence(status="UNKNOWN", reason=unknown_reason), + ), + provenance={ + "current": BuildProvenance( + status=provenance_status, + source_commit=source_commit, + built_at=built_at, + image_digest=image_digest, + reason=None if provenance_complete else "One or more build provenance fields are unavailable.", + ), + "approved": BuildProvenance(status="UNKNOWN", reason=unknown_reason), + }, + execution=UpdateExecution(), + ) diff --git a/backend/src/modelforge_api/main.py b/backend/src/modelforge_api/main.py index 7524f05..d6f93e7 100644 --- a/backend/src/modelforge_api/main.py +++ b/backend/src/modelforge_api/main.py @@ -18,8 +18,10 @@ from sqlalchemy.orm import Session from modelforge_api import __version__ from modelforge_api.api.authorization import ( AccessBoundary, + Principal, access_boundary_for_request, authenticate_operator_token, + operator_token_from_headers, request_body_limit_bytes, required_capability_for_request, ) @@ -27,21 +29,35 @@ from modelforge_api.api.request_limits import RequestBodyLimitMiddleware from modelforge_api.api.routes import ( acquisition, agent, + audit_timeline, catalog, + configuration, evaluation, + external_creative, hardware, health, + identity, + lab, lifecycle, migrations, + model_updates, observability, + operator_login, + portfolio, + projects, recovery, registry, runtime, + selected_model_comparison, + selected_replacement, serving, + update_preflight, ) from modelforge_api.db import engine, get_session from modelforge_api.domain.observability import metrics, route_class from modelforge_api.domain.release import build_identity +from modelforge_api.services.alert_notifications import AlertNotificationError +from modelforge_api.services.configuration_changes import ConfigurationError from modelforge_api.services.evaluation import EvaluationError from modelforge_api.services.hardware_polling import HardwarePollingService from modelforge_api.services.lifecycle import LifecycleError, LifecycleService @@ -50,6 +66,7 @@ from modelforge_api.services.migration_engine import ( MigrationEngineError, MigrationEngineService, ) +from modelforge_api.services.model_update_polling import ModelUpdatePollingService from modelforge_api.services.node_agent import ( AgentProtocolError, NodeAgentService, @@ -59,9 +76,14 @@ from modelforge_api.services.node_decommission import NodeDecommissionError from modelforge_api.services.node_liveness import NodeLivenessPollingService from modelforge_api.services.observability import ObservabilityError, ObservabilityService from modelforge_api.services.observability_polling import ObservabilityPollingService +from modelforge_api.services.operator_identities import ( + ManagedIdentityError, + authenticate_managed_operator, +) from modelforge_api.services.project_registry import sync_project_registry from modelforge_api.services.recovery import RecoveryError, RecoveryService from modelforge_api.services.registry import RegistryError, seed_candidate_registry +from modelforge_api.services.secret_slots import SecretSlotError from modelforge_api.services.serving import ( CapabilityAuthenticationEvidence, ServingError, @@ -151,6 +173,18 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: "project_bindings_waiting_for_contracts", bindings=sync_result.unavailable_contracts, ) + if sync_result.unavailable_bindings: + logger.warning( + "project_bindings_unavailable_for_declared_stability", + bindings=[ + { + "project": item.project_key, + "capability": f"{item.capability}@{item.contract_version}", + "declared_stability": item.declared_stability, + } + for item in sync_result.unavailable_bindings + ], + ) poller = HardwarePollingService(settings) hardware_task = ( asyncio.create_task(poller.run()) if settings.hardware_refresh_on_startup else None @@ -166,15 +200,24 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: else None ) observability_poller = ObservabilityPollingService(settings, engine) - if settings.observability_monitor_enabled: + if settings.observability_monitor_enabled or settings.alert_notifications_enabled: with Session(engine) as session: ObservabilityService(session, settings).ensure_defaults() observability_task = ( asyncio.create_task(observability_poller.run()) - if settings.observability_monitor_enabled + if settings.observability_monitor_enabled or settings.alert_notifications_enabled + else None + ) + model_update_poller = ModelUpdatePollingService(settings, engine) + model_update_task = ( + asyncio.create_task(model_update_poller.run()) + if settings.env == "production" and settings.model_update_monitor_enabled else None ) yield + if model_update_task: + model_update_poller.stop() + await model_update_task if observability_task: observability_poller.stop() await observability_task @@ -199,19 +242,51 @@ app = FastAPI( redoc_url="/redoc" if interactive_api_enabled else None, openapi_url="/openapi.json" if interactive_api_enabled else None, ) +app.include_router(operator_login.router) app.include_router(health.router) app.include_router(lifecycle.router) app.include_router(migrations.router) app.include_router(observability.router) app.include_router(recovery.router) app.include_router(agent.router) +app.include_router(audit_timeline.router) app.include_router(hardware.router) app.include_router(catalog.router) +app.include_router(configuration.router) +app.include_router(identity.router) +app.include_router(portfolio.router) +app.include_router(projects.router) app.include_router(registry.router) app.include_router(acquisition.router) +app.include_router(model_updates.router) +app.include_router(selected_model_comparison.router) +app.include_router(selected_replacement.router) app.include_router(runtime.router) +app.include_router(lab.router) app.include_router(serving.router) +app.include_router(external_creative.router) app.include_router(evaluation.router) +app.include_router(update_preflight.router) + + +@app.exception_handler(ConfigurationError) +async def configuration_error(request: Request, exc: ConfigurationError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"error": { + "code": exc.code, "message": exc.message, + "correlation_id": getattr(request.state, "correlation_id", "unknown"), "details": {}, + }}) + + +@app.exception_handler(ManagedIdentityError) +async def managed_identity_error(request: Request, exc: ManagedIdentityError) -> JSONResponse: + return _authentication_error(exc.status_code, exc.code, exc.message, + getattr(request.state, "correlation_id", "unknown")) + + +@app.exception_handler(SecretSlotError) +async def secret_slot_error(request: Request, exc: SecretSlotError) -> JSONResponse: + return _authentication_error(exc.status_code, exc.code, exc.message, + getattr(request.state, "correlation_id", "unknown")) @app.exception_handler(LifecycleError) @@ -277,6 +352,24 @@ async def observability_error(request: Request, exc: ObservabilityError) -> JSON ) +@app.exception_handler(AlertNotificationError) +async def alert_notification_error( + request: Request, exc: AlertNotificationError +) -> JSONResponse: + correlation_id = getattr(request.state, "correlation_id", "unknown") + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": exc.code, + "message": exc.message, + "correlation_id": correlation_id, + "details": {}, + } + }, + ) + + @contextmanager def _authentication_session(application: FastAPI) -> Iterator[Session]: """Use a test/application session override when present, otherwise a short owned session.""" @@ -316,6 +409,7 @@ def _authenticate_node_before_body( service.session.commit() return evidence + with _authentication_session(application) as session: service = NodeAgentService(session, request_settings) _identity, evidence = service.authenticate_with_evidence(authorization) @@ -323,6 +417,13 @@ def _authenticate_node_before_body( return evidence +def _authenticate_managed_before_body(application: FastAPI, token: str) -> Principal: + with _authentication_session(application) as session: + principal = authenticate_managed_operator(session, token) + session.commit() + return principal + + def _authenticate_capability_before_body( application: FastAPI, request_settings: Settings, @@ -389,12 +490,31 @@ async def correlation_middleware( boundary, ) response: Response | None = None + if request.method == "POST" and request.url.path == "/api/v1/operator/login": + request.state.request_body_limit_bytes = 8192 + try: + await run_in_threadpool(operator_login.limit_login, request, request_settings) + except HTTPException as exc: + response = _authentication_error( + exc.status_code, f"http_{exc.status_code}", str(exc.detail), correlation_id + ) if boundary is AccessBoundary.CONTROL_PLANE: try: - request.state.principal = authenticate_operator_token( - request_settings, + operator_token = operator_token_from_headers( request.headers.get("x-modelforge-admin-token"), + request.headers.get("authorization"), ) + if operator_token is not None and operator_token.startswith("mfopm_"): + request.state.principal = await run_in_threadpool( + _authenticate_managed_before_body, request.app, operator_token, + ) + else: + request.state.principal = authenticate_operator_token(request_settings, operator_token) + except ManagedIdentityError as exc: + response = _authentication_error(exc.status_code, exc.code, exc.message, correlation_id) + except SQLAlchemyError: + response = _authentication_error(503, "MANAGED_AUTHENTICATION_UNAVAILABLE", + "managed authentication backend is unavailable", correlation_id) except HTTPException as exc: response = _authentication_error( exc.status_code, @@ -461,15 +581,15 @@ async def correlation_middleware( response = await call_next(request) duration_seconds = time.perf_counter() - started response.headers["x-correlation-id"] = correlation_id + if boundary is AccessBoundary.CONTROL_PLANE or request.url.path == "/api/v1/operator/login": + response.headers["Cache-Control"] = "no-store" body_error_status_code = getattr( request.state, "request_body_error_status_code", None, ) effective_status_code = ( - body_error_status_code - if isinstance(body_error_status_code, int) - else response.status_code + body_error_status_code if isinstance(body_error_status_code, int) else response.status_code ) labels = { "method": request.method, @@ -536,6 +656,20 @@ def _renderable(value: object) -> object: @app.exception_handler(RequestValidationError) async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: correlation_id = getattr(request.state, "correlation_id", "unknown") + errors = exc.errors() + if request.url.path.startswith("/api/v1/selected-model-comparisons"): + # Claim secrets, dataset content and arbitrary discriminator locations never echo. + errors = [{"type": error["type"], "msg": "Invalid comparison request field"} for error in errors] + elif request.url.path.startswith("/api/v1/lab/"): + # Discriminator messages and locations can contain caller-controlled payload data. + errors = [{"type": error["type"], "msg": "Invalid lab request field"} for error in errors] + elif request.url.path.startswith("/api/v1/portfolio"): + # Portfolio snapshots are intentionally secret-free; never echo body values or paths. + errors = [{"type": error["type"], "msg": "Invalid portfolio request field"} for error in errors] + if request.url.path == "/api/v1/operator/login": + return _authentication_error( + 422, "request_validation_failed", "Invalid login request", correlation_id + ) return JSONResponse( status_code=422, content={ @@ -545,8 +679,16 @@ async def validation_error(request: Request, exc: RequestValidationError) -> JSO "correlation_id": correlation_id, "details": { "errors": [ - {key: _renderable(value) for key, value in error.items() if key != "ctx"} - for error in exc.errors() + {key: _renderable(value) for key, value in error.items() + if key != "ctx" and not ( + (request.url.path.startswith("/api/v1/configuration/") + or request.url.path.startswith("/api/v1/operator/managed-identities")) + and key in {"input", "loc"} + ) and not ( + request.url.path.startswith("/api/v1/portfolio") + and key in {"input", "loc"} + )} + for error in errors ] }, } diff --git a/backend/src/modelforge_api/persistence/__init__.py b/backend/src/modelforge_api/persistence/__init__.py index acd647e..22c517e 100644 --- a/backend/src/modelforge_api/persistence/__init__.py +++ b/backend/src/modelforge_api/persistence/__init__.py @@ -1,3 +1,4 @@ +from .model_change import ModelChangeCase from .models import Base -__all__ = ["Base"] +__all__ = ["Base", "ModelChangeCase"] diff --git a/backend/src/modelforge_api/persistence/hardware_repository.py b/backend/src/modelforge_api/persistence/hardware_repository.py index a8d071b..dcb1837 100644 --- a/backend/src/modelforge_api/persistence/hardware_repository.py +++ b/backend/src/modelforge_api/persistence/hardware_repository.py @@ -40,7 +40,10 @@ class HardwareRepository: def node_for_update(self, node_id: uuid.UUID) -> ComputeNode | None: return self.session.scalar( - select(ComputeNode).where(ComputeNode.id == node_id).with_for_update() + select(ComputeNode) + .where(ComputeNode.id == node_id) + .with_for_update() + .execution_options(populate_existing=True) ) def accelerators(self, node_id: uuid.UUID | None = None) -> list[Accelerator]: diff --git a/backend/src/modelforge_api/persistence/model_change.py b/backend/src/modelforge_api/persistence/model_change.py new file mode 100644 index 0000000..b78dc0d --- /dev/null +++ b/backend/src/modelforge_api/persistence/model_change.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from modelforge_api.persistence.models import Base, IdMixin, TimestampMixin + +MODEL_CHANGE_STATUSES = ( + "searching", + "found", + "source_checked", + "downloaded", + "ready_to_test", + "tested", + "comparison_available", + "migration_required", + "ready_to_activate", + "active", + "checked", + "rolled_back", + "rejected", +) + + +class ModelChangeCase(Base, IdMixin, TimestampMixin): + """User-facing workflow shell that only references authoritative ModelForge evidence.""" + + __tablename__ = "model_change_cases" + __table_args__ = ( + sa.CheckConstraint("version >= 1", name="ck_model_change_case_version"), + sa.CheckConstraint( + "status IN (" + ",".join(repr(value) for value in MODEL_CHANGE_STATUSES) + ")", + name="ck_model_change_case_status", + ), + sa.Index("ix_model_change_cases_binding", "project_binding_id"), + sa.Index("ix_model_change_cases_status", "status"), + sa.Index( + "uq_model_change_cases_open_binding", + "project_binding_id", + unique=True, + postgresql_where=sa.text("closed_at IS NULL"), + sqlite_where=sa.text("closed_at IS NULL"), + ), + ) + + project_binding_id: Mapped[uuid.UUID] = mapped_column( + sa.ForeignKey("project_bindings.id", ondelete="RESTRICT"), nullable=False + ) + current_deployment_id: Mapped[uuid.UUID] = mapped_column( + sa.ForeignKey("capability_deployments.id", ondelete="RESTRICT"), nullable=False + ) + candidate_model_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("models.id", ondelete="RESTRICT") + ) + candidate_revision_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("model_revisions.id", ondelete="RESTRICT") + ) + candidate_artifact_set_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("artifact_sets.id", ondelete="RESTRICT") + ) + candidate_deployment_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("capability_deployments.id", ondelete="RESTRICT") + ) + discovery_assessment_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("discovery_candidate_assessments.id", ondelete="RESTRICT") + ) + download_plan_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("download_plans.id", ondelete="RESTRICT") + ) + artifact_job_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("artifact_jobs.id", ondelete="RESTRICT") + ) + selected_comparison_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("selected_model_comparisons.id", ondelete="RESTRICT") + ) + migration_plan_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("migration_plans.id", ondelete="RESTRICT") + ) + lifecycle_plan_id: Mapped[uuid.UUID | None] = mapped_column( + sa.ForeignKey("lifecycle_promotion_plans.id", ondelete="RESTRICT") + ) + status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="searching") + status_reason: Mapped[str] = mapped_column(sa.Text, nullable=False, default="") + version: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=1) + created_by: Mapped[str] = mapped_column(sa.String(255), nullable=False) + updated_by: Mapped[str] = mapped_column(sa.String(255), nullable=False) + context: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, default=dict) + activated_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True)) + checked_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True)) + closed_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True)) diff --git a/backend/src/modelforge_api/persistence/models.py b/backend/src/modelforge_api/persistence/models.py index be1b74c..cc6a712 100644 --- a/backend/src/modelforge_api/persistence/models.py +++ b/backend/src/modelforge_api/persistence/models.py @@ -58,6 +58,123 @@ class Base(DeclarativeBase): pass +class SecretSlotState(Base): + __tablename__ = "platform_secret_state" + __table_args__ = (CheckConstraint("singleton_id = 1", name="ck_secret_state_singleton"), CheckConstraint("revision >= 0", name="ck_secret_state_revision")) + singleton_id: Mapped[int] = mapped_column(Integer, primary_key=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + initialized: Mapped[bool] = mapped_column(Boolean, nullable=False) + + +@event.listens_for(SecretSlotState.__table__, "after_create") +def _seed_secret_slot_state(_table: Any, connection: Connection, **_kwargs: Any) -> None: + connection.execute(insert(SecretSlotState).values(singleton_id=1, revision=0, initialized=False)) + + +class SecretSlotVersion(Base): + __tablename__ = "platform_secret_versions" + __table_args__ = (CheckConstraint("version > 0", name="ck_secret_version_positive"),) + version: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) + key_id: Mapped[str] = mapped_column(String(64), nullable=False) + secret_ciphertext: Mapped[str] = mapped_column(Text, nullable=False) + secret_nonce: Mapped[str] = mapped_column(String(32), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class SecretSlotTest(Base): + __tablename__ = "platform_secret_tests" + version: Mapped[int] = mapped_column(ForeignKey("platform_secret_versions.version", ondelete="RESTRICT"), primary_key=True) + passed: Mapped[bool] = mapped_column(Boolean, nullable=False) + tested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + code: Mapped[str] = mapped_column(String(64), nullable=False) + + +class SecretSlotActivation(Base): + __tablename__ = "platform_secret_activations" + __table_args__ = (CheckConstraint("singleton_id = 1", name="ck_secret_activation_singleton"),) + singleton_id: Mapped[int] = mapped_column(Integer, primary_key=True) + version: Mapped[int] = mapped_column(ForeignKey("platform_secret_versions.version", ondelete="RESTRICT"), nullable=False) + activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ManagedOperatorState(Base): + __tablename__ = "managed_operator_state" + __table_args__ = ( + CheckConstraint("singleton_id = 1", name="ck_managed_operator_singleton"), + CheckConstraint("revision >= 0", name="ck_managed_operator_revision"), + ) + singleton_id: Mapped[int] = mapped_column(Integer, primary_key=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + +@event.listens_for(ManagedOperatorState.__table__, "after_create") +def _seed_managed_operator_state(_table: Any, connection: Connection, **_kwargs: Any) -> None: + connection.execute(insert(ManagedOperatorState).values(singleton_id=1, revision=0)) + + +class ManagedOperatorIdentity(Base): + __tablename__ = "managed_operator_identities" + __table_args__ = ( + CheckConstraint("role IN ('viewer','operator','admin')", name="ck_managed_operator_role"), + CheckConstraint("version >= 1", name="ck_managed_operator_version"), + ) + slug: Mapped[str] = mapped_column(String(63), primary_key=True) + role: Mapped[str] = mapped_column(String(16), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ManagedOperatorCredential(Base): + __tablename__ = "managed_operator_credentials" + __table_args__ = ( + Index("uq_managed_operator_current", "identity_slug", unique=True, + postgresql_where=sa_text("revoked_at IS NULL AND superseded_at IS NULL"), + sqlite_where=sa_text("revoked_at IS NULL AND superseded_at IS NULL")), + ) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + identity_slug: Mapped[str] = mapped_column( + ForeignKey("managed_operator_identities.slug", ondelete="RESTRICT"), index=True, + ) + token_sha256: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ManagedOperatorCredentialActivation(Base): + """Rebuildable admission evidence, deliberately cleared by supported recovery.""" + __tablename__ = "managed_operator_credential_activations" + credential_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("managed_operator_credentials.id", ondelete="RESTRICT"), primary_key=True, + ) + activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ConfigurationState(Base): + __tablename__ = "configuration_state" + __table_args__ = (CheckConstraint("id = 1"), CheckConstraint("revision >= 0")) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + overrides: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + + +class ConfigurationRevision(Base): + __tablename__ = "configuration_revisions" + + revision: Mapped[int] = mapped_column(Integer, primary_key=True) + previous_revision: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + actor: Mapped[str] = mapped_column(String(255), nullable=False) + correlation_id: Mapped[str] = mapped_column(String(255), nullable=False) + overrides: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + preview_digest: Mapped[str] = mapped_column(String(64), nullable=False) + + _PROTECTED_AUDIT_TABLES = frozenset({"audit_events", "audit_chain_heads"}) @@ -974,12 +1091,14 @@ class CapabilityContract(Base, IdMixin, TimestampMixin): __tablename__ = "capability_contracts" __table_args__ = ( UniqueConstraint("capability_id", "version", name="uq_capability_contract_version"), + CheckConstraint("serving_generation >= 0", name="ck_capability_serving_generation"), ) capability_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("capabilities.id", ondelete="RESTRICT"), index=True ) version: Mapped[int] = mapped_column(Integer) + serving_generation: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") input_schema: Mapped[dict[str, Any]] = mapped_column(JSON) output_schema: Mapped[dict[str, Any]] = mapped_column(JSON) contract: Mapped[dict[str, Any]] = mapped_column(JSON) @@ -1000,6 +1119,87 @@ class Project(Base, IdMixin, TimestampMixin): bindings: Mapped[list[ProjectBinding]] = relationship(back_populates="project") +class PortfolioRepositoryObservation(Base, IdMixin, TimestampMixin): + """Latest source inventory state, optionally linked to a ModelForge project identity.""" + + __tablename__ = "portfolio_repository_observations" + __table_args__ = ( + UniqueConstraint( + "source_system", "external_repository_id", name="uq_portfolio_repo_external_id" + ), + UniqueConstraint("source_system", "project_key", name="uq_portfolio_source_project_key"), + UniqueConstraint("project_id", name="uq_portfolio_repository_project_id"), + CheckConstraint("length(head_sha) = 40", name="ck_portfolio_repo_head_sha_length"), + CheckConstraint( + "pending_head_sha IS NULL OR length(pending_head_sha) = 40", + name="ck_portfolio_repo_pending_sha_length", + ), + CheckConstraint( + "classification IN ('local_model', 'hosted_model', 'gateway', 'mixed', " + "'model_management', 'runtime_observer', 'non_ai', 'unknown')", + name="ck_portfolio_repo_classification", + ), + ) + + project_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("projects.id", ondelete="RESTRICT"), index=True, nullable=True + ) + project_key: Mapped[str | None] = mapped_column(String(128)) + source_system: Mapped[str] = mapped_column(String(16)) + external_repository_id: Mapped[str] = mapped_column(String(128)) + slug: Mapped[str] = mapped_column(String(255)) + default_branch: Mapped[str] = mapped_column(String(255)) + head_sha: Mapped[str] = mapped_column(String(40)) + classification: Mapped[str] = mapped_column(String(32)) + code_evidence: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) + runtime_evidence: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) + source_observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + source_snapshot_id: Mapped[str] = mapped_column(String(128)) + source_snapshot_digest: Mapped[str] = mapped_column(String(64)) + pending_head_sha: Mapped[str | None] = mapped_column(String(40)) + pending_default_branch: Mapped[str | None] = mapped_column(String(255)) + pending_project_key: Mapped[str | None] = mapped_column(String(128)) + pending_classification: Mapped[str | None] = mapped_column(String(32)) + pending_code_evidence: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON) + pending_observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + pending_snapshot_id: Mapped[str | None] = mapped_column(String(128)) + pending_snapshot_digest: Mapped[str | None] = mapped_column(String(64)) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + stale_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class PortfolioReconciliationRun(Base, IdMixin, TimestampMixin): + """Idempotency ledger for exact replay and conflict detection of source snapshots.""" + + __tablename__ = "portfolio_reconciliation_runs" + __table_args__ = ( + UniqueConstraint("source_system", "snapshot_id", name="uq_portfolio_snapshot_identity"), + CheckConstraint("length(payload_digest) = 64", name="ck_portfolio_snapshot_digest_length"), + CheckConstraint( + "projects_created >= 0 AND repositories_created >= 0 " + "AND repositories_updated >= 0 AND stale_observations >= 0 " + "AND repositories_marked_stale >= 0 AND runtime_observations_updated >= 0 " + "AND head_changes_review_required >= 0 AND head_changes_accepted >= 0", + name="ck_portfolio_snapshot_counts_nonnegative", + ), + ) + + source_system: Mapped[str] = mapped_column(String(16)) + snapshot_id: Mapped[str] = mapped_column(String(128)) + payload_digest: Mapped[str] = mapped_column(String(64)) + observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + complete: Mapped[bool] = mapped_column(Boolean, default=False) + stale_snapshot: Mapped[bool] = mapped_column(Boolean, default=False) + projects_created: Mapped[int] = mapped_column(Integer, default=0) + repositories_created: Mapped[int] = mapped_column(Integer, default=0) + repositories_updated: Mapped[int] = mapped_column(Integer, default=0) + stale_observations: Mapped[int] = mapped_column(Integer, default=0) + repositories_marked_stale: Mapped[int] = mapped_column(Integer, default=0) + runtime_observations_updated: Mapped[int] = mapped_column(Integer, default=0) + head_changes_review_required: Mapped[int] = mapped_column(Integer, default=0) + head_changes_accepted: Mapped[int] = mapped_column(Integer, default=0) + + class ProjectBinding(Base, IdMixin, TimestampMixin): __tablename__ = "project_bindings" __table_args__ = ( @@ -1026,6 +1226,18 @@ class ProjectBinding(Base, IdMixin, TimestampMixin): project: Mapped[Project] = relationship(back_populates="bindings") +class ProjectOnboarding(Base): + __tablename__ = "project_onboarding" + + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("projects.id", ondelete="RESTRICT"), primary_key=True + ) + owner: Mapped[str] = mapped_column(String(128)) + environment: Mapped[str] = mapped_column(String(32)) + labels: Mapped[list[str]] = mapped_column(JSON, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + class ComputeNode(Base, IdMixin, TimestampMixin): __tablename__ = "compute_nodes" @@ -1335,9 +1547,28 @@ class BenchmarkSuite(Base, IdMixin, TimestampMixin): class BenchmarkRun(Base, IdMixin): __tablename__ = "benchmark_runs" - deployment_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("deployments.id", ondelete="RESTRICT"), index=True + __table_args__ = ( + UniqueConstraint( + "actor_subject", "idempotency_key", name="uq_benchmark_runs_actor_idempotency" + ), + CheckConstraint( + "(actor_subject IS NULL AND idempotency_key IS NULL AND " + "request_fingerprint IS NULL AND capability_deployment_id IS NULL) OR " + "(actor_subject IS NOT NULL AND idempotency_key IS NOT NULL AND " + "request_fingerprint IS NOT NULL AND capability_deployment_id IS NOT NULL)", + name="ck_benchmark_runs_operator_identity", + ), ) + deployment_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("deployments.id", ondelete="RESTRICT"), index=True, nullable=True + ) + capability_deployment_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("capability_deployments.id", ondelete="RESTRICT") + ) + target_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON) + actor_subject: Mapped[str | None] = mapped_column(String(255)) + idempotency_key: Mapped[str | None] = mapped_column(String(128)) + request_fingerprint: Mapped[str | None] = mapped_column(String(64)) suite_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("benchmark_suites.id", ondelete="RESTRICT"), index=True ) @@ -1354,10 +1585,16 @@ class BenchmarkRun(Base, IdMixin): seed: Mapped[int | None] = mapped_column(Integer) results: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) status: Mapped[str] = mapped_column(String(32)) + failure_code: Mapped[str | None] = mapped_column(String(64)) comparable: Mapped[bool] = mapped_column(Boolean, default=True) stale_reason: Mapped[str | None] = mapped_column(Text) + cancellation_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + generation: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False) + + __mapper_args__ = {"version_id_col": generation} # noqa: RUF012 class Experiment(Base, IdMixin, TimestampMixin): @@ -1621,8 +1858,8 @@ class ServiceCredential(Base, IdMixin): "uq_active_service_client_credential", "service_client_id", unique=True, - postgresql_where=sa_text("revoked_at IS NULL"), - sqlite_where=sa_text("revoked_at IS NULL"), + postgresql_where=sa_text("revoked_at IS NULL AND superseded_at IS NULL"), + sqlite_where=sa_text("revoked_at IS NULL AND superseded_at IS NULL"), ), ) @@ -1631,12 +1868,115 @@ class ServiceCredential(Base, IdMixin): ) secret_hash: Mapped[str] = mapped_column(String(64), unique=True) secret_prefix: Mapped[str] = mapped_column(String(16)) + superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class ExternalCreativeAllocation(Base, IdMixin): + """A service-client-owned reservation for a creative workload run outside ModelForge.""" + + __tablename__ = "external_creative_allocations" + __table_args__ = ( + UniqueConstraint( + "service_client_id", "idempotency_key", name="uq_external_creative_idempotency" + ), + CheckConstraint("required_vram_bytes > 0", name="ck_external_creative_vram_positive"), + CheckConstraint("ttl_seconds >= 30 AND ttl_seconds <= 3600", name="ck_external_creative_ttl"), + ) + + service_client_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("service_clients.id", ondelete="RESTRICT"), index=True + ) + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("projects.id", ondelete="RESTRICT"), index=True + ) + accelerator_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("accelerators.id", ondelete="RESTRICT"), index=True + ) + request_id: Mapped[str] = mapped_column(String(128), index=True) + idempotency_key: Mapped[str] = mapped_column(String(128)) + request_sha256: Mapped[str] = mapped_column(String(64)) + workload: Mapped[str] = mapped_column(String(32)) + resource_ref: Mapped[str] = mapped_column(String(255)) + provider_resource_id: Mapped[str] = mapped_column(String(128)) + required_vram_bytes: Mapped[int] = mapped_column(BigInteger) + priority: Mapped[str] = mapped_column(String(32)) + ttl_seconds: Mapped[int] = mapped_column(Integer) + state: Mapped[str] = mapped_column(String(32), index=True) + decision_code: Mapped[str] = mapped_column(String(64)) + decision_evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + required_actions: Mapped[list[str]] = mapped_column(JSON, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + acquired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + uncertain_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ExternalCreativeResource(Base, IdMixin): + __tablename__ = "external_creative_resources" + __table_args__ = ( + UniqueConstraint("project_id", "resource_ref", "workload", "provider_resource_id", + name="uq_external_creative_resource_binding"), + CheckConstraint("configured_vram_bytes > 0", name="ck_external_resource_vram_positive"), + Index("ix_external_creative_resources_project", "project_id", "approved"), + ) + project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id", ondelete="RESTRICT")) + resource_ref: Mapped[str] = mapped_column(String(255)) + workload: Mapped[str] = mapped_column(String(32)) + provider_resource_id: Mapped[str] = mapped_column(String(128)) + approved: Mapped[bool] = mapped_column(Boolean, server_default="false") + configured_vram_bytes: Mapped[int] = mapped_column(BigInteger) + measured_peak_vram_bytes: Mapped[int | None] = mapped_column(BigInteger) + measurement_count: Mapped[int] = mapped_column(Integer, server_default="0") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class ExternalCreativeActionAttestation(Base, IdMixin): + __tablename__ = "external_creative_action_attestations" + __table_args__ = ( + UniqueConstraint("allocation_id", "action", name="uq_external_creative_action_attestation"), + CheckConstraint("before_vram_bytes >= 0 AND after_vram_bytes >= 0", + name="ck_external_action_vram_nonnegative"), + ) + allocation_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("external_creative_allocations.id", ondelete="RESTRICT")) + action: Mapped[str] = mapped_column(String(64)) + before_vram_bytes: Mapped[int] = mapped_column(BigInteger) + after_vram_bytes: Mapped[int] = mapped_column(BigInteger) + observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + validated: Mapped[bool] = mapped_column(Boolean) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class ExternalCreativeProfileObservation(Base, IdMixin): + __tablename__ = "external_creative_profile_observations" + __table_args__ = ( + UniqueConstraint("allocation_id", name="uq_external_creative_profile_observation_allocation"), + CheckConstraint("observed_peak_vram_bytes > 0 AND reserved_vram_bytes > 0", + name="ck_external_profile_vram_positive"), + CheckConstraint("duration_ms >= 0 AND duration_ms <= 604800000", + name="ck_external_profile_duration"), + ) + allocation_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("external_creative_allocations.id", ondelete="RESTRICT")) + resource_ref: Mapped[str] = mapped_column(String(255)) + provider_resource_id: Mapped[str] = mapped_column(String(128)) + workload: Mapped[str] = mapped_column(String(32)) + observed_peak_vram_bytes: Mapped[int] = mapped_column(BigInteger) + reserved_vram_bytes: Mapped[int] = mapped_column(BigInteger) + duration_ms: Mapped[int] = mapped_column(BigInteger) + outcome: Mapped[str] = mapped_column(String(16)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + class ResidencyAllocation(Base, IdMixin, TimestampMixin): __tablename__ = "residency_allocations" __table_args__ = (UniqueConstraint("capability_deployment_id", name="uq_residency_deployment"),) @@ -1739,6 +2079,9 @@ class ServingJob(Base, IdMixin, TimestampMixin): gateway_request_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("gateway_requests.id", ondelete="SET NULL"), index=True ) + lab_execution_run_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("lab_execution_runs.id", ondelete="SET NULL"), index=True + ) operation: Mapped[str] = mapped_column(String(32), index=True) status: Mapped[str] = mapped_column(String(32), default="queued", index=True) priority: Mapped[str] = mapped_column(String(32), default="production") @@ -1755,6 +2098,53 @@ class ServingJob(Base, IdMixin, TimestampMixin): finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class LabExecutionRun(Base, IdMixin, TimestampMixin): + __tablename__ = "lab_execution_runs" + __table_args__ = ( + UniqueConstraint("actor_subject", "idempotency_key", name="uq_lab_run_actor_idempotency"), + CheckConstraint( + "status IN ('accepted', 'preparing', 'queued', 'running', 'cancel_requested', " + "'completed', 'failed', 'cancelled', 'interrupted')", + name="ck_lab_execution_run_status", + ), + CheckConstraint("input_count BETWEEN 1 AND 8", name="ck_lab_execution_run_input_count"), + CheckConstraint("timeout_seconds BETWEEN 5 AND 300", name="ck_lab_execution_run_timeout"), + CheckConstraint( + "queue_timeout_seconds BETWEEN 1 AND 60", name="ck_lab_execution_run_queue_timeout" + ), + CheckConstraint( + "retention_seconds BETWEEN 60 AND 3600", name="ck_lab_execution_run_retention" + ), + ) + + actor_subject: Mapped[str] = mapped_column(String(255), index=True) + idempotency_key: Mapped[str] = mapped_column(String(128)) + request_fingerprint: Mapped[str] = mapped_column(String(64)) + status: Mapped[str] = mapped_column(String(24), default="accepted", index=True) + capability: Mapped[str] = mapped_column(String(128), default="rag.embedding@1") + target_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON) + input_sha256: Mapped[str] = mapped_column(String(64)) + input_count: Mapped[int] = mapped_column(Integer) + input_encoded_bytes: Mapped[int] = mapped_column(Integer) + payload_reference: Mapped[str | None] = mapped_column(String(128)) + input_payload_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + gateway_request_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("gateway_requests.id", ondelete="RESTRICT"), unique=True, index=True + ) + timeout_seconds: Mapped[int] = mapped_column(Integer, default=60) + queue_timeout_seconds: Mapped[int] = mapped_column(Integer, default=10) + retention_seconds: Mapped[int] = mapped_column(Integer, default=300) + output_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON) + worker_observations: Mapped[dict[str, Any] | None] = mapped_column(JSON) + failure_code: Mapped[str | None] = mapped_column(String(64)) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + dispatch_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancellation_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancellation_acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + class SchedulerPolicyRevision(Base, IdMixin): __tablename__ = "scheduler_policy_revisions" @@ -2439,6 +2829,12 @@ Index("ix_migrations_binding_status", Migration.project_binding_id, Migration.st Index( "ix_benchmark_runs_deployment_completed", BenchmarkRun.deployment_id, BenchmarkRun.completed_at ) +Index("ix_benchmark_runs_actor_subject", BenchmarkRun.actor_subject) +Index( + "ix_benchmark_runs_capability_deployment_completed", + BenchmarkRun.capability_deployment_id, + BenchmarkRun.completed_at, +) Index( "ix_capability_deployments_contract_channel_status", CapabilityDeployment.capability_contract_id, @@ -3085,7 +3481,7 @@ def _append_canonical_audit_event( """ if not isinstance(details, dict): - raise ValueError("canonical audit details must be an object") + raise TypeError("canonical audit details must be an object") if expected_event_count != expected_last_sequence: raise ValueError("canonical audit append requires a coherent predecessor count") if expected_hash_format != AUDIT_CURRENT_HASH_FORMAT: @@ -3518,6 +3914,43 @@ class AlertHistoryEvent(Base, IdMixin): ) +class AlertNotificationDelivery(Base, IdMixin): + __tablename__ = "alert_notification_deliveries" + __table_args__ = ( + UniqueConstraint("idempotency_key", name="uq_alert_notification_idempotency"), + CheckConstraint( + "event_kind IN ('FIRING', 'RESOLVED', 'TEST')", + name="ck_alert_notification_event_kind", + ), + CheckConstraint( + "status IN ('PENDING', 'DISPATCHING', 'RETRY_WAIT', 'DELIVERED', 'FAILED', 'HELD_FOR_REVIEW')", + name="ck_alert_notification_status", + ), + CheckConstraint("attempt_count >= 0", name="ck_alert_notification_attempt_count"), + ) + + alert_history_event_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("alert_history_events.id", ondelete="RESTRICT"), index=True + ) + route_key: Mapped[str] = mapped_column(String(64), default="default") + idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False) + event_kind: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + severity: Mapped[str | None] = mapped_column(String(16), index=True) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False, index=True) + attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + claim_token_hash: Mapped[str | None] = mapped_column(String(64)) + last_http_status: Mapped[int | None] = mapped_column(Integer) + last_error_code: Mapped[str | None] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + class MaintenanceWindow(Base, IdMixin): __tablename__ = "maintenance_windows" @@ -4289,3 +4722,76 @@ def _prevent_immutable_field_changes(_mapper: Any, _connection: Any, target: Bas for _immutable_type in IMMUTABLE_FIELDS: event.listen(_immutable_type, "before_update", _prevent_immutable_field_changes) + + +class ModelUpdateWatch(Base, IdMixin): + __tablename__ = "model_update_watches" + __table_args__ = (UniqueConstraint("model_id", "upstream_ref", name="uq_model_watch_ref"),) + model_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("models.id", ondelete="RESTRICT")) + baseline_revision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("model_revisions.id", ondelete="RESTRICT")) + baseline_commit_sha: Mapped[str] = mapped_column(String(64)) + upstream_ref: Mapped[str] = mapped_column(String(255)) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + version: Mapped[int] = mapped_column(Integer, default=1, server_default="1") + last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error_code: Mapped[str | None] = mapped_column(String(64)) + check_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + check_token: Mapped[uuid.UUID | None] = mapped_column(Uuid) + + +class ModelUpdateNotice(Base, IdMixin): + __tablename__ = "model_update_notices" + __table_args__ = ( + UniqueConstraint("watch_id", "candidate_commit_sha", name="uq_model_notice_commit"), + CheckConstraint("status IN ('new', 'dismissed')", name="ck_model_notice_status"), + ) + watch_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("model_update_watches.id", ondelete="RESTRICT")) + model_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("models.id", ondelete="RESTRICT")) + baseline_commit_sha: Mapped[str] = mapped_column(String(64)) + candidate_revision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("model_revisions.id", ondelete="RESTRICT")) + candidate_commit_sha: Mapped[str] = mapped_column(String(64)) + status: Mapped[str] = mapped_column(String(16), default="new") + discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class SelectedModelComparison(Base, IdMixin, TimestampMixin): + """One explicitly delegated current/candidate comparison; no implicit replay.""" + + __tablename__ = "selected_model_comparisons" + __table_args__ = ( + UniqueConstraint("project_binding_id", "idempotency_key", name="uq_selected_comparison_key"), + Index("uq_selected_comparison_active_binding", "project_binding_id", unique=True, + postgresql_where=sa_text("status IN ('pending', 'running', 'cancel_requested')"), + sqlite_where=sa_text("status IN ('pending', 'running', 'cancel_requested')")), + CheckConstraint("status IN ('pending', 'running', 'cancel_requested', 'completed', 'failed', 'cancelled', 'interrupted')", name="ck_selected_comparison_status"), + CheckConstraint("expected_cases BETWEEN 1 AND 100", name="ck_selected_comparison_cases"), + CheckConstraint("current_completed_cases BETWEEN 0 AND expected_cases AND candidate_completed_cases BETWEEN 0 AND expected_cases", name="ck_selected_comparison_progress"), + CheckConstraint("version > 0", name="ck_selected_comparison_version"), + CheckConstraint("current_deployment_id <> candidate_deployment_id", name="ck_selected_comparison_distinct_targets"), + ) + project_binding_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("project_bindings.id", ondelete="RESTRICT")) + consumer_client_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("service_clients.id", ondelete="RESTRICT")) + suite_revision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("evaluation_suite_revisions.id", ondelete="RESTRICT")) + current_deployment_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("capability_deployments.id", ondelete="RESTRICT")) + candidate_deployment_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("capability_deployments.id", ondelete="RESTRICT")) + candidate_route_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("capability_experiment_routes.id", ondelete="RESTRICT")) + idempotency_key: Mapped[str] = mapped_column(String(128)) + request_digest: Mapped[str] = mapped_column(String(64)) + actor_id: Mapped[str] = mapped_column(String(128)) + pins: Mapped[dict[str, Any]] = mapped_column(JSON) + status: Mapped[str] = mapped_column(String(32), default="pending") + version: Mapped[int] = mapped_column(Integer, default=1, server_default="1") + current_completed_cases: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + candidate_completed_cases: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + expected_cases: Mapped[int] = mapped_column(Integer) + claim_token_hash: Mapped[str | None] = mapped_column(String(64)) + claim_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancel_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + error_code: Mapped[str | None] = mapped_column(String(64)) + result_digest: Mapped[str | None] = mapped_column(String(64)) + baseline_run_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("evaluation_runs.id", ondelete="RESTRICT")) + candidate_run_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("evaluation_runs.id", ondelete="RESTRICT")) + evaluation_comparison_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("evaluation_comparisons.id", ondelete="RESTRICT")) diff --git a/backend/src/modelforge_api/persistence/registry_repository.py b/backend/src/modelforge_api/persistence/registry_repository.py index a6822b9..a8fb3eb 100644 --- a/backend/src/modelforge_api/persistence/registry_repository.py +++ b/backend/src/modelforge_api/persistence/registry_repository.py @@ -13,6 +13,8 @@ from modelforge_api.persistence.models import ( Model, ModelArtifact, ModelRevision, + ModelUpdateNotice, + ModelUpdateWatch, RuntimeProfile, StorageRoot, ) @@ -171,6 +173,20 @@ class RegistryRepository: def dependencies(self, resource_type: str, resource_id: uuid.UUID) -> list[dict[str, str]]: dependencies: list[dict[str, str]] = [] + if resource_type in {"model", "model_revision"}: + for entity, relation, column in ( + (ModelUpdateWatch, "update_tracking_baseline", + ModelUpdateWatch.model_id if resource_type == "model" + else ModelUpdateWatch.baseline_revision_id), + (ModelUpdateNotice, "update_candidate_history", + ModelUpdateNotice.model_id if resource_type == "model" + else ModelUpdateNotice.candidate_revision_id), + ): + dependencies.extend( + {"resource_type": "model_update_watch" if entity is ModelUpdateWatch + else "model_update_notice", "resource_id": str(item), "relation": relation} + for item in self.session.scalars(select(entity.id).where(column == resource_id)) + ) if resource_type == "model": ids = self.session.scalars( select(ModelRevision.id).where(ModelRevision.model_id == resource_id) diff --git a/backend/src/modelforge_api/persistence/runtime_engine.py b/backend/src/modelforge_api/persistence/runtime_engine.py index 4c6371a..31dc712 100644 --- a/backend/src/modelforge_api/persistence/runtime_engine.py +++ b/backend/src/modelforge_api/persistence/runtime_engine.py @@ -20,4 +20,3 @@ def register_application_engine(engine: Engine) -> Engine: def is_application_connection(connection: Connection) -> bool: return connection.engine in _APPLICATION_ENGINES - diff --git a/backend/src/modelforge_api/persistence/serving_repository.py b/backend/src/modelforge_api/persistence/serving_repository.py index 6e21fab..23650ce 100644 --- a/backend/src/modelforge_api/persistence/serving_repository.py +++ b/backend/src/modelforge_api/persistence/serving_repository.py @@ -53,6 +53,15 @@ class ServingRepository: self.session.flush() return entity + def lock_contract_route(self, contract_id: uuid.UUID) -> CapabilityContract | None: + """Serialize stable-route writers; caller retains this lock through commit.""" + return self.session.scalar( + select(CapabilityContract) + .where(CapabilityContract.id == contract_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + def capability(self, key: str) -> Capability | None: return self.session.scalar(select(Capability).where(Capability.key == key)) @@ -251,6 +260,7 @@ class ServingRepository: select(ServiceCredential).where( ServiceCredential.service_client_id == client_id, ServiceCredential.revoked_at.is_(None), + ServiceCredential.superseded_at.is_(None), ) ) @@ -342,7 +352,9 @@ class ServingRepository: ServingJob.compute_node_id == node_id, or_( ServingJob.status == "queued", - (ServingJob.status == "running") & (ServingJob.lease_expires_at < now), + (ServingJob.status == "running") + & ServingJob.lab_execution_run_id.is_(None) + & (ServingJob.lease_expires_at < now), ), ) .order_by(priority_order, ServingJob.created_at) diff --git a/backend/src/modelforge_api/providers/huggingface.py b/backend/src/modelforge_api/providers/huggingface.py index 19c91a1..c05d8ee 100644 --- a/backend/src/modelforge_api/providers/huggingface.py +++ b/backend/src/modelforge_api/providers/huggingface.py @@ -4,7 +4,7 @@ from collections.abc import Iterable from dataclasses import dataclass, field from datetime import datetime from pathlib import PurePosixPath -from typing import Any, Protocol +from typing import Any, Literal, Protocol import httpx from huggingface_hub import HfApi @@ -116,8 +116,10 @@ class OfficialHuggingFaceProvider: """Narrow adapter over the official huggingface_hub client; no HTML scraping.""" def __init__(self, *, token: str | None = None, timeout: float = 30) -> None: - self.api = HfApi(token=token) - self.token = token + # Explicit False disables ambient HF_TOKEN/cache credentials. The official public + # provider endpoint is fixed; HF_ENDPOINT must not redirect credentials elsewhere. + self.api = HfApi(endpoint="https://huggingface.co", token=token or False) + self.token: str | Literal[False] = token or False self.timeout = timeout @staticmethod diff --git a/backend/src/modelforge_api/services/acquisition.py b/backend/src/modelforge_api/services/acquisition.py index 1e654ab..ba705e2 100644 --- a/backend/src/modelforge_api/services/acquisition.py +++ b/backend/src/modelforge_api/services/acquisition.py @@ -4,6 +4,7 @@ import hashlib import json import secrets import uuid +from collections.abc import Callable from datetime import UTC, datetime, timedelta from typing import Any @@ -126,13 +127,20 @@ class AcquisitionService: *, actor_type: str = "operator", actor_id: str = "local-operator", + credential_configured: bool | Callable[[], bool] | None = None, ) -> None: self.session = session self.settings = settings self.provider = provider + self._credential_configured = bool(settings.hf_token) if credential_configured is None else credential_configured self.repo = AcquisitionRepository(session) self.audit = AuditWriter(session, actor_type, actor_id) + @property + def credential_configured(self) -> bool: + value = self._credential_configured + return value() if callable(value) else value + def _commit(self) -> None: self.session.commit() @@ -386,7 +394,7 @@ class AcquisitionService: revision = self.session.get(ModelRevision, artifact_set.revision_id) if not snapshot or not revision: raise AcquisitionError("artifact set provenance is incomplete") - if snapshot.access_state != "public" and not self.settings.hf_token: + if snapshot.access_state != "public" and not self.credential_configured: raise AcquisitionError( f"repository access is {snapshot.access_state}; Hugging Face authentication is required" ) diff --git a/backend/src/modelforge_api/services/alert_notifications.py b/backend/src/modelforge_api/services/alert_notifications.py new file mode 100644 index 0000000..1f2d389 --- /dev/null +++ b/backend/src/modelforge_api/services/alert_notifications.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import hashlib +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any +from urllib.parse import urlsplit + +import httpx +import structlog +from sqlalchemy import or_, select, update +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Principal +from modelforge_api.domain.alert_notifications import ( + NotificationDeliveryResponse, + NotificationKind, + NotificationStatus, + NotificationTestResponse, +) +from modelforge_api.persistence.models import ( + AlertHistoryEvent, + AlertNotificationDelivery, + OperationalAlert, +) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.settings import Settings + +logger = structlog.get_logger() +_SEVERITY_RANK = {"INFO": 0, "WARNING": 1, "CRITICAL": 2} +_ACTIVE_STATES = ("PENDING", "DISPATCHING", "RETRY_WAIT") + + +class AlertNotificationError(RuntimeError): + def __init__(self, status_code: int, code: str, message: str) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _route_ready(settings: Settings) -> bool: + if not settings.alert_notifications_enabled or settings.alert_webhook_url is None: + return False + try: + parsed = urlsplit(settings.alert_webhook_url.get_secret_value()) + hostname = (parsed.hostname or "").casefold().rstrip(".") + allowed = set(settings.alert_webhook_allowed_hosts.split(",")) + return ( + parsed.scheme.casefold() == "https" + and hostname in allowed + and parsed.username is None + and parsed.password is None + and not parsed.fragment + and parsed.port in (None, 443) + ) + except ValueError: + return False + + +def _payload(alert: OperationalAlert, event: AlertHistoryEvent) -> dict[str, Any]: + return { + "schema_version": 1, + "event_id": str(event.id), + "event": event.to_state, + "alert_id": str(alert.id), + "alert_type": alert.alert_type[:64], + "severity": alert.severity, + "subject_type": alert.subject_type[:64], + "subject_ref": alert.subject_ref[:255], + "summary": alert.summary[:500], + "occurred_at": event.occurred_at.isoformat(), + } + + +def enqueue_alert_transition( + session: Session, + settings: Settings, + alert: OperationalAlert, + event: AlertHistoryEvent, +) -> None: + """Add one route delivery in the caller's alert/history transaction.""" + kind = event.to_state + if ( + not settings.alert_notifications_enabled + or kind not in {NotificationKind.FIRING.value, NotificationKind.RESOLVED.value} + or _SEVERITY_RANK.get(alert.severity, -1) + < _SEVERITY_RANK[settings.alert_webhook_min_severity] + ): + return + session.add( + AlertNotificationDelivery( + alert_history_event_id=event.id, + route_key="default", + idempotency_key=f"alert:{event.id}:default", + event_kind=kind, + severity=alert.severity, + payload=_payload(alert, event), + status=NotificationStatus.PENDING.value, + attempt_count=0, + next_attempt_at=None, + ) + ) + + +class AlertNotificationService: + def __init__(self, session: Session, settings: Settings) -> None: + self.session = session + self.settings = settings + + def queue_test(self, principal: Principal, correlation_id: str | None) -> NotificationTestResponse: + if not _route_ready(self.settings): + raise AlertNotificationError( + 503, + "NOTIFICATION_ROUTE_UNAVAILABLE", + "The deployment-managed alert notification route is unavailable", + ) + event_id = secrets.token_hex(16) + row = AlertNotificationDelivery( + alert_history_event_id=None, + route_key="default", + idempotency_key=f"test:{event_id}:default", + event_kind=NotificationKind.TEST.value, + severity=None, + payload={ + "schema_version": 1, + "event_id": event_id, + "event": "TEST", + "summary": "ModelForge operational alert webhook test", + "occurred_at": _now().isoformat(), + }, + status=NotificationStatus.PENDING.value, + attempt_count=0, + next_attempt_at=None, + ) + self.session.add(row) + self.session.flush() + AuditWriter( + self.session, + context=AuditContext("operator", principal.subject, correlation_id), + ).write( + "ALERT_NOTIFICATION_TEST_QUEUED", + "alert_notification_delivery", + str(row.id), + {"route_key": "default", "delivery_id": str(row.id)}, + ) + self.session.commit() + return NotificationTestResponse( + delivery=NotificationDeliveryResponse.model_validate(row), + message="Test notification queued for asynchronous delivery", + ) + + def deliveries(self, limit: int) -> list[NotificationDeliveryResponse]: + rows = self.session.scalars( + select(AlertNotificationDelivery) + .order_by(AlertNotificationDelivery.created_at.desc(), AlertNotificationDelivery.id.desc()) + .limit(limit) + ) + return [NotificationDeliveryResponse.model_validate(row) for row in rows] + + +def _claim( + session: Session, settings: Settings, now: datetime +) -> list[tuple[Any, str, str, dict[str, Any], int]]: + due = or_( + AlertNotificationDelivery.status == NotificationStatus.PENDING.value, + ( + (AlertNotificationDelivery.status == NotificationStatus.RETRY_WAIT.value) + & (AlertNotificationDelivery.next_attempt_at <= now) + ), + ( + (AlertNotificationDelivery.status == NotificationStatus.DISPATCHING.value) + & (AlertNotificationDelivery.lease_expires_at <= now) + ), + ) + rows = list( + session.scalars( + select(AlertNotificationDelivery) + .where(due) + .order_by(AlertNotificationDelivery.created_at, AlertNotificationDelivery.id) + .limit(settings.alert_notification_batch_size) + .with_for_update(skip_locked=True) + ) + ) + claims: list[tuple[Any, str, str, dict[str, Any], int]] = [] + for row in rows: + token = secrets.token_urlsafe(32) + row.status = NotificationStatus.DISPATCHING.value + row.attempt_count += 1 + row.last_attempt_at = now + row.lease_expires_at = now + timedelta(seconds=settings.alert_notification_lease_seconds) + row.claim_token_hash = hashlib.sha256(token.encode()).hexdigest() + claims.append((row.id, row.idempotency_key, token, dict(row.payload), row.attempt_count)) + session.commit() + return claims + + +def _retry_delay(settings: Settings, attempt: int) -> int: + multiplier = 1 << min(max(attempt - 1, 0), 10) + return min(settings.alert_notification_retry_base_seconds * multiplier, 3600) + + +def _safe_result( + status_code: int | None, + error_code: str | None, + *, + settings: Settings, + attempt: int, +) -> tuple[str, datetime | None, int | None, str | None]: + if status_code is not None and 200 <= status_code < 300: + return NotificationStatus.DELIVERED.value, None, status_code, None + retryable = ( + (status_code is None and error_code != "DESTINATION_REJECTED") + or status_code in {408, 425, 429} + or (status_code is not None and status_code >= 500) + ) + safe_code = error_code or ( + "REMOTE_RETRYABLE" if retryable else "REMOTE_REJECTED" + ) + if status_code is not None and 300 <= status_code < 400: + retryable = False + safe_code = "REDIRECT_REJECTED" + if retryable and attempt < settings.alert_notification_max_attempts: + due = _now() + timedelta(seconds=_retry_delay(settings, attempt)) + return NotificationStatus.RETRY_WAIT.value, due, status_code, safe_code + return NotificationStatus.FAILED.value, None, status_code, safe_code + + +def _post_once( + settings: Settings, + payload: dict[str, Any], + idempotency_key: str, + transport: httpx.BaseTransport | None, +) -> tuple[int | None, str | None]: + webhook = settings.alert_webhook_url + if not _route_ready(settings) or webhook is None: + return None, "DESTINATION_REJECTED" + url = webhook.get_secret_value() + try: + with httpx.Client( + timeout=httpx.Timeout(settings.alert_notification_timeout_seconds), + follow_redirects=False, + trust_env=False, + transport=transport, + ) as client, client.stream( + "POST", + url, + json=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Idempotency-Key": idempotency_key, + }, + ) as response: + return response.status_code, None + except httpx.TimeoutException: + return None, "DELIVERY_TIMEOUT" + except httpx.HTTPError: + return None, "DELIVERY_TRANSPORT_ERROR" + except (ValueError, OSError, RuntimeError): + return None, "DELIVERY_TRANSPORT_ERROR" + + +def dispatch_pending( + session: Session, + settings: Settings, + *, + now: datetime | None = None, + transport: httpx.BaseTransport | None = None, +) -> int: + """Claim and deliver one bounded batch; network calls happen outside the claim transaction.""" + if not settings.alert_notifications_enabled: + return 0 + claims = _claim(session, settings, now or _now()) + for row_id, idempotency_key, token, payload, attempt in claims: + event_id = str(payload.get("event_id", ""))[:64] + status_code, error_code = _post_once( + settings, payload, idempotency_key, transport + ) + status, next_attempt, status_code, safe_code = _safe_result( + status_code, error_code, settings=settings, attempt=attempt + ) + token_hash = hashlib.sha256(token.encode()).hexdigest() + values: dict[str, Any] = { + "status": status, + "next_attempt_at": next_attempt, + "lease_expires_at": None, + "claim_token_hash": None, + "last_http_status": status_code, + "last_error_code": safe_code, + } + if status == NotificationStatus.DELIVERED.value: + values["delivered_at"] = _now() + session.execute( + update(AlertNotificationDelivery) + .where( + AlertNotificationDelivery.id == row_id, + AlertNotificationDelivery.status == NotificationStatus.DISPATCHING.value, + AlertNotificationDelivery.claim_token_hash == token_hash, + ) + .values(**values) + ) + session.commit() + logger.info( + "alert_notification_delivery_attempted", + delivery_id=str(row_id), + event_id=event_id, + status=status, + error_code=safe_code, + http_status=status_code, + attempt=attempt, + ) + return len(claims) diff --git a/backend/src/modelforge_api/services/audit.py b/backend/src/modelforge_api/services/audit.py index 3f66296..343fe6f 100644 --- a/backend/src/modelforge_api/services/audit.py +++ b/backend/src/modelforge_api/services/audit.py @@ -126,7 +126,8 @@ def _decode_canonical_audit_payload(value: str) -> dict[str, Any]: decoded = json.loads(value, object_pairs_hook=reject_duplicate_keys) if not isinstance(decoded, dict): - raise ValueError("canonical audit payload is not an object") + # The string is valid JSON but an invalid persisted audit value, not a caller type error. + raise ValueError("canonical audit payload is not an object") # noqa: TRY004 return decoded diff --git a/backend/src/modelforge_api/services/audit_timeline.py b/backend/src/modelforge_api/services/audit_timeline.py new file mode 100644 index 0000000..2e116d8 --- /dev/null +++ b/backend/src/modelforge_api/services/audit_timeline.py @@ -0,0 +1,209 @@ +"""Read-only, bounded queries over append-only audit events.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import re +import uuid +from dataclasses import dataclass +from datetime import UTC + +from sqlalchemy import and_, or_, select +from sqlalchemy.orm import Session + +from modelforge_api.domain.audit_timeline import ( + AuditTimelineEvent, + AuditTimelineRequest, + AuditTimelineResponse, +) +from modelforge_api.persistence.models import AuditEvent + +_CURSOR_VERSION = 1 +_CURSOR_MAX_LENGTH = 256 + + +class AuditTimelineCursorError(ValueError): + """A cursor is malformed or was created for a different filter set.""" + + +@dataclass(frozen=True, slots=True) +class _AuditCursor: + sequence: int + event_id: uuid.UUID + + +def _filters_digest(request: AuditTimelineRequest) -> str: + values: dict[str, object] = { + "principal": request.principal, + "action": request.action, + "resource": request.resource, + "outcome": request.outcome, + "occurred_after": ( + request.occurred_after.astimezone(UTC).isoformat() + if request.occurred_after is not None + else None + ), + "occurred_before": ( + request.occurred_before.astimezone(UTC).isoformat() + if request.occurred_before is not None + else None + ), + "correlation_id": request.correlation_id, + } + encoded = json.dumps(values, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _encode_cursor(sequence: int, event_id: uuid.UUID, filters_digest: str) -> str: + payload = json.dumps( + { + "v": _CURSOR_VERSION, + "sequence": sequence, + "event_id": str(event_id), + "filters": filters_digest, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + + +def _decode_cursor(value: str, filters_digest: str) -> _AuditCursor: + if len(value) > _CURSOR_MAX_LENGTH or not re.fullmatch(r"[A-Za-z0-9_-]+", value): + raise AuditTimelineCursorError("invalid audit timeline cursor") + padded = value + "=" * (-len(value) % 4) + try: + decoded = base64.b64decode(padded, altchars=b"-_", validate=True) + payload = json.loads(decoded) + except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, ValueError): + raise AuditTimelineCursorError("invalid audit timeline cursor") from None + if not isinstance(payload, dict) or set(payload) != {"v", "sequence", "event_id", "filters"}: + raise AuditTimelineCursorError("invalid audit timeline cursor") + sequence = payload["sequence"] + event_id_value = payload["event_id"] + cursor_version = payload["v"] + cursor_filters = payload["filters"] + if ( + type(cursor_version) is not int + or cursor_version != _CURSOR_VERSION + or type(sequence) is not int + or sequence < 1 + or not isinstance(event_id_value, str) + or not isinstance(cursor_filters, str) + or not re.fullmatch(r"[0-9a-f]{64}", cursor_filters) + ): + raise AuditTimelineCursorError("invalid audit timeline cursor") + try: + event_id = uuid.UUID(event_id_value) + except (AttributeError, TypeError, ValueError): + raise AuditTimelineCursorError("invalid audit timeline cursor") from None + if str(event_id) != event_id_value or cursor_filters != filters_digest: + raise AuditTimelineCursorError("audit timeline cursor does not match the supplied filters") + return _AuditCursor(sequence=sequence, event_id=event_id) + + +class AuditTimelineService: + """Read a bounded page without returning arbitrary event details or changing the chain.""" + + def __init__(self, session: Session) -> None: + self.session = session + + def list_events(self, request: AuditTimelineRequest) -> AuditTimelineResponse: + filters_digest = _filters_digest(request) + cursor = _decode_cursor(request.cursor, filters_digest) if request.cursor else None + predicates = [] + if request.principal: + predicates.append( + or_( + AuditEvent.actor_id.icontains(request.principal, autoescape=True), + AuditEvent.actor_type.icontains(request.principal, autoescape=True), + ) + ) + if request.action: + predicates.append(AuditEvent.action.icontains(request.action, autoescape=True)) + if request.resource: + predicates.append( + or_( + AuditEvent.resource_type.icontains(request.resource, autoescape=True), + AuditEvent.resource_id.icontains(request.resource, autoescape=True), + ) + ) + if request.outcome: + predicates.append(AuditEvent.outcome.icontains(request.outcome, autoescape=True)) + if request.occurred_after is not None: + predicates.append(AuditEvent.occurred_at >= request.occurred_after) + if request.occurred_before is not None: + predicates.append(AuditEvent.occurred_at <= request.occurred_before) + if request.correlation_id: + predicates.append( + AuditEvent.correlation_id.icontains(request.correlation_id, autoescape=True) + ) + if cursor is not None: + predicates.append( + or_( + AuditEvent.sequence < cursor.sequence, + and_( + AuditEvent.sequence == cursor.sequence, + AuditEvent.id < cursor.event_id, + ), + ) + ) + + statement = ( + select( + AuditEvent.id, + AuditEvent.sequence, + AuditEvent.occurred_at, + AuditEvent.correlation_id, + AuditEvent.actor_type, + AuditEvent.actor_id, + AuditEvent.action, + AuditEvent.resource_type, + AuditEvent.resource_id, + AuditEvent.outcome, + ) + .where(*predicates) + .order_by(AuditEvent.sequence.desc(), AuditEvent.id.desc()) + .limit(request.limit + 1) + ) + rows = self.session.execute(statement).all() + has_more = len(rows) > request.limit + page = rows[: request.limit] + events = [ + AuditTimelineEvent( + id=event_id, + sequence=sequence, + occurred_at=( + occurred_at.replace(tzinfo=UTC) + if occurred_at.tzinfo is None + else occurred_at.astimezone(UTC) + ), + correlation_id=correlation_id, + actor_type=actor_type, + actor_id=actor_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + outcome=outcome, + ) + for ( + event_id, + sequence, + occurred_at, + correlation_id, + actor_type, + actor_id, + action, + resource_type, + resource_id, + outcome, + ) in page + ] + next_cursor = None + if has_more and page: + last_event_id = uuid.UUID(str(page[-1][0])) + next_cursor = _encode_cursor(page[-1][1], last_event_id, filters_digest) + return AuditTimelineResponse(events=events, next_cursor=next_cursor, limit=request.limit) diff --git a/backend/src/modelforge_api/services/benchmark_runs.py b/backend/src/modelforge_api/services/benchmark_runs.py new file mode 100644 index 0000000..3f34ee5 --- /dev/null +++ b/backend/src/modelforge_api/services/benchmark_runs.py @@ -0,0 +1,1222 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import math +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, cast +from uuid import NAMESPACE_URL, UUID, uuid5 + +from sqlalchemy import and_, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.exc import StaleDataError + +from modelforge_api.domain.benchmarking import ( + BenchmarkRunHistoryPage, + BenchmarkRunProgress, + BenchmarkRunResponse, + BenchmarkRunStartRequest, + BenchmarkRunStatus, + BenchmarkSuiteResponse, +) +from modelforge_api.domain.contracts import BenchmarkEnvironmentFingerprint, BenchmarkRunContract +from modelforge_api.domain.lab import ( + LabEmbeddingInput, + LabExecutionStartRequest, +) +from modelforge_api.domain.serving import EmbeddingInvokeRequest +from modelforge_api.persistence.models import ( + Accelerator, + ArtifactSet, + BenchmarkRun, + BenchmarkSuite, + Capability, + CapabilityContract, + GatewayRequest, + LabExecutionRun, + ModelRevision, + RuntimeEnvironment, + RuntimeProfile, +) +from modelforge_api.services.lab_runs import LabRunService +from modelforge_api.services.serving import ServingError, ServingService + +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +_SUITE_KEY = "modelforge.rag-embedding.operator-performance" +_SUITE_REVISION = "1" +_DATASET_REVISION = "builtin:rag-embedding-performance-v1" +_SUITE_ID = uuid5(NAMESPACE_URL, f"{_SUITE_KEY}@{_SUITE_REVISION}") +_SUITE_IMMUTABLE_AT = datetime(2026, 9, 24, tzinfo=UTC) +_BENCHMARK_INPUTS = ( + "ModelForge embedding performance sample one.", + "ModelForge embedding performance sample two.", + "ModelForge embedding performance sample three.", + "ModelForge embedding performance sample four.", +) +_HISTORY_CURSOR_VERSION = 1 +_HISTORY_CURSOR_MAX_LENGTH = 512 + + +@dataclass(frozen=True, slots=True) +class _BenchmarkHistoryCursor: + created_at: datetime + run_id: UUID + + +def _encode_history_cursor(run: BenchmarkRun, actor_subject: str) -> str: + payload = json.dumps( + { + "v": _HISTORY_CURSOR_VERSION, + "created_at": _aware(run.created_at).astimezone(UTC).isoformat(), + "run_id": str(run.id), + "actor": hashlib.sha256(actor_subject.encode("utf-8")).hexdigest(), + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + + +def _decode_history_cursor(value: str, actor_subject: str) -> _BenchmarkHistoryCursor: + def invalid() -> ServingError: + return ServingError(422, "BENCHMARK_CURSOR_INVALID", "benchmark history cursor is invalid") + + if len(value) > _HISTORY_CURSOR_MAX_LENGTH or not re.fullmatch(r"[A-Za-z0-9_-]+", value): + raise invalid() + padded = value + "=" * (-len(value) % 4) + try: + decoded = base64.b64decode(padded, altchars=b"-_", validate=True) + payload = json.loads(decoded) + except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, ValueError): + raise invalid() from None + if not isinstance(payload, dict) or set(payload) != {"v", "created_at", "run_id", "actor"}: + raise invalid() + cursor_version = payload["v"] + created_at_value = payload["created_at"] + run_id_value = payload["run_id"] + actor_digest = payload["actor"] + if ( + type(cursor_version) is not int + or cursor_version != _HISTORY_CURSOR_VERSION + or not isinstance(created_at_value, str) + or not isinstance(run_id_value, str) + or not isinstance(actor_digest, str) + or not re.fullmatch(r"[0-9a-f]{64}", actor_digest) + or actor_digest != hashlib.sha256(actor_subject.encode("utf-8")).hexdigest() + ): + raise invalid() + try: + created_at = datetime.fromisoformat(created_at_value) + run_id = UUID(run_id_value) + except (TypeError, ValueError): + raise invalid() from None + if ( + created_at.tzinfo is None + or created_at.utcoffset() is None + or str(run_id) != run_id_value + ): + raise invalid() + return _BenchmarkHistoryCursor(created_at=created_at.astimezone(UTC), run_id=run_id) +_BENCHMARK_CASES = tuple(f"request-{index + 1}" for index in range(len(_BENCHMARK_INPUTS))) +_TERMINAL_RUNS = frozenset({"completed", "failed", "cancelled", "interrupted"}) +_TERMINAL_BENCHMARKS = frozenset({"completed", "failed", "cancelled", "interrupted"}) + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _canonical_json(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False + ).encode("utf-8") + + +def _sha256(value: Any) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def _finite_nonnegative(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, int | float): + return None + number = float(value) + return number if math.isfinite(number) and number >= 0 else None + + +def _percentile(values: list[float], fraction: float) -> float | None: + if not values: + return None + ordered = sorted(values) + index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1)) + return ordered[index] + + +def _suite_definition() -> dict[str, Any]: + return { + "schema_version": 1, + "capability": "rag.embedding@1", + "execution": {"concurrency": 1, "max_invocations": len(_BENCHMARK_INPUTS)}, + "cases": [ + {"case_id": case_id, "input_sha256": _sha256([value])} + for case_id, value in zip(_BENCHMARK_CASES, _BENCHMARK_INPUTS, strict=True) + ], + "reported_metrics": [ + "initial_request_latency_ms", + "cold_load_latency_ms", + "warm_latency_ms", + "throughput", + "failure_rate", + "gpu_vram_telemetry", + ], + "quality_metrics": {"status": "unknown", "reason": "PROJECT_CORPUS_NOT_INCLUDED"}, + } + + +class BenchmarkRunService: + """Drive small operator benchmarks through the durable LAB and private-worker path.""" + + def __init__(self, serving: ServingService) -> None: + self.serving = serving + self.session = serving.session + + def suites(self) -> list[BenchmarkSuiteResponse]: + definition = _suite_definition() + contract = self.session.scalar( + select(CapabilityContract) + .join(Capability, Capability.id == CapabilityContract.capability_id) + .where(Capability.key == "rag.embedding", CapabilityContract.version == 1) + ) + if contract is None: + raise ServingError( + 503, + "BENCHMARK_CAPABILITY_UNAVAILABLE", + "the declared rag.embedding@1 contract is unavailable", + ) + suite = self.session.scalar( + select(BenchmarkSuite).where( + BenchmarkSuite.key == _SUITE_KEY, + BenchmarkSuite.revision == _SUITE_REVISION, + ) + ) + if suite is not None and ( + suite.id != _SUITE_ID + or suite.immutable_at is None + or suite.dataset_revision != _DATASET_REVISION + or suite.definition != definition + or suite.capability_contract_id != contract.id + ): + raise ServingError( + 409, "BENCHMARK_SUITE_INVALID", "the immutable benchmark suite is inconsistent" + ) + if suite is not None: + return [self._suite_response(suite)] + return [ + BenchmarkSuiteResponse( + id=_SUITE_ID, + key=_SUITE_KEY, + revision=_SUITE_REVISION, + capability="rag.embedding@1", + dataset_revision=_DATASET_REVISION, + definition=definition, + immutable_at=_SUITE_IMMUTABLE_AT, + ) + ] + + @staticmethod + def _suite_response(suite: BenchmarkSuite) -> BenchmarkSuiteResponse: + return BenchmarkSuiteResponse( + id=suite.id, + key=suite.key, + revision=suite.revision, + capability="rag.embedding@1", + dataset_revision=suite.dataset_revision, + definition=suite.definition, + immutable_at=suite.immutable_at or _now(), + ) + + def _suite(self, suite_id: UUID) -> BenchmarkSuite: + if suite_id != _SUITE_ID: + raise ServingError(404, "BENCHMARK_SUITE_NOT_FOUND", "benchmark suite was not found") + suite = self.session.scalar( + select(BenchmarkSuite).where( + BenchmarkSuite.id == _SUITE_ID, + BenchmarkSuite.id == suite_id, + BenchmarkSuite.key == _SUITE_KEY, + BenchmarkSuite.revision == _SUITE_REVISION, + ) + ) + if suite is None: + contract = self.session.scalar( + select(CapabilityContract) + .join(Capability, Capability.id == CapabilityContract.capability_id) + .where(Capability.key == "rag.embedding", CapabilityContract.version == 1) + ) + if contract is None: + raise ServingError( + 503, + "BENCHMARK_CAPABILITY_UNAVAILABLE", + "the declared rag.embedding@1 contract is unavailable", + ) + suite = BenchmarkSuite( + id=_SUITE_ID, + key=_SUITE_KEY, + revision=_SUITE_REVISION, + capability_contract_id=contract.id, + dataset_revision=_DATASET_REVISION, + definition=_suite_definition(), + immutable_at=_SUITE_IMMUTABLE_AT, + ) + self.session.add(suite) + try: + self.session.flush() + self.session.commit() + except IntegrityError: + self.session.rollback() + suite = self.session.get(BenchmarkSuite, _SUITE_ID) + if ( + suite is None + or suite.immutable_at is None + or suite.dataset_revision != _DATASET_REVISION + or suite.definition != _suite_definition() + ): + raise ServingError( + 409, "BENCHMARK_SUITE_INVALID", "the immutable benchmark suite is inconsistent" + ) + contract = self.session.get(CapabilityContract, suite.capability_contract_id) + capability = self.session.get(Capability, contract.capability_id) if contract else None + if ( + contract is None + or contract.version != 1 + or capability is None + or capability.key != "rag.embedding" + ): + raise ServingError(409, "BENCHMARK_SUITE_INVALID", "suite capability identity changed") + return suite + + def _provenance( + self, request: BenchmarkRunStartRequest + ) -> tuple[dict[str, Any], str, str, BenchmarkEnvironmentFingerprint, bool]: + sample = LabEmbeddingInput( + capability="rag.embedding", + payload=EmbeddingInvokeRequest(input=_BENCHMARK_INPUTS[0]), + ) + deployment, route_key = self.serving._admit_operator_embedding( + sample, request.deployment_id, request.experiment_route_id + ) + snapshot = self.serving._operator_lab_target_snapshot( + deployment, request.experiment_route_id + ) + expected = request.target.model_dump(mode="json") + actual = { + "deployment_candidate_id": snapshot["candidate_id"], + "artifact_set_id": snapshot["artifact_set_id"], + "runtime_profile_id": snapshot["runtime_profile_id"], + "compute_node_id": snapshot["compute_node_id"], + "execution_approval_id": snapshot["execution_approval_id"], + } + if expected != actual: + raise ServingError( + 409, "BENCHMARK_TARGET_MISMATCH", "selected benchmark identities do not match" + ) + if route_key == "": + raise ServingError( + 409, "BENCHMARK_TARGET_MISMATCH", "selected experiment route is invalid" + ) + + profile = self.session.get(RuntimeProfile, deployment.runtime_profile_id) + artifact_set = self.session.get(ArtifactSet, deployment.artifact_set_id) + accelerator = self.session.get(Accelerator, deployment.accelerator_id) + node = self.serving.repo.node(deployment.compute_node_id) + environment = ( + self.session.get(RuntimeEnvironment, profile.runtime_environment_id) + if profile and profile.runtime_environment_id + else None + ) + revision = ( + self.session.get(ModelRevision, artifact_set.revision_id) if artifact_set else None + ) + if ( + profile is None + or artifact_set is None + or accelerator is None + or node is None + or environment is None + or revision is None + or len(profile.artifact_sha256) != 64 + ): + raise ServingError( + 409, + "BENCHMARK_PROVENANCE_INCOMPLETE", + "exact benchmark model, artifact, runtime or hardware provenance is unavailable", + ) + launch_arguments = profile.launch_arguments or {} + if self._contains_secret_argument(launch_arguments): + raise ServingError( + 409, + "BENCHMARK_RUNTIME_ARGUMENTS_UNSAFE", + "runtime arguments cannot be recorded safely for benchmark provenance", + ) + context_length = next( + ( + value + for value in ( + profile.context_policy.get("context_length"), + profile.context_policy.get("max_sequence_length"), + profile.context_policy.get("max_model_len"), + ) + if isinstance(value, int) and not isinstance(value, bool) and value > 0 + ), + None, + ) + os_name = node.os_name + operating_system = ( + f"{os_name} {node.os_version}" if os_name and node.os_version else os_name + ) + fingerprint = BenchmarkEnvironmentFingerprint( + runtime_type=profile.runtime_type, + runtime_version=profile.runtime_version, + runtime_image_digest=profile.runtime_image_digest or environment.image_digest, + launch_arguments=launch_arguments, + cuda_version=environment.cuda_runtime_version, + driver_version=accelerator.driver_version, + accelerator_name=accelerator.name, + accelerator_uuid=accelerator.device_uuid, + context_length=context_length, + concurrency=profile.concurrency, + seed=None, + operating_system=operating_system, + python_version=environment.python_version, + ) + complete = all( + value is not None and value != "" + for value in ( + fingerprint.runtime_image_digest, + fingerprint.cuda_version, + fingerprint.driver_version, + fingerprint.accelerator_name, + fingerprint.accelerator_uuid, + fingerprint.operating_system, + fingerprint.python_version, + ) + ) + return ( + snapshot, + revision.resolved_commit_sha, + profile.artifact_sha256, + fingerprint, + complete, + ) + + @staticmethod + def _contains_secret_argument(value: Any) -> bool: + sensitive = {"credential", "password", "secret", "token", "private_key", "api_key"} + if isinstance(value, dict): + for key, item in value.items(): + normalized = re.sub(r"[^a-z0-9]+", "_", str(key).lower()).strip("_") + if any(word in normalized.split("_") for word in sensitive): + return True + if BenchmarkRunService._contains_secret_argument(item): + return True + elif isinstance(value, list): + return any(BenchmarkRunService._contains_secret_argument(item) for item in value) + elif isinstance(value, str): + normalized = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") + return any(word in normalized.split("_") for word in sensitive) + return False + + def start( + self, + request: BenchmarkRunStartRequest, + *, + actor_subject: str, + idempotency_key: str, + ) -> BenchmarkRunResponse: + if not actor_subject or len(actor_subject) > 255: + raise ServingError( + 401, "OPERATOR_CONTEXT_REQUIRED", "operator principal is unavailable" + ) + if not _IDEMPOTENCY_KEY.fullmatch(idempotency_key): + raise ServingError( + 422, "BENCHMARK_IDEMPOTENCY_KEY_INVALID", "idempotency key is invalid" + ) + fingerprint_hash = _sha256(request.model_dump(mode="json")) + existing = self.session.scalar( + select(BenchmarkRun).where( + BenchmarkRun.actor_subject == actor_subject, + BenchmarkRun.idempotency_key == idempotency_key, + ) + ) + if existing is not None: + if existing.request_fingerprint != fingerprint_hash: + raise ServingError( + 409, + "BENCHMARK_IDEMPOTENCY_CONFLICT", + "idempotency key was already used for a different benchmark request", + ) + return self._response(existing) + + suite = self._suite(request.suite_id) + snapshot, revision_sha, artifact_sha, environment, comparable = self._provenance(request) + now = _now() + run = BenchmarkRun( + deployment_id=None, + capability_deployment_id=request.deployment_id, + actor_subject=actor_subject, + idempotency_key=idempotency_key, + request_fingerprint=fingerprint_hash, + suite_id=suite.id, + target_snapshot=snapshot, + model_revision_sha=revision_sha, + artifact_sha256=artifact_sha, + runtime_type=environment.runtime_type, + runtime_version=environment.runtime_version, + runtime_image_digest=environment.runtime_image_digest, + launch_arguments=environment.launch_arguments, + suite_revision=suite.revision, + dataset_revision=suite.dataset_revision, + environment_fingerprint=environment.model_dump(mode="json"), + environment_digest=environment.digest, + seed=environment.seed, + results=self._initial_results(comparable), + status="accepted", + comparable=comparable, + stale_reason=None if comparable else "ENVIRONMENT_PROVENANCE_INCOMPLETE", + started_at=now, + created_at=now, + ) + self.session.add(run) + try: + self.session.commit() + except IntegrityError: + self.session.rollback() + winner = self.session.scalar( + select(BenchmarkRun).where( + BenchmarkRun.actor_subject == actor_subject, + BenchmarkRun.idempotency_key == idempotency_key, + ) + ) + if winner is not None and winner.request_fingerprint == fingerprint_hash: + return self._response(winner) + if winner is not None: + raise ServingError( + 409, + "BENCHMARK_IDEMPOTENCY_CONFLICT", + "idempotency key was already used for a different benchmark request", + ) from None + raise ServingError( + 409, "STATE_CONFLICT", "benchmark run could not be persisted" + ) from None + self._advance(run.id) + return self.get(run.id, actor_subject=actor_subject) + + @staticmethod + def _initial_results(comparable: bool) -> dict[str, Any]: + return { + "schema_version": 1, + "execution": { + "next_case_index": 0, + "active_case": None, + "active_lab_run_id": None, + "attempts": [], + }, + "metrics": { + "initial_request_latency_ms": None, + "cold_load_latency_ms": {"p50": None, "p95": None, "samples": 0}, + "warm_latency_ms": {"p50": None, "p95": None, "samples": 0}, + "total_latency_ms": {"p50": None, "p95": None, "samples": 0}, + "throughput": { + "successful_vectors_per_inference_second": None, + "inference_samples": 0, + "measured_vectors": 0, + "successful_vectors_coverage": None, + "successful_calls_per_observed_second": None, + "observed_elapsed_ms": None, + }, + "failures": { + "attempted": 0, + "failed": 0, + "rate": None, + }, + "gpu_vram": { + "telemetry_status": "UNKNOWN", + "vram_status": "UNKNOWN", + "gpu_utilization_status": "UNKNOWN", + "source": None, + "measured_samples": 0, + "peak_used_vram_bytes": None, + "peak_process_used_vram_bytes": None, + "minimum_free_vram_bytes": None, + "peak_gpu_utilization_percent": None, + "target_matched_samples": 0, + "target_accelerator_uuid": None, + }, + "quality": { + "status": "UNKNOWN", + "metrics": {"recall_at_k": None, "mrr": None, "ndcg_at_k": None}, + "reason": "PROJECT_CORPUS_NOT_INCLUDED_IN_PERFORMANCE_SUITE", + }, + "environment_comparable": comparable, + }, + } + + def _response(self, run: BenchmarkRun) -> BenchmarkRunResponse: + suite = self.session.get(BenchmarkSuite, run.suite_id) + if suite is None or run.capability_deployment_id is None: + raise ServingError( + 500, "BENCHMARK_PROVENANCE_MISSING", "benchmark provenance is incomplete" + ) + try: + environment = BenchmarkEnvironmentFingerprint.model_validate( + run.environment_fingerprint + ) + except ValueError as error: + raise ServingError( + 500, "BENCHMARK_PROVENANCE_INVALID", "benchmark environment is invalid" + ) from error + contract = BenchmarkRunContract( + deployment_id=str(run.capability_deployment_id), + model_revision_sha=run.model_revision_sha, + artifact_sha256=run.artifact_sha256, + suite_key=suite.key, + suite_revision=run.suite_revision, + dataset_revision=run.dataset_revision, + environment=environment, + started_at=_aware(run.started_at), + completed_at=_aware(run.completed_at) if run.completed_at else None, + ) + results = run.results if isinstance(run.results, dict) else {} + execution_value = results.get("execution") + execution = execution_value if isinstance(execution_value, dict) else {} + attempts = execution.get("attempts", []) + completed = len(attempts) if isinstance(attempts, list) else 0 + active_case = execution.get("active_case") + return BenchmarkRunResponse( + id=run.id, + capability="rag.embedding@1", + suite_id=suite.id, + capability_deployment_id=run.capability_deployment_id, + status=cast(BenchmarkRunStatus, run.status), + contract=contract, + target_snapshot=run.target_snapshot or {}, + progress=BenchmarkRunProgress( + completed_invocations=completed, + total_invocations=len(_BENCHMARK_CASES), + active_case=active_case if isinstance(active_case, str) else None, + ), + results=results, + failure_code=run.failure_code, + cancellation_requested_at=( + _aware(run.cancellation_requested_at) if run.cancellation_requested_at else None + ), + created_at=_aware(run.created_at), + completed_at=_aware(run.completed_at) if run.completed_at else None, + ) + + def _owned(self, run_id: UUID, actor_subject: str) -> BenchmarkRun: + run = self.session.scalar( + select(BenchmarkRun).where( + BenchmarkRun.id == run_id, + BenchmarkRun.actor_subject == actor_subject, + ) + ) + if run is None: + raise ServingError(404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run was not found") + return run + + def get(self, run_id: UUID, *, actor_subject: str) -> BenchmarkRunResponse: + return self._response(self._owned(run_id, actor_subject)) + + def list_page( + self, + *, + actor_subject: str, + limit: int = 50, + cursor: str | None = None, + ) -> BenchmarkRunHistoryPage: + if not actor_subject: + raise ServingError( + 401, "OPERATOR_CONTEXT_REQUIRED", "operator principal is unavailable" + ) + if not 1 <= limit <= 100: + raise ServingError(422, "BENCHMARK_LIMIT_INVALID", "benchmark history limit is invalid") + decoded_cursor = ( + _decode_history_cursor(cursor, actor_subject) if cursor is not None else None + ) + predicates = [BenchmarkRun.actor_subject == actor_subject] + if decoded_cursor is not None: + predicates.append( + or_( + BenchmarkRun.created_at < decoded_cursor.created_at, + and_( + BenchmarkRun.created_at == decoded_cursor.created_at, + BenchmarkRun.id < decoded_cursor.run_id, + ), + ) + ) + rows = list( + self.session.scalars( + select(BenchmarkRun) + .where(*predicates) + .order_by(BenchmarkRun.created_at.desc(), BenchmarkRun.id.desc()) + .limit(limit + 1) + ) + ) + has_more = len(rows) > limit + page_rows = rows[:limit] + next_cursor = _encode_history_cursor(page_rows[-1], actor_subject) if has_more else None + return BenchmarkRunHistoryPage( + runs=[self._response(row) for row in page_rows], + next_cursor=next_cursor, + limit=limit, + ) + + def list(self, *, actor_subject: str, limit: int = 50) -> list[BenchmarkRunResponse]: + """Return the original list contract for callers that do not page history.""" + return self.list_page(actor_subject=actor_subject, limit=limit).runs + + def cancel(self, run_id: UUID, *, actor_subject: str) -> BenchmarkRunResponse: + self._owned(run_id, actor_subject) + run = self.session.scalar( + select(BenchmarkRun) + .where(BenchmarkRun.id == run_id, BenchmarkRun.actor_subject == actor_subject) + .with_for_update() + ) + if run is None: + raise ServingError(404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run was not found") + if run.status in _TERMINAL_BENCHMARKS: + self.session.commit() + return self._response(run) + run.cancellation_requested_at = run.cancellation_requested_at or _now() + run.status = "cancel_requested" + results = ( + run.results if isinstance(run.results, dict) else self._initial_results(run.comparable) + ) + execution = results.get("execution") + active_id = execution.get("active_lab_run_id") if isinstance(execution, dict) else None + if not active_id: + run.status = "cancelled" + run.completed_at = _now() + self.session.commit() + if active_id: + try: + LabRunService(self.serving).cancel( + UUID(str(active_id)), actor_subject=actor_subject + ) + except ServingError as error: + if error.code != "LAB_RUN_NOT_FOUND": + raise + self.reconcile_one(run_id) + return self.get(run_id, actor_subject=actor_subject) + + def _lab_request(self, parent: BenchmarkRun, index: int) -> LabExecutionStartRequest: + if parent.capability_deployment_id is None: + raise ServingError( + 409, "BENCHMARK_TARGET_MISSING", "benchmark target identity is missing" + ) + snapshot = parent.target_snapshot + if not isinstance(snapshot, dict): + raise ServingError( + 409, "BENCHMARK_TARGET_MISSING", "benchmark target snapshot is missing" + ) + text = _BENCHMARK_INPUTS[index] + return LabExecutionStartRequest.model_validate( + { + "target": { + "deployment_candidate_id": snapshot["candidate_id"], + "artifact_set_id": snapshot["artifact_set_id"], + "runtime_profile_id": snapshot["runtime_profile_id"], + "compute_node_id": snapshot["compute_node_id"], + "execution_approval_id": snapshot["execution_approval_id"], + }, + "input": {"capability": "rag.embedding", "payload": {"input": text}}, + "limits": { + "timeout_seconds": 60, + "queue_timeout_seconds": 10, + "retention_seconds": 60, + "max_output_bytes": 65536, + "concurrency": 1, + }, + "deployment_id": str(parent.capability_deployment_id), + "experiment_route_id": snapshot["experiment_route_id"], + } + ) + + @staticmethod + def _child_idempotency_key(run_id: UUID, index: int) -> str: + return f"benchmark-{run_id.hex}-{index:02d}" + + def _advance(self, run_id: UUID) -> bool: + run = self.session.scalar( + select(BenchmarkRun).where(BenchmarkRun.id == run_id).with_for_update(skip_locked=True) + ) + if run is None or run.status in _TERMINAL_BENCHMARKS: + self.session.rollback() + return False + if run.actor_subject is None: + run.status = "failed" + run.failure_code = "BENCHMARK_ACTOR_MISSING" + run.completed_at = _now() + self.session.commit() + return True + results = ( + run.results if isinstance(run.results, dict) else self._initial_results(run.comparable) + ) + execution = results.get("execution") + if not isinstance(execution, dict): + run.status = "failed" + run.failure_code = "BENCHMARK_EXECUTION_STATE_INVALID" + run.completed_at = _now() + self.session.commit() + return True + active_id = execution.get("active_lab_run_id") + child: LabExecutionRun | None = None + if active_id: + child = self.session.get(LabExecutionRun, UUID(str(active_id))) + if child is None: + run.status = "interrupted" + run.failure_code = "BENCHMARK_LINKED_LAB_RUN_MISSING" + run.completed_at = _now() + self.session.commit() + return True + if child.status not in _TERMINAL_RUNS: + if run.cancellation_requested_at is not None: + self.session.commit() + LabRunService(self.serving).cancel(child.id, actor_subject=run.actor_subject) + return True + run.status = "running" + self.session.commit() + return False + index = execution.get("next_case_index") + if not isinstance(index, int) or not 0 <= index < len(_BENCHMARK_CASES): + run.status = "interrupted" + run.failure_code = "BENCHMARK_CASE_INDEX_INVALID" + run.completed_at = _now() + self.session.commit() + return True + try: + attempt = self._attempt(child, index) + except ServingError as error: + run.status = "interrupted" + run.failure_code = error.code + run.completed_at = _now() + self.session.commit() + return True + attempts = execution.get("attempts") + if not isinstance(attempts, list): + attempts = [] + execution = { + **execution, + "next_case_index": index + 1, + "active_case": None, + "active_lab_run_id": None, + "attempts": [*attempts, attempt], + } + results = {**results, "execution": execution} + results = self._refresh_metrics(results, run.environment_fingerprint) + run.results = results + if child.status == "interrupted": + run.status = "interrupted" + run.failure_code = child.failure_code or "LAB_WORKER_INTERRUPTED" + run.completed_at = _now() + self.session.commit() + return True + if child.status == "cancelled" or run.cancellation_requested_at is not None: + run.status = "cancelled" + run.completed_at = _now() + self.session.commit() + return True + if index + 1 >= len(_BENCHMARK_CASES): + run.status = "completed" + run.completed_at = _now() + self.session.commit() + return True + run.status = "running" + self.session.commit() + return self._launch_next(run_id) + + if run.cancellation_requested_at is not None: + run.status = "cancelled" + run.completed_at = _now() + self.session.commit() + return True + next_index = execution.get("next_case_index", 0) + if not isinstance(next_index, int) or not 0 <= next_index < len(_BENCHMARK_CASES): + run.status = "interrupted" + run.failure_code = "BENCHMARK_CASE_INDEX_INVALID" + run.completed_at = _now() + self.session.commit() + return True + self.session.commit() + return self._launch_next(run_id, next_index) + + def _launch_next(self, run_id: UUID, index: int | None = None) -> bool: + run = self.session.get(BenchmarkRun, run_id) + if run is None or run.status in _TERMINAL_BENCHMARKS or run.actor_subject is None: + return False + actor_subject = run.actor_subject + results = ( + run.results if isinstance(run.results, dict) else self._initial_results(run.comparable) + ) + execution = results.get("execution") + if not isinstance(execution, dict) or execution.get("active_lab_run_id"): + return False + chosen = execution.get("next_case_index", 0) if index is None else index + if not isinstance(chosen, int) or not 0 <= chosen < len(_BENCHMARK_CASES): + return False + if run.cancellation_requested_at is not None: + run.status = "cancelled" + run.completed_at = _now() + self.session.commit() + return True + try: + child_response = LabRunService(self.serving).start( + self._lab_request(run, chosen), + actor_subject=actor_subject, + idempotency_key=self._child_idempotency_key(run.id, chosen), + ) + except ServingError as error: + current = self.session.scalar( + select(BenchmarkRun).where(BenchmarkRun.id == run_id).with_for_update() + ) + if current is None: + self.session.rollback() + return False + current.status = "failed" + current.failure_code = error.code + current.completed_at = _now() + self.session.commit() + return True + current = self.session.scalar( + select(BenchmarkRun).where(BenchmarkRun.id == run_id).with_for_update() + ) + if current is None: + self.session.rollback() + return False + current_results = ( + current.results + if isinstance(current.results, dict) + else self._initial_results(current.comparable) + ) + current_execution = current_results.get("execution") + if not isinstance(current_execution, dict): + current.status = "interrupted" + current.failure_code = "BENCHMARK_EXECUTION_STATE_INVALID" + current.completed_at = _now() + self.session.commit() + try: + LabRunService(self.serving).cancel(child_response.id, actor_subject=actor_subject) + except ServingError as error: + if error.code != "LAB_RUN_NOT_FOUND": + raise + return True + # The child idempotency key is deterministic, so two reconcilers racing here can only + # converge on the same LAB run even on databases without row-level lock support. + cancel_child = False + if not current_execution.get("active_lab_run_id"): + same_case = current_execution.get("next_case_index", 0) == chosen + if same_case: + current_execution = { + **current_execution, + "active_case": ( + None + if current.status in _TERMINAL_BENCHMARKS + else _BENCHMARK_CASES[chosen] + ), + "active_lab_run_id": str(child_response.id), + "next_case_index": chosen, + } + current.results = {**current_results, "execution": current_execution} + if not same_case: + cancel_child = True + elif current.status in _TERMINAL_BENCHMARKS: + # Cancellation can commit while LAB admission is in progress. Preserve that + # terminal truth and stop the just-created child instead of resurrecting it. + cancel_child = True + elif ( + current.cancellation_requested_at is not None + or current.status == "cancel_requested" + ): + current.cancellation_requested_at = current.cancellation_requested_at or _now() + current.status = "cancel_requested" + cancel_child = True + else: + current.status = "running" + elif current_execution.get("active_lab_run_id") != str(child_response.id): + # Progress moved while this reconciler was waiting on LAB admission. The + # deterministic child key prevents duplicate rows; cancel any obsolete child. + cancel_child = child_response.status not in _TERMINAL_RUNS + self.session.commit() + if cancel_child: + try: + LabRunService(self.serving).cancel(child_response.id, actor_subject=actor_subject) + except ServingError as error: + if error.code != "LAB_RUN_NOT_FOUND": + raise + return True + + def _attempt(self, child: LabExecutionRun, index: int) -> dict[str, Any]: + gateway = self.session.get(GatewayRequest, child.gateway_request_id) + if gateway is None: + raise ServingError( + 409, + "BENCHMARK_GATEWAY_EVIDENCE_MISSING", + "linked Gateway request evidence is missing", + ) + worker = child.worker_observations if isinstance(child.worker_observations, dict) else {} + return { + "case_id": _BENCHMARK_CASES[index], + "lab_run_id": str(child.id), + "status": child.status, + "failure_code": child.failure_code or gateway.failure_code, + "gateway": { + "cold": gateway.cold, + "queue_time_ms": _finite_nonnegative(gateway.queue_time_ms), + "load_time_ms": _finite_nonnegative(gateway.load_time_ms), + "inference_time_ms": _finite_nonnegative(gateway.inference_time_ms), + "total_latency_ms": _finite_nonnegative(gateway.total_latency_ms), + "created_at": _aware(gateway.created_at).isoformat(), + "finished_at": _aware(gateway.finished_at).isoformat() + if gateway.finished_at + else None, + "input_count": gateway.input_count, + }, + "worker_observations": worker, + } + + @staticmethod + def _refresh_metrics(results: dict[str, Any], environment: dict[str, Any]) -> dict[str, Any]: + execution = results.get("execution") + attempts = execution.get("attempts", []) if isinstance(execution, dict) else [] + if not isinstance(attempts, list): + attempts = [] + gateways = [item.get("gateway", {}) for item in attempts if isinstance(item, dict)] + total_latencies = [ + value + for gateway in gateways + if isinstance(gateway, dict) + if (value := _finite_nonnegative(gateway.get("total_latency_ms"))) is not None + ] + warm_latencies = [ + value + for gateway in gateways + if isinstance(gateway, dict) and gateway.get("cold") is False + if (value := _finite_nonnegative(gateway.get("total_latency_ms"))) is not None + ] + cold_load = [ + value + for gateway in gateways + if isinstance(gateway, dict) and gateway.get("cold") is True + if (value := _finite_nonnegative(gateway.get("load_time_ms"))) is not None + ] + failures = sum( + 1 for item in attempts if isinstance(item, dict) and item.get("status") != "completed" + ) + successful = [ + item + for item in attempts + if isinstance(item, dict) and item.get("status") == "completed" + ] + measured_inference: list[tuple[int, float]] = [] + successful_vectors = 0 + for item in successful: + gateway = item.get("gateway") + if not isinstance(gateway, dict): + continue + input_count = gateway.get("input_count") + if ( + isinstance(input_count, int) + and not isinstance(input_count, bool) + and input_count > 0 + ): + successful_vectors += input_count + inference_ms_value = _finite_nonnegative(gateway.get("inference_time_ms")) + if ( + inference_ms_value is not None + and inference_ms_value > 0 + and isinstance(input_count, int) + and not isinstance(input_count, bool) + and input_count > 0 + ): + measured_inference.append((input_count, inference_ms_value)) + inference_ms = sum(value for _, value in measured_inference) + vectors = sum(count for count, _ in measured_inference) + times: list[datetime] = [] + for gateway in gateways: + if not isinstance(gateway, dict): + continue + for key in ("created_at", "finished_at"): + value = gateway.get(key) + if isinstance(value, str): + try: + times.append(datetime.fromisoformat(value)) + except ValueError: + continue + elapsed_ms = ( + max(0.0, (_aware(max(times)) - _aware(min(times))).total_seconds() * 1000) + if len(times) >= 2 + else None + ) + samples: list[dict[str, float]] = [] + for item in attempts: + if not isinstance(item, dict): + continue + observation = item.get("worker_observations") + if not isinstance(observation, dict): + continue + worker_metrics = observation.get("metrics") + if not isinstance(worker_metrics, dict): + continue + if observation.get("device_matches_target") is True: + safe_metrics = { + key: value + for key in ( + "used_vram_bytes", + "process_used_vram_bytes", + "free_vram_bytes", + "gpu_utilization_percent", + ) + if (value := _finite_nonnegative(worker_metrics.get(key))) is not None + and (key != "gpu_utilization_percent" or value <= 100) + } + if safe_metrics: + samples.append(safe_metrics) + observed_values = { + key: [ + value + for item in samples + if (value := _finite_nonnegative(item.get(key))) is not None + ] + for key in ( + "used_vram_bytes", + "process_used_vram_bytes", + "free_vram_bytes", + "gpu_utilization_percent", + ) + } + initial_gateway = gateways[0] if gateways and isinstance(gateways[0], dict) else {} + metrics = { + "initial_request_latency_ms": _finite_nonnegative( + initial_gateway.get("total_latency_ms") + ), + "cold_load_latency_ms": { + "p50": _percentile(cold_load, 0.50), + "p95": _percentile(cold_load, 0.95), + "samples": len(cold_load), + }, + "warm_latency_ms": { + "p50": _percentile(warm_latencies, 0.50), + "p95": _percentile(warm_latencies, 0.95), + "samples": len(warm_latencies), + }, + "total_latency_ms": { + "p50": _percentile(total_latencies, 0.50), + "p95": _percentile(total_latencies, 0.95), + "samples": len(total_latencies), + }, + "throughput": { + "successful_vectors_per_inference_second": ( + vectors / (inference_ms / 1000) if inference_ms > 0 else None + ), + "inference_samples": len(measured_inference), + "measured_vectors": vectors, + "successful_vectors_coverage": ( + vectors / successful_vectors if successful_vectors > 0 else None + ), + "successful_calls_per_observed_second": ( + len(successful) / (elapsed_ms / 1000) + if elapsed_ms is not None and elapsed_ms > 0 + else None + ), + "observed_elapsed_ms": elapsed_ms, + }, + "failures": { + "attempted": len(attempts), + "failed": failures, + "rate": failures / len(attempts) if attempts else None, + }, + "gpu_vram": { + "telemetry_status": "MEASURED" if samples else "UNKNOWN", + "vram_status": ( + "MEASURED" + if any( + observed_values[key] + for key in ( + "used_vram_bytes", + "process_used_vram_bytes", + "free_vram_bytes", + ) + ) + else "UNKNOWN" + ), + "gpu_utilization_status": ( + "MEASURED" if observed_values["gpu_utilization_percent"] else "UNKNOWN" + ), + "source": "runtime-worker" if samples else None, + "measured_samples": len(samples), + "peak_used_vram_bytes": max(observed_values["used_vram_bytes"], default=None), + "peak_process_used_vram_bytes": max( + observed_values["process_used_vram_bytes"], default=None + ), + "minimum_free_vram_bytes": min(observed_values["free_vram_bytes"], default=None), + "peak_gpu_utilization_percent": max( + observed_values["gpu_utilization_percent"], default=None + ), + "target_matched_samples": len(samples), + "target_accelerator_uuid": environment.get("accelerator_uuid"), + }, + } + current_metrics = results.get("metrics") + merged = { + **(current_metrics if isinstance(current_metrics, dict) else {}), + **metrics, + "environment_comparable": bool( + current_metrics.get("environment_comparable") + if isinstance(current_metrics, dict) + else False + ), + "quality": { + "status": "UNKNOWN", + "metrics": {"recall_at_k": None, "mrr": None, "ndcg_at_k": None}, + "reason": "PROJECT_CORPUS_NOT_INCLUDED_IN_PERFORMANCE_SUITE", + }, + } + return {**results, "metrics": merged} + + def reconcile_one(self, run_id: UUID) -> bool: + try: + return self._advance(run_id) + except StaleDataError: + # The row generation is the portable fallback when a test/development SQLite + # database ignores SELECT FOR UPDATE. A concurrent reconciler will retry next tick. + self.session.rollback() + return False + except ServingError: + raise + + def reconcile(self) -> dict[str, int]: + driven = 0 + run_ids = list( + self.session.scalars( + select(BenchmarkRun.id) + .where(BenchmarkRun.status.in_(("accepted", "running", "cancel_requested"))) + .order_by(BenchmarkRun.created_at) + .limit(100) + ) + ) + for run_id in run_ids: + try: + driven += int(self.reconcile_one(run_id)) + except ServingError: + # Admission failures are persisted as typed terminal benchmark outcomes by + # _launch_next; unexpected errors remain visible to the normal reconciler. + self.session.rollback() + raise + return {"benchmark_runs_driven": driven} diff --git a/backend/src/modelforge_api/services/configuration.py b/backend/src/modelforge_api/services/configuration.py new file mode 100644 index 0000000..5c1ecf0 --- /dev/null +++ b/backend/src/modelforge_api/services/configuration.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +from pydantic import SecretStr + +from modelforge_api.domain.configuration import ( + ConfigurationApplicationMode, + ConfigurationCatalogResponse, + ConfigurationSettingResponse, + ConfigurationSource, +) +from modelforge_api.domain.configuration_policy import CONFIGURATION_CONSUMER, SERVING_OVERRIDE_KEYS +from modelforge_api.domain.configuration_reference import SETTING_DOCS, Sensitivity +from modelforge_api.settings import Settings + + +def _environment_variable_name(setting_key: str) -> str: + return f"MODELFORGE_{setting_key.upper()}" + + +def _is_configured(value: object) -> bool: + if value is None: + return False + if isinstance(value, SecretStr): + return bool(value.get_secret_value()) + if isinstance(value, str): + return bool(value) + return True + + +def _redact_url_userinfo(value: str) -> str: + """Remove URL userinfo even when a legacy setting is not classified as SECRET.""" + + try: + parsed = urlsplit(value) + except ValueError: + return value + if not parsed.scheme or "@" not in parsed.netloc: + return value + # Keep useful topology without leaking a username or password. rsplit handles userinfo that + # legitimately contains percent-encoded separators while preserving the complete host/port. + safe_netloc = parsed.netloc.rsplit("@", 1)[-1] + return urlunsplit((parsed.scheme, safe_netloc, parsed.path, parsed.query, parsed.fragment)) + + +def _safe_effective_value( + value: object, sensitivity: Sensitivity +) -> str | int | float | bool | None: + if sensitivity is Sensitivity.SECRET: + return None + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + return _redact_url_userinfo(value) + if isinstance(value, int | float): + return value + if isinstance(value, Path): + return str(value) + # Future settings may gain structured values. A read-only catalog must not guess a wire format + # or accidentally serialise an object with surprising/revealing repr semantics. + return None + + +def build_configuration_catalog( + settings: Settings, + *, + revision: int = 0, + overrides: dict[str, int | float] | None = None, +) -> ConfigurationCatalogResponse: + """Return the runtime configuration reference without exposing credential material.""" + + setting_fields = set(type(settings).model_fields) + documented_fields = set(SETTING_DOCS) + if setting_fields != documented_fields: + missing_docs = sorted(setting_fields - documented_fields) + unknown_docs = sorted(documented_fields - setting_fields) + raise RuntimeError( + "configuration reference is out of sync with Settings: " + f"missing_docs={missing_docs}, unknown_docs={unknown_docs}" + ) + + active = overrides or {} + schema = Settings.model_json_schema()["properties"] + entries: list[ConfigurationSettingResponse] = [] + for key in type(settings).model_fields: + documentation = SETTING_DOCS[key] + environment_variable = _environment_variable_name(key) + value = getattr(settings, key) + field = type(settings).model_fields[key] + rules = { + name: rule + for name, rule in schema[key].items() + if name in {"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "maxLength"} + } + editable = key in SERVING_OVERRIDE_KEYS + if key == "scheduler_safety_reserve_percentage": + rules.pop("maximum", None) + rules["exclusiveMaximum"] = 0.5 + if key == "gateway_queue_timeout_seconds": + rules["less_than_setting"] = "gateway_request_timeout_seconds" + entries.append( + ConfigurationSettingResponse( + key=key, + display_name=key.replace("_", " ").capitalize(), + category=key.split("_", 1)[0], + data_type=schema[key].get("type", "optional"), + default_value=_safe_effective_value(field.default, documentation.sensitivity), + validation_rules=rules, + editable=editable, + consumer=CONFIGURATION_CONSUMER if editable else None, + rollback=( + "Preview a previous revision or remove the override, then apply." + if editable + else "Restore deployment configuration and restart the component." + ), + environment_variable=environment_variable, + description=documentation.description, + sensitivity=documentation.sensitivity, + required_in_production=documentation.required_in_production, + application_mode=( + ConfigurationApplicationMode.DYNAMIC + if editable + else ConfigurationApplicationMode.RESTART_REQUIRED + ), + configured=_is_configured(value), + source=( + ConfigurationSource.OPERATOR_OVERRIDE + if key in active + else ConfigurationSource(settings.setting_source(key)) + ), + value=_safe_effective_value(value, documentation.sensitivity), + ) + ) + + return ConfigurationCatalogResponse( + environment=settings.env, + settings=entries, + read_only=False, + revision=revision, + overrides=active, + ) diff --git a/backend/src/modelforge_api/services/configuration_changes.py b/backend/src/modelforge_api/services/configuration_changes.py new file mode 100644 index 0000000..0e50b00 --- /dev/null +++ b/backend/src/modelforge_api/services/configuration_changes.py @@ -0,0 +1,326 @@ +"""Atomic, preview-bound safe overrides; all persisted payloads are numeric and allowlisted.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import math +from datetime import UTC, datetime + +from pydantic import ValidationError +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from modelforge_api.db import rows_affected +from modelforge_api.domain.configuration import ( + ConfigurationApplied, + ConfigurationApply, + ConfigurationChange, + ConfigurationDifference, + ConfigurationExport, + ConfigurationImport, + ConfigurationPreview, + ConfigurationRevisionResponse, + ConfigurationSource, +) +from modelforge_api.domain.configuration_policy import ( + CONFIGURATION_CONSUMER, + CONFIGURATION_FORMAT, + SERVING_OVERRIDE_KEYS, +) +from modelforge_api.domain.configuration_reference import SETTING_DOCS, Sensitivity +from modelforge_api.domain.release import PRODUCT_VERSION +from modelforge_api.persistence.models import ConfigurationRevision, ConfigurationState +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.settings import Settings + + +class ConfigurationError(Exception): + def __init__(self, code: str, message: str, status_code: int = 409) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +def _validate_overrides(settings: Settings, overrides: dict[str, int | float]) -> Settings: + for key, value in overrides.items(): + if key not in SERVING_OVERRIDE_KEYS: + raise ConfigurationError( + "CONFIGURATION_KEY_NOT_EDITABLE", + "Only the documented serving settings can be changed here.", + 422, + ) + if type(value) not in (int, float) or ( + isinstance(value, float) and not math.isfinite(value) + ): + raise ConfigurationError("CONFIGURATION_INVALID", "A finite number is required.", 422) + if Settings.model_fields[key].annotation is int and type(value) is not int: + raise ConfigurationError("CONFIGURATION_INVALID", f"{key} requires an integer.", 422) + try: + candidate = Settings.model_validate(settings.model_dump() | overrides) + candidate._inherit_setting_sources(settings) + except ValidationError as exc: + # Validation input may contain credentials from the deployment settings. Never echo it. + raise ConfigurationError( + "CONFIGURATION_INVALID", + "A value is outside the documented validation bounds.", + 422, + ) from exc + if candidate.gateway_queue_timeout_seconds >= candidate.gateway_request_timeout_seconds: + raise ConfigurationError( + "CONFIGURATION_INVALID", + "Queue timeout must be lower than request timeout.", + 422, + ) + if candidate.scheduler_safety_reserve_percentage >= 0.5: + raise ConfigurationError( + "CONFIGURATION_INVALID", + "The GPU safety reserve must be below 50%.", + 422, + ) + return candidate + + +def configuration_state(session: Session) -> tuple[int, dict[str, int | float]]: + row = session.execute( + select( + ConfigurationState.revision, + ConfigurationState.overrides, + ).where(ConfigurationState.id == 1) + ).first() + if row is None: + # Empty create_all test databases use the same first-write CAS as a fresh registry. + return 0, {} + return row.revision, dict(row.overrides) + + +def resolve_serving_settings(session: Session, settings: Settings) -> Settings: + return resolved_configuration(session, settings)[2] + + +def resolved_configuration( + session: Session, + settings: Settings, +) -> tuple[int, dict[str, int | float], Settings]: + revision, overrides = configuration_state(session) + return revision, overrides, _validate_overrides(settings, overrides) if overrides else settings + + +class ConfigurationService: + def __init__(self, session: Session, settings: Settings, context: AuditContext) -> None: + self.session = session + self.settings = settings + self.context = context + + def preview(self, request: ConfigurationChange) -> ConfigurationPreview: + revision, current = configuration_state(self.session) + if request.expected_revision != revision: + raise ConfigurationError( + "CONFIGURATION_CONFLICT", "Configuration changed; refresh and preview again." + ) + desired = dict(current) + for key, value in request.changes.items(): + if key not in SERVING_OVERRIDE_KEYS: + raise ConfigurationError( + "CONFIGURATION_KEY_NOT_EDITABLE", + "The requested setting is deployment-managed.", + 422, + ) + if value is None: + desired.pop(key, None) + else: + desired[key] = value + before = _validate_overrides(self.settings, current) + after = _validate_overrides(self.settings, desired) + differences = [ + ConfigurationDifference( + key=key, + before=getattr(before, key), + after=getattr(after, key), + source_before=( + ConfigurationSource.OPERATOR_OVERRIDE + if key in current + else ConfigurationSource(self.settings.setting_source(key)) + ), + source_after=( + ConfigurationSource.OPERATOR_OVERRIDE + if key in desired + else ConfigurationSource(self.settings.setting_source(key)) + ), + ) + for key in sorted(request.changes) + if getattr(before, key) != getattr(after, key) or (key in current) != (key in desired) + ] + if not differences: + raise ConfigurationError( + "CONFIGURATION_NO_CHANGE", "The proposal does not change configuration.", 422 + ) + # Include every editable base value: deployment changes invalidate earlier previews, even + # if the operator revision has not advanced. No credential enters this digest. + baseline = {key: getattr(self.settings, key) for key in sorted(SERVING_OVERRIDE_KEYS)} + payload = { + "format": CONFIGURATION_FORMAT, + "revision": revision, + "base": baseline, + "current": current, + "desired": desired, + } + digest = hashlib.sha256( + json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + ).hexdigest() + return ConfigurationPreview( + expected_revision=revision, + preview_digest=digest, + differences=differences, + affected_components=[CONFIGURATION_CONSUMER], + overrides=desired, + impact="New serving requests use this snapshot. In-flight requests retain their settings. " + "Admission and queue changes may reject new work; no model is promoted or worker restarted.", + rollback="Preview a retained revision or remove an override, then explicitly apply it as a new revision.", + ) + + def apply(self, request: ConfigurationApply) -> ConfigurationApplied: + preview = self.preview(request) + if not hmac.compare_digest(request.preview_digest, preview.preview_digest): + raise ConfigurationError( + "CONFIGURATION_PREVIEW_STALE", "The proposal changed; preview it again." + ) + next_revision = request.expected_revision + 1 + try: + result = self.session.execute( + update(ConfigurationState) + .where( + ConfigurationState.id == 1, + ConfigurationState.revision == request.expected_revision, + ) + .values(revision=next_revision, overrides=preview.overrides) + ) + if rows_affected(result) != 1: + if request.expected_revision != 0: + raise ConfigurationError( + "CONFIGURATION_CONFLICT", "A concurrent configuration change won." + ) + self.session.add(ConfigurationState(id=1, revision=1, overrides=preview.overrides)) + self.session.flush() # Unique singleton also protects the empty-registry race. + self.session.add( + ConfigurationRevision( + revision=next_revision, + previous_revision=request.expected_revision, + created_at=datetime.now(UTC), + actor=self.context.actor_id, + correlation_id=self.context.correlation_id or "unknown", + overrides=preview.overrides, + preview_digest=preview.preview_digest, + ) + ) + self.session.flush() + # Exercise the exact reader used by every new ServingService before acknowledging. + resolve_serving_settings(self.session, self.settings) + AuditWriter(self.session, context=self.context).write( + "configuration.applied", + "configuration_revision", + str(next_revision), + { + "previous_revision": request.expected_revision, + "preview_digest": preview.preview_digest, + "differences": [item.model_dump() for item in preview.differences], + "acknowledged_by": CONFIGURATION_CONSUMER, + }, + ) + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise ConfigurationError( + "CONFIGURATION_CONFLICT", "A concurrent configuration change won." + ) from exc + except Exception: + self.session.rollback() + raise + return ConfigurationApplied( + revision=next_revision, + acknowledged_by=CONFIGURATION_CONSUMER, + preview_digest=preview.preview_digest, + ) + + def history(self, limit: int = 30) -> list[ConfigurationRevisionResponse]: + rows = self.session.scalars( + select(ConfigurationRevision) + .order_by( + ConfigurationRevision.revision.desc(), + ) + .limit(limit) + ) + return [ + ConfigurationRevisionResponse.model_validate(row, from_attributes=True) for row in rows + ] + + def rollback_change(self, revision: int, expected_revision: int) -> ConfigurationChange: + _, current = configuration_state(self.session) + if revision == 0: + desired: dict[str, int | float] = {} + else: + row = self.session.get(ConfigurationRevision, revision) + if row is None: + raise ConfigurationError( + "CONFIGURATION_REVISION_NOT_FOUND", "Configuration revision was not found.", 404 + ) + desired = row.overrides + if desired == current: + raise ConfigurationError("CONFIGURATION_NO_CHANGE", "That revision is already effective.", 422) + return ConfigurationChange( + expected_revision=expected_revision, + changes={key: desired.get(key) for key in current.keys() | desired.keys()}, + ) + + def export(self) -> ConfigurationExport: + _, overrides = configuration_state(self.session) + _validate_overrides(self.settings, overrides) + return ConfigurationExport( + format=CONFIGURATION_FORMAT, + source_version=PRODUCT_VERSION, + overrides=overrides, + secret_slots=sorted({"huggingface_control_plane"} | { + key for key, doc in SETTING_DOCS.items() if doc.sensitivity is Sensitivity.SECRET + }), + ) + + def import_change(self, request: ConfigurationImport) -> ConfigurationChange: + document = request.document + if document.format != CONFIGURATION_FORMAT: + raise ConfigurationError( + "CONFIGURATION_FORMAT_UNSUPPORTED", "Unsupported configuration format.", 422 + ) + if document.source_version.split(".")[0] != PRODUCT_VERSION.split(".")[0]: + raise ConfigurationError( + "CONFIGURATION_VERSION_UNSUPPORTED", "Incompatible product major version.", 422 + ) + expected_slots = {"huggingface_control_plane"} | { + key for key, doc in SETTING_DOCS.items() if doc.sensitivity is Sensitivity.SECRET + } + if ( + len(document.secret_slots) != len(set(document.secret_slots)) + or set(document.secret_slots) != expected_slots + ): + raise ConfigurationError( + "CONFIGURATION_SLOTS_INVALID", + "Secret slot references do not match this product.", + 422, + ) + _validate_overrides(self.settings, document.overrides) + _, current = configuration_state(self.session) + changes = { + key: document.overrides.get(key) for key in current.keys() | document.overrides.keys() + } + if not changes: + raise ConfigurationError( + "CONFIGURATION_NO_CHANGE", "The import contains no overrides to change.", 422 + ) + return ConfigurationChange(expected_revision=request.expected_revision, changes=changes) diff --git a/backend/src/modelforge_api/services/evaluation.py b/backend/src/modelforge_api/services/evaluation.py index 77818f3..8877a0e 100644 --- a/backend/src/modelforge_api/services/evaluation.py +++ b/backend/src/modelforge_api/services/evaluation.py @@ -130,8 +130,16 @@ def _percentile(values: list[float], fraction: float) -> float: class EvaluationService: - def __init__(self, session: Session) -> None: + def __init__(self, session: Session, *, commit: bool = True) -> None: self.session = session + self._commit_results = commit + + def _finish_result(self, row: EvaluationRun | EvaluationComparison) -> None: + if self._commit_results: + self.session.commit() + else: + self.session.flush() + self.session.refresh(row) def create_suite(self, request: EvaluationSuiteCreate) -> EvaluationSuiteResponse: if self.session.get(Project, request.project_id) is None: @@ -220,6 +228,8 @@ class EvaluationService: "project or suite revision not found", status_code=404, ) + if not self._suite_belongs_to_project(revision.id, request.project_id): + raise EvaluationError("suite_project_mismatch", "suite revision belongs to another project") cases = list( self.session.scalars( select(EvaluationCase).where(EvaluationCase.suite_revision_id == revision.id) @@ -254,8 +264,7 @@ class EvaluationService: started_at=datetime.now(UTC), ) self.session.add(run) - self.session.commit() - self.session.refresh(run) + self._finish_result(run) return EvaluationRunResponse.model_validate(run) def complete_run( @@ -266,6 +275,8 @@ class EvaluationService: raise EvaluationError("run_not_found", "evaluation run not found", status_code=404) if run.status != "running": raise EvaluationError("run_not_active", "only a running evaluation can complete") + if not self._suite_belongs_to_project(run.suite_revision_id, run.project_id): + raise EvaluationError("suite_project_mismatch", "suite revision belongs to another project") cases = { case.id: case for case in self.session.scalars( @@ -310,10 +321,17 @@ class EvaluationService: run.aggregate_metrics = {name: totals[name] / len(request.results) for name in METRICS} run.status = "completed" if errors == 0 else "completed_with_errors" run.completed_at = datetime.now(UTC) - self.session.commit() - self.session.refresh(run) + self._finish_result(run) return EvaluationRunResponse.model_validate(run) + def _suite_belongs_to_project(self, revision_id: uuid.UUID, project_id: uuid.UUID) -> bool: + owner = self.session.scalar( + select(EvaluationSuite.project_id) + .join(EvaluationSuiteRevision, EvaluationSuiteRevision.suite_id == EvaluationSuite.id) + .where(EvaluationSuiteRevision.id == revision_id) + ) + return owner == project_id + def runs(self) -> list[EvaluationRunResponse]: return [ EvaluationRunResponse.model_validate(item) @@ -758,6 +776,8 @@ class EvaluationService: raise EvaluationError("run_incomplete", "both runs must be complete") factors = { "project": baseline.project_id == candidate.project_id, + "baseline_suite_project": self._suite_belongs_to_project(baseline.suite_revision_id, baseline.project_id), + "candidate_suite_project": self._suite_belongs_to_project(candidate.suite_revision_id, candidate.project_id), "suite_revision": baseline.suite_revision_id == candidate.suite_revision_id, "corpus_revision": baseline.corpus_revision == candidate.corpus_revision, "retrieval_config": baseline.retrieval_config_digest @@ -765,7 +785,8 @@ class EvaluationService: } if all(factors.values()): comparability = "comparable" - elif factors["project"] and factors["suite_revision"]: + elif (factors["project"] and factors["suite_revision"] + and factors["baseline_suite_project"] and factors["candidate_suite_project"]): comparability = "partially_comparable" else: comparability = "not_comparable" @@ -817,8 +838,10 @@ class EvaluationService: for name in METRICS } error_rate = candidate.error_count / candidate.expected_cases + baseline_error_free = baseline.status == "completed" and baseline.error_count == 0 eligible = ( comparability == "comparable" + and baseline_error_free and error_rate <= request.maximum_error_rate and deltas["recall_at_10"] >= request.minimum_recall_at_10_delta and (not request.require_no_critical_regressions or critical_regressions == 0) @@ -843,13 +866,14 @@ class EvaluationService: promotion_eligibility=eligibility, eligibility_evidence={ "error_rate": error_rate, + "baseline_error_count": baseline.error_count, + "baseline_error_free": baseline_error_free, "criteria": request.model_dump(mode="json"), "promotion_performed": False, }, ) self.session.add(comparison) - self.session.commit() - self.session.refresh(comparison) + self._finish_result(comparison) return EvaluationComparisonResponse.model_validate(comparison) def comparison(self, comparison_id: uuid.UUID) -> EvaluationComparisonResponse: diff --git a/backend/src/modelforge_api/services/external_creative.py b/backend/src/modelforge_api/services/external_creative.py new file mode 100644 index 0000000..cd85ec9 --- /dev/null +++ b/backend/src/modelforge_api/services/external_creative.py @@ -0,0 +1,770 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import UTC, datetime, timedelta +from typing import cast + +import sqlalchemy as sa +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from modelforge_api.domain.external_creative import ( + CreativeAllocationState, + CreativeRequiredAction, + CreativeWorkload, + ExternalActionAttestation, + ExternalAllocationCreate, + ExternalAllocationList, + ExternalAllocationResponse, + ExternalProfileObservation, + ExternalResourceApproval, + ExternalResourceList, + ExternalResourceResponse, +) +from modelforge_api.persistence.models import ( + Accelerator, + ExternalCreativeActionAttestation, + ExternalCreativeAllocation, + ExternalCreativeProfileObservation, + ExternalCreativeResource, + Project, + ProjectBinding, + ServiceClient, +) +from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.serving import ServingError, ServingService + +ACTIVE = {"granted", "uncertain"} +PRIORITY = {"production": 0, "interactive": 1, "background": 2, "benchmark": 3} +resources = cast(sa.Table, ExternalCreativeResource.__table__) +attestations = cast(sa.Table, ExternalCreativeActionAttestation.__table__) +observations = cast(sa.Table, ExternalCreativeProfileObservation.__table__) + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _digest(value: ExternalAllocationCreate) -> str: + return hashlib.sha256( + json.dumps(value.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +class ExternalCreativeService: + def __init__(self, session: Session, serving: ServingService): + self.session, self.serving = session, serving + + def _lock_accelerators(self) -> None: + self.session.scalars(select(Accelerator).order_by(Accelerator.id).with_for_update()).all() + + def _project(self, client: ServiceClient) -> uuid.UUID: + fresh = self.session.get( + ServiceClient, client.id, populate_existing=True, with_for_update=True + ) + binding = ( + self.session.get( + ProjectBinding, + fresh.project_binding_id, + populate_existing=True, + with_for_update=True, + ) + if fresh and fresh.project_binding_id + else None + ) + project = ( + self.session.get( + Project, binding.project_id, populate_existing=True, with_for_update=True + ) + if binding + else None + ) + if ( + fresh is None + or fresh.status != "active" + or fresh.disabled_at is not None + or "creative.resource@1" not in fresh.allowed_capabilities + or binding is None + or binding.deprecated_at is not None + or project is None + or not project.active + ): + raise ServingError( + 403, + "PROJECT_BINDING_REQUIRED", + "creative coordination requires an active project-bound client", + ) + return binding.project_id + + def _priority(self, client: ServiceClient, requested: str) -> str: + binding = self.session.get(ProjectBinding, client.project_binding_id) + allowed = max( + PRIORITY.get(client.workload_priority, 99), + PRIORITY.get(binding.priority, 99) if binding else 99, + ) + if PRIORITY.get(requested, -1) < allowed: + raise ServingError( + 403, + "ALLOCATION_PRIORITY_FORBIDDEN", + "requested priority exceeds client or project policy", + ) + return requested + + def _expire(self, row: ExternalCreativeAllocation) -> bool: + expiry = row.expires_at + if row.state == "granted" and ( + expiry is None + or (expiry.astimezone(UTC) if expiry.tzinfo else expiry.replace(tzinfo=UTC)) <= _now() + ): + row.state, row.uncertain_at, row.updated_at = "uncertain", _now(), _now() + row.expires_at = None + row.decision_code = "ALLOCATION_EXPIRED_UNCERTAIN" + self._audit("system", "external-creative-expiry").write( + "EXTERNAL_CREATIVE_ALLOCATION_EXPIRED", + "external_creative_allocation", + str(row.id), + {"state": row.state, "reservation_retained_bytes": row.required_vram_bytes}, + ) + return True + return False + + def _expire_grants(self) -> None: + for row in self.session.scalars( + select(ExternalCreativeAllocation) + .where(ExternalCreativeAllocation.state == "granted") + .order_by(ExternalCreativeAllocation.id) + .with_for_update() + .execution_options(populate_existing=True) + ): + self._expire(row) + self.session.flush() + + def _queued_authority(self, row: ExternalCreativeAllocation) -> bool: + client = self.session.get(ServiceClient, row.service_client_id, populate_existing=True) + try: + if client is None or self._project(client) != row.project_id: + return False + self._priority(client, row.priority) + except ServingError: + return False + resource = ( + self.session.execute( + select(resources) + .where( + resources.c.project_id == row.project_id, + resources.c.resource_ref == row.resource_ref, + resources.c.workload == row.workload, + resources.c.provider_resource_id == row.provider_resource_id, + ) + .with_for_update() + ) + .mappings() + .one_or_none() + ) + if resource is None or not resource["approved"]: + return False + row.required_vram_bytes = max( + row.required_vram_bytes, + resource["configured_vram_bytes"], + resource["measured_peak_vram_bytes"] or 0, + ) + return True + + def _audit( + self, client_or_type: ServiceClient | str, actor_id: str | None = None + ) -> AuditWriter: + if isinstance(client_or_type, ServiceClient): + return AuditWriter(self.session, "service_client", str(client_or_type.id)) + return AuditWriter(self.session, client_or_type, actor_id or "unknown") + + def _response( + self, row: ExternalCreativeAllocation, *, as_of: datetime | None = None + ) -> ExternalAllocationResponse: + expired = False + if as_of is not None and row.state == "granted": + expiry = row.expires_at + expired = ( + expiry is None + or (expiry.astimezone(UTC) if expiry.tzinfo else expiry.replace(tzinfo=UTC)) + <= as_of + ) + return ExternalAllocationResponse( + allocation_id=row.id, + project_id=row.project_id, + request_id=row.request_id, + installation_id=row.decision_evidence.get("installation_id", "unknown"), + workload=cast(CreativeWorkload, row.workload), + resource_ref=row.resource_ref, + provider_resource_id=row.provider_resource_id, + required_vram_bytes=row.required_vram_bytes, + priority=row.priority, + state=cast(CreativeAllocationState, "uncertain" if expired else row.state), + decision_code=("ALLOCATION_EXPIRED_UNCERTAIN" if expired else row.decision_code), + required_actions=cast(list[CreativeRequiredAction], row.required_actions or []), + accelerator_id=row.accelerator_id, + acquired_at=row.acquired_at, + heartbeat_at=row.heartbeat_at, + expires_at=None if expired else row.expires_at, + uncertain_at=row.uncertain_at, + released_at=row.released_at, + decision_evidence=row.decision_evidence or {}, + queue_position=(row.decision_evidence or {}).get("queue_position"), + ) + + def _promote_queue(self) -> None: + self._lock_accelerators() + self._expire_grants() + queued = list( + self.session.scalars( + select(ExternalCreativeAllocation) + .where(ExternalCreativeAllocation.state == "queued") + .order_by(ExternalCreativeAllocation.created_at, ExternalCreativeAllocation.id) + .with_for_update() + ) + ) + queued.sort( + key=lambda item: (PRIORITY.get(item.priority, 9), item.created_at, str(item.id)) + ) + # Snapshot already includes all existing external reservations. + reserved: dict[uuid.UUID, int] = {} + budgets, now = self.serving.scheduler_overview(commit=False), _now() + remaining_position = 0 + for item in queued: + if not self._queued_authority(item): + item.state, item.decision_code = "blocked", "QUEUED_AUTHORITY_REVOKED" + item.decision_evidence = {**item.decision_evidence, "queue_position": None} + self._audit("system", "external-creative-admission").write( + "EXTERNAL_CREATIVE_ALLOCATION_BLOCKED", + "external_creative_allocation", + str(item.id), + {"decision_code": item.decision_code}, + ) + continue + chosen = next( + ( + budget + for budget in budgets + if budget.attribution_confidence != "UNKNOWN" + and budget.pressure_state not in {"HIGH", "CRITICAL"} + and budget.schedulable_free_vram_bytes - reserved.get(budget.accelerator_id, 0) + >= item.required_vram_bytes + ), + None, + ) + evidence = dict(item.decision_evidence or {}) + if chosen is None: + remaining_position += 1 + evidence["queue_position"] = remaining_position + item.decision_evidence = evidence + continue + item.state, item.decision_code, item.accelerator_id = ( + "granted", + "CAPACITY_GRANTED_FROM_QUEUE", + chosen.accelerator_id, + ) + item.acquired_at = item.heartbeat_at = item.updated_at = now + item.expires_at = now + timedelta(seconds=item.ttl_seconds) + # This grant is based on observed schedulable free VRAM. A forced + # unload would discard a reusable resident model despite admission + # already succeeding. Preparation is only meaningful when a future + # decision explicitly accounts for reclaimable provider memory. + item.required_actions = [] + evidence.update( + { + "queue_position": None, + "promoted_at": now.isoformat(), + "policy_revision": chosen.policy_revision, + } + ) + item.decision_evidence = evidence + self._audit("system", "external-creative-admission").write( + "EXTERNAL_CREATIVE_ALLOCATION_GRANTED", + "external_creative_allocation", + str(item.id), + { + "accelerator_id": str(chosen.accelerator_id), + "reserved_vram_bytes": item.required_vram_bytes, + }, + ) + reserved[chosen.accelerator_id] = ( + reserved.get(chosen.accelerator_id, 0) + item.required_vram_bytes + ) + + def acquire( + self, request: ExternalAllocationCreate, client: ServiceClient + ) -> ExternalAllocationResponse: + self._lock_accelerators() + self._expire_grants() + project_id = self._project(client) + self._priority(client, request.priority) + digest = _digest(request) + previous = self.session.scalar( + select(ExternalCreativeAllocation).where( + ExternalCreativeAllocation.service_client_id == client.id, + ExternalCreativeAllocation.idempotency_key == request.idempotency_key, + ) + ) + if previous: + if previous.request_sha256 != digest: + raise ServingError( + 409, + "IDEMPOTENCY_CONFLICT", + "idempotency key belongs to another allocation request", + ) + self.session.commit() + return self._response(previous) + + binding = ( + self.session.execute( + select(resources) + .where( + resources.c.project_id == project_id, + resources.c.resource_ref == request.resource_ref, + resources.c.workload == request.workload, + resources.c.provider_resource_id == request.provider_resource_id, + ) + .with_for_update() + ) + .mappings() + .one_or_none() + ) + if binding is None or not binding["approved"]: + raise ServingError( + 403, + "RESOURCE_NOT_APPROVED", + "resource and provider binding is not approved for this project", + ) + profiled = binding["measured_peak_vram_bytes"] if request.use_measured_profile else None + required_vram = max( + request.required_vram_bytes, profiled or 0, binding["configured_vram_bytes"] + ) + + # Give already-queued work first access to newly available capacity, while + # allowing requests that fit to bypass queue entries that still cannot. + self._promote_queue() + budgets = self.serving.scheduler_overview(commit=False) + chosen = next( + ( + b + for b in budgets + if b.attribution_confidence != "UNKNOWN" + and b.pressure_state not in {"HIGH", "CRITICAL"} + and b.schedulable_free_vram_bytes >= required_vram + ), + None, + ) + queued = list( + self.session.scalars( + select(ExternalCreativeAllocation).where( + ExternalCreativeAllocation.state == "queued" + ) + ) + ) + queue_position = 1 + sum( + PRIORITY.get(item.priority, 9) <= PRIORITY[request.priority] for item in queued + ) + state, code = ("granted", "CAPACITY_GRANTED") if chosen else ("queued", "CAPACITY_QUEUED") + observed_budget = chosen or max( + budgets, key=lambda budget: budget.schedulable_free_vram_bytes, default=None + ) + actions: list[str] = [] + evidence = { + "installation_id": request.installation_id, + "policy_revision": observed_budget.policy_revision + if observed_budget + else "unavailable", + "observed_at": observed_budget.observed_at.isoformat() + if observed_budget and observed_budget.observed_at + else None, + "headroom_before_bytes": ( + observed_budget.schedulable_free_vram_bytes if observed_budget else 0 + ), + "profile_source": "measured" if profiled else "configured", + "requested_vram_bytes": request.required_vram_bytes, + "queue_position": None if chosen else queue_position, + } + now = _now() + row = ExternalCreativeAllocation( + service_client_id=client.id, + project_id=project_id, + accelerator_id=chosen.accelerator_id if chosen else None, + request_id=request.request_id, + idempotency_key=request.idempotency_key, + request_sha256=digest, + workload=request.workload, + resource_ref=request.resource_ref, + provider_resource_id=request.provider_resource_id, + required_vram_bytes=required_vram, + priority=request.priority, + ttl_seconds=request.ttl_seconds, + state=state, + decision_code=code, + decision_evidence=evidence, + required_actions=actions if chosen else [], + acquired_at=now if chosen else None, + heartbeat_at=now if chosen else None, + expires_at=now + timedelta(seconds=request.ttl_seconds) if chosen else None, + ) + self.session.add(row) + try: + self.session.flush() + self._audit(client).write( + "EXTERNAL_CREATIVE_ALLOCATION_DECIDED", + "external_creative_allocation", + str(row.id), + { + "project_id": str(project_id), + "workload": row.workload, + "resource_ref": row.resource_ref, + "state": row.state, + "decision_code": row.decision_code, + "accelerator_id": str(row.accelerator_id) if row.accelerator_id else None, + }, + ) + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise ServingError( + 409, + "ALLOCATION_CONFLICT", + "allocation was concurrently created; retry with the same idempotency key", + ) from exc + self.session.refresh(row) + return self._response(row) + + def get(self, allocation_id: uuid.UUID, client: ServiceClient) -> ExternalAllocationResponse: + self._lock_accelerators() + self._project(client) + self._promote_queue() + row = self.session.get(ExternalCreativeAllocation, allocation_id) + if row is None or row.service_client_id != client.id: + raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found") + self.session.commit() + return self._response(row) + + def action( + self, + allocation_id: uuid.UUID, + request_id: str, + action: str, + client: ServiceClient, + observed_peak_vram_bytes: int | None = None, + attestation: ExternalActionAttestation | None = None, + ) -> ExternalAllocationResponse: + self._lock_accelerators() + row = self.session.get( + ExternalCreativeAllocation, + allocation_id, + with_for_update=True, + populate_existing=True, + ) + if row is None or row.service_client_id != client.id or row.request_id != request_id: + raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found") + if self._expire(row) and action == "heartbeat": + self.session.commit() + raise ServingError( + 409, "ALLOCATION_EXPIRED", "expired allocation retains an uncertain reservation" + ) + if action == "heartbeat": + self._project(client) + if action == "attest" and attestation is None: + raise ServingError(422, "ATTESTATION_REQUIRED", "attestation is required") + now = _now() + if attestation is not None: + if attestation.action not in (row.required_actions or []): + raise ServingError(409, "ACTION_NOT_REQUIRED", "attested action was not required") + if attestation.observed_after > attestation.observed_before: + raise ServingError( + 422, "ATTESTATION_INVALID", "action attestation did not release VRAM" + ) + self.session.execute( + attestations.insert().values( + id=uuid.uuid4(), + allocation_id=row.id, + action=attestation.action, + before_vram_bytes=attestation.observed_before, + after_vram_bytes=attestation.observed_after, + observed_at=attestation.completed_at, + validated=True, + created_at=now, + ) + ) + if action == "release": + if row.state != "released": + row.state, row.released_at, row.updated_at = "released", now, now + elif action == "uncertain": + if row.state != "released": + row.state, row.uncertain_at, row.expires_at, row.updated_at = ( + "uncertain", + now, + None, + now, + ) + elif action == "heartbeat": + if row.state != "granted": + raise ServingError( + 409, "ALLOCATION_NOT_ACTIVE", "only granted allocations can be renewed" + ) + row.heartbeat_at, row.expires_at, row.updated_at = ( + now, + now + timedelta(seconds=row.ttl_seconds), + now, + ) + self._audit(client).write( + f"EXTERNAL_CREATIVE_ALLOCATION_{action.upper()}", + "external_creative_allocation", + str(row.id), + {"state": row.state, "workload": row.workload}, + ) + if action == "release": + self.session.flush() + self._promote_queue() + self.session.commit() + self.session.refresh(row) + return self._response(row) + + def observe( + self, body: ExternalProfileObservation, client: ServiceClient + ) -> ExternalResourceResponse: + self._lock_accelerators() + self._project(client) + row = self.session.get( + ExternalCreativeAllocation, + body.allocation_id, + with_for_update=True, + populate_existing=True, + ) + if row is None or row.service_client_id != client.id: + raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found") + if self._expire(row): + self.session.commit() + if ( + row.acquired_at is None + or row.accelerator_id is None + or row.state not in {"granted", "released"} + or body.reserved_vram_bytes != row.required_vram_bytes + ): + raise ServingError( + 409, + "PROFILE_GRANT_REQUIRED", + "observation requires an actual grant and exact reserved bytes", + ) + if (row.resource_ref, row.provider_resource_id, row.workload) != ( + body.resource_ref, + body.provider_resource_id, + body.workload, + ): + raise ServingError( + 409, "PROFILE_BINDING_MISMATCH", "observation does not match its allocation" + ) + record = ( + self.session.execute( + select(resources) + .where( + resources.c.project_id == row.project_id, + resources.c.resource_ref == row.resource_ref, + resources.c.workload == row.workload, + resources.c.provider_resource_id == row.provider_resource_id, + ) + .with_for_update() + ) + .mappings() + .one() + ) + previous = self.session.execute( + select(observations.c.id).where(observations.c.allocation_id == row.id) + ).scalar_one_or_none() + if previous is not None: + return self._resource(record["id"]) + try: + self.session.execute( + observations.insert().values( + id=uuid.uuid4(), + allocation_id=row.id, + resource_ref=body.resource_ref, + provider_resource_id=body.provider_resource_id, + workload=body.workload, + observed_peak_vram_bytes=body.observed_peak_vram_bytes, + reserved_vram_bytes=row.required_vram_bytes, + duration_ms=body.duration_ms, + outcome=body.outcome, + created_at=_now(), + ) + ) + except IntegrityError as exc: + self.session.rollback() + raise ServingError( + 409, "PROFILE_OBSERVATION_EXISTS", "allocation already has a profile observation" + ) from exc + self.session.execute( + resources.update() + .where(resources.c.id == record["id"]) + .values( + measured_peak_vram_bytes=max( + record["measured_peak_vram_bytes"] or 0, body.observed_peak_vram_bytes + ), + measurement_count=record["measurement_count"] + 1, + updated_at=_now(), + ) + ) + self._audit(client).write( + "EXTERNAL_CREATIVE_PROFILE_OBSERVED", + "external_creative_resource", + str(record["id"]), + {"outcome": body.outcome, "observed_peak_vram_bytes": body.observed_peak_vram_bytes}, + ) + self.session.commit() + return self._resource(record["id"]) + + def _resource(self, identifier: uuid.UUID) -> ExternalResourceResponse: + record = ( + self.session.execute(select(resources).where(resources.c.id == identifier)) + .mappings() + .one() + ) + return ExternalResourceResponse( + resource_id=identifier, + **{ + key: record[key] + for key in ExternalResourceResponse.model_fields + if key != "resource_id" + }, + ) + + def upsert_resource( + self, body: ExternalResourceApproval, actor_id: str + ) -> ExternalResourceResponse: + record = ( + self.session.execute( + select(resources) + .where( + resources.c.project_id == body.project_id, + resources.c.resource_ref == body.resource_ref, + resources.c.workload == body.workload, + resources.c.provider_resource_id == body.provider_resource_id, + ) + .with_for_update() + ) + .mappings() + .one_or_none() + ) + identifier, now = (record["id"] if record else uuid.uuid4()), _now() + values = {**body.model_dump(), "updated_at": now} + if record: + self.session.execute( + resources.update().where(resources.c.id == identifier).values(**values) + ) + else: + self.session.execute( + resources.insert().values( + id=identifier, measured_peak_vram_bytes=None, measurement_count=0, **values + ) + ) + self._audit("operator", actor_id).write( + "EXTERNAL_CREATIVE_RESOURCE_CONFIGURED", + "external_creative_resource", + str(identifier), + {"project_id": str(body.project_id), "approved": body.approved}, + ) + self.session.commit() + return self._resource(identifier) + + def list_allocations( + self, state: str | None, limit: int, offset: int + ) -> ExternalAllocationList: + observed_at = _now() + expired_grant = sa.and_( + ExternalCreativeAllocation.state == "granted", + sa.or_( + ExternalCreativeAllocation.expires_at.is_(None), + ExternalCreativeAllocation.expires_at <= observed_at, + ), + ) + effective_state = sa.case( + (expired_grant, "uncertain"), else_=ExternalCreativeAllocation.state + ) + condition = effective_state == state if state else sa.true() + with self.session.no_autoflush: + total = ( + self.session.scalar( + select(func.count()).select_from(ExternalCreativeAllocation).where(condition) + ) + or 0 + ) + rows = self.session.scalars( + select(ExternalCreativeAllocation) + .where(condition) + .order_by(ExternalCreativeAllocation.created_at.desc()) + .offset(offset) + .limit(limit) + ).all() + return ExternalAllocationList( + items=[self._response(row, as_of=observed_at) for row in rows], total=total + ) + + def list_resources( + self, project_id: uuid.UUID | None, limit: int, offset: int + ) -> ExternalResourceList: + condition = resources.c.project_id == project_id if project_id else sa.true() + total = ( + self.session.scalar(select(func.count()).select_from(resources).where(condition)) or 0 + ) + records = ( + self.session.execute( + select(resources) + .where(condition) + .order_by(resources.c.updated_at.desc()) + .offset(offset) + .limit(limit) + ) + .mappings() + .all() + ) + return ExternalResourceList( + items=[ + ExternalResourceResponse( + resource_id=record["id"], + **{ + key: record[key] + for key in ExternalResourceResponse.model_fields + if key != "resource_id" + }, + ) + for record in records + ], + total=total, + ) + + def reconcile( + self, allocation_id: uuid.UUID, action: str, reason: str, actor_id: str + ) -> ExternalAllocationResponse: + self._lock_accelerators() + row = self.session.get( + ExternalCreativeAllocation, + allocation_id, + with_for_update=True, + populate_existing=True, + ) + if row is None: + raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found") + self._expire(row) + if row.state != "uncertain": + raise ServingError( + 409, "ALLOCATION_NOT_UNCERTAIN", "only uncertain allocations can be reconciled" + ) + if action == "confirm_released": + row.state, row.released_at, row.updated_at = "released", _now(), _now() + self.session.flush() + self._promote_queue() + self._audit("operator", actor_id).write( + "EXTERNAL_CREATIVE_ALLOCATION_RECONCILED", + "external_creative_allocation", + str(row.id), + {"action": action, "reason": reason}, + ) + self.session.commit() + self.session.refresh(row) + return self._response(row) diff --git a/backend/src/modelforge_api/services/lab.py b/backend/src/modelforge_api/services/lab.py new file mode 100644 index 0000000..4241c87 --- /dev/null +++ b/backend/src/modelforge_api/services/lab.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import hashlib +import json + +from sqlalchemy.orm import Session + +from modelforge_api.domain.lab import ( + LabBinding, + LabEmbeddingInput, + LabInputSummary, + LabPreflightRequest, + LabPreflightResponse, + LabRerankingInput, + LabVisionInput, +) +from modelforge_api.persistence.models import DeploymentCandidate +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.registry import RegistryNotFound +from modelforge_api.services.runtime import RuntimeService +from modelforge_api.settings import Settings + +FAMILY_MODALITIES = { + "rag.embedding": "embedding", + "rag.reranking": "reranking", + "document.ocr": "document", + "vision.embedding": "vision", + "speech.transcription": "audio", +} + + +class LabPreflightService: + """Read-only snapshot validation, never an execution grant or resource reservation.""" + + def __init__(self, session: Session, settings: Settings) -> None: + self.session = session + self.settings = settings + self.runtime = RuntimeService(session, settings) + + def preflight(self, request: LabPreflightRequest) -> LabPreflightResponse: + with self.session.no_autoflush: + return self._preflight(request) + + def _preflight(self, request: LabPreflightRequest) -> LabPreflightResponse: + payload = request.input.payload.model_dump(mode="json") + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() + count = 1 + blockers: list[str] = [] + if isinstance(request.input, LabEmbeddingInput): + values = request.input.payload.inputs() + count = len(values) + if count > self.settings.gateway_max_batch_size or any( + len(value) > self.settings.gateway_max_input_characters for value in values + ): + blockers.append("INPUT_LIMIT_EXCEEDED") + elif isinstance(request.input, LabRerankingInput): + count = len(request.input.payload.documents) + elif isinstance(request.input, LabVisionInput): + count = len(request.input.payload.items) + if len(encoded) > self.settings.gateway_max_payload_bytes: + blockers.append("PAYLOAD_LIMIT_EXCEEDED") + summary = LabInputSummary( + sha256=hashlib.sha256(encoded).hexdigest(), item_count=count, encoded_bytes=len(encoded) + ) + contract = next( + ( + item + for item in ManifestRegistry(self.settings.config_root).capabilities() + if item.capability == request.input.capability + and item.version == request.contract_version + ), + None, + ) + if contract is None: + blockers.append("CAPABILITY_CONTRACT_UNAVAILABLE") + binding: LabBinding | None = None + target = request.target + candidate = self.session.get(DeploymentCandidate, target.deployment_candidate_id) + if candidate is None: + blockers.append("CANDIDATE_NOT_FOUND") + else: + if ( + candidate.production + or candidate.channel != "lab" + or candidate.status != "lab_ready" + ): + blockers.append("CANDIDATE_NOT_LAB_READY") + if ( + candidate.artifact_set_id != target.artifact_set_id + or candidate.runtime_profile_id != target.runtime_profile_id + or candidate.compute_node_id != target.compute_node_id + ): + blockers.append("TARGET_IDENTITY_MISMATCH") + repo = self.runtime.repo + profile = repo.profile(candidate.runtime_profile_id) + node = repo.node(candidate.compute_node_id) + assessment = repo.assessment(candidate.compatibility_assessment_id) + probe = repo.probe(candidate.runtime_probe_id) + approval = repo.approval(target.execution_approval_id) + if not profile or not node or not assessment or not probe or not approval: + blockers.append("EVIDENCE_MISSING") + else: + if ( + profile.artifact_set_id != candidate.artifact_set_id + or any( + item.artifact_set_id != candidate.artifact_set_id + or item.runtime_profile_id != candidate.runtime_profile_id + or item.compute_node_id != candidate.compute_node_id + for item in (assessment, probe) + ) + or probe.compatibility_assessment_id != assessment.id + or probe.execution_approval_id != approval.id + or approval.artifact_set_id != candidate.artifact_set_id + ): + blockers.append("EVIDENCE_IDENTITY_MISMATCH") + if profile.modality != FAMILY_MODALITIES[request.input.capability]: + blockers.append("CAPABILITY_PROFILE_MISMATCH") + if profile.trust_remote_code or profile.network_egress: + blockers.append("RUNTIME_POLICY_UNSUPPORTED") + if ( + not node.enabled + or not node.lab_eligible + or node.liveness_state != "online" + or not { + "deployment.load.v1", + "deployment.invoke.v1", + "deployment.unload.v1", + }.issubset(node.agent_capabilities) + ): + blockers.append("NODE_NOT_READY") + try: + if ( + assessment.status not in {"compatible", "requires_probe"} + or assessment.blockers + or self.runtime.assessment_response(assessment).stale + ): + blockers.append("ASSESSMENT_STALE_OR_BLOCKED") + if ( + self.runtime.approval_response(approval).stale + or approval.scope != "lab_execution" + ): + blockers.append("LAB_APPROVAL_STALE") + except RegistryNotFound: + blockers.append("ARTIFACT_EVIDENCE_MISSING") + if probe.status != "completed" or probe.cancel_requested or probe.failure_code: + blockers.append("PROBE_NOT_COMPLETED") + result = probe.inference_result + capability = request.input.capability + valid_output = False + if capability in {"rag.embedding", "vision.embedding"}: + dimension = result.get("dimension") + valid_output = ( + isinstance(dimension, int) + and not isinstance(dimension, bool) + and 32 <= dimension <= 8192 + and result.get("finite") is True + and result.get("output_type") in (None, "float_vector") + and ( + not contract + or not contract.vector + or contract.vector.dimensionality is None + or dimension == contract.vector.dimensionality + ) + ) + elif capability == "rag.reranking": + valid_output = ( + result.get("output_type") == "ranked_scores" + and result.get("finite") is True + and probe.runtime_facts.get("adapter") == "qwen3_reranker" + ) + else: + expected = "ocr_text" if capability == "document.ocr" else "transcription" + valid_output = ( + result.get("output_type") == expected and result.get("output_valid") is True + ) + if not valid_output: + blockers.append("PROBE_OUTPUT_INVALID") + gpu_uuid = probe.runtime_facts.get("gpu_uuid") + accelerator = next( + (item for item in repo.accelerators(node.id) if item.device_uuid == gpu_uuid), + None, + ) + if accelerator is None: + blockers.append("PROBED_ACCELERATOR_MISSING") + # Only expose a coherent server-resolved binding, never echo a mismatched target. + if not {"TARGET_IDENTITY_MISMATCH", "EVIDENCE_IDENTITY_MISMATCH"}.intersection( + blockers + ): + binding = LabBinding( + **target.model_dump(), + compatibility_assessment_id=assessment.id, + runtime_probe_id=probe.id, + capability=capability, + runtime_profile_fingerprint=profile.fingerprint, + assessment_fingerprint=assessment.environment_fingerprint, + approval_evidence_fingerprint=approval.evidence_fingerprint, + probe_environment_fingerprint=probe.environment_fingerprint, + accelerator_id=accelerator.id if accelerator else None, + ) + return LabPreflightResponse( + input_summary=summary, + binding=binding, + limits=request.limits, + blockers=list(dict.fromkeys(blockers)), + checks_passed=not blockers, + ) diff --git a/backend/src/modelforge_api/services/lab_runs.py b/backend/src/modelforge_api/services/lab_runs.py new file mode 100644 index 0000000..7689a36 --- /dev/null +++ b/backend/src/modelforge_api/services/lab_runs.py @@ -0,0 +1,805 @@ +from __future__ import annotations + +import hashlib +import json +import re +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from modelforge_api.domain.dispatch_context import DispatchContext +from modelforge_api.domain.lab import ( + LabEmbeddingInput, + LabExecutionRunResponse, + LabExecutionStartRequest, + LabInputSummary, +) +from modelforge_api.persistence.models import ( + CapabilityDeployment, + GatewayRequest, + LabExecutionRun, + ServingGpuLease, + ServingJob, +) +from modelforge_api.services.gpu_scheduler import PlacementVerdict +from modelforge_api.services.serving import ServingError, ServingService + +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +_ACTIVE_STATUSES = frozenset({"accepted", "preparing", "queued", "running", "cancel_requested"}) +_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "interrupted"}) +_RESIDENT_STATES = frozenset({"warm", "idle"}) + + +def _canonical_json(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False + ).encode("utf-8") + + +def _sha256(value: Any) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +class LabRunService: + """Persist actor-scoped identities for bounded, selected embedding runs.""" + + def __init__(self, serving: ServingService) -> None: + self.serving = serving + self.session = serving.session + + def _owned(self, run_id: UUID, actor_subject: str) -> LabExecutionRun: + run = self.session.scalar( + select(LabExecutionRun).where( + LabExecutionRun.id == run_id, + LabExecutionRun.actor_subject == actor_subject, + ) + ) + if run is None: + raise ServingError(404, "LAB_RUN_NOT_FOUND", "LAB run was not found") + return run + + @staticmethod + def _response(run: LabExecutionRun) -> LabExecutionRunResponse: + return LabExecutionRunResponse( + id=run.id, + status=run.status, # type: ignore[arg-type] + target_snapshot=run.target_snapshot, + input_summary=LabInputSummary( + sha256=run.input_sha256, + item_count=run.input_count, + encoded_bytes=run.input_encoded_bytes, + ), + output_metadata=run.output_metadata, + worker_observations=run.worker_observations, + failure_code=run.failure_code, + input_payload_expires_at=run.input_payload_expires_at, + retention_seconds=run.retention_seconds, + created_at=run.created_at, + started_at=run.started_at, + cancellation_requested_at=run.cancellation_requested_at, + cancellation_acknowledged_at=run.cancellation_acknowledged_at, + finished_at=run.finished_at, + ) + + def _fingerprint(self, request: LabExecutionStartRequest) -> str: + return _sha256(request.model_dump(mode="json")) + + def _selected_target( + self, request: LabExecutionStartRequest + ) -> tuple[CapabilityDeployment, dict[str, Any], LabEmbeddingInput]: + deployment = self.serving.repo.deployment(request.deployment_id) + if deployment is None: + raise ServingError(404, "LAB_TARGET_NOT_FOUND", "selected LAB target was not found") + target = request.target + if ( + deployment.deployment_candidate_id != target.deployment_candidate_id + or deployment.artifact_set_id != target.artifact_set_id + or deployment.runtime_profile_id != target.runtime_profile_id + or deployment.compute_node_id != target.compute_node_id + or deployment.execution_approval_id != target.execution_approval_id + ): + raise ServingError( + 409, "LAB_TARGET_MISMATCH", "selected LAB target identity does not match" + ) + if not isinstance(request.input, LabEmbeddingInput): + raise ServingError( + 422, "LAB_INPUT_NOT_SUPPORTED", "operator execution supports text embeddings only" + ) + selected, _route_key = self.serving._admit_operator_embedding( + request.input, deployment.id, request.experiment_route_id + ) + return ( + selected, + self.serving._operator_lab_target_snapshot(selected, request.experiment_route_id), + request.input, + ) + + def _gateway(self, run: LabExecutionRun) -> GatewayRequest | None: + return self.session.get(GatewayRequest, run.gateway_request_id) + + def _set_gateway_state( + self, + run: LabExecutionRun, + status: str, + *, + failure_code: str | None = None, + ) -> None: + gateway = self._gateway(run) + if gateway is None: + return + gateway.status = status + if status in {"completed", "failed", "cancelled", "interrupted"}: + gateway.finished_at = _now() + if failure_code: + gateway.failure_code = failure_code + evidence = gateway.decision_evidence or {} + lab_evidence = evidence.get("lab_run_evidence", {}) + if isinstance(lab_evidence, dict): + updated = {**lab_evidence, "status": status} + if failure_code: + updated["failure_code"] = failure_code + if run.output_metadata: + updated["output"] = run.output_metadata + if run.worker_observations: + updated["worker_observations"] = run.worker_observations + gateway.decision_evidence = {**evidence, "lab_run_evidence": updated} + + def _delete_payload(self, run: LabExecutionRun) -> None: + if run.payload_reference: + self.serving.payload_store.delete(run.payload_reference) + run.payload_reference = None + + def _release_reservation(self, run: LabExecutionRun, *, failed: bool = False) -> None: + gateway = self._gateway(run) + if gateway is None: + return + now = _now() + request_id = gateway.request_id + lease = self.session.scalar( + select(ServingGpuLease) + .where(ServingGpuLease.request_id == request_id) + .order_by(ServingGpuLease.created_at.desc()) + .limit(1) + ) + if lease is None: + return + if lease and lease.state not in {"released", "expired", "failed"}: + lease.state = "failed" if failed else "released" + lease.released_at = now + deployment = ( + self.serving.repo.deployment(gateway.capability_deployment_id) + if gateway.capability_deployment_id + else None + ) + residency = self.serving.repo.residency(deployment.id, lock=True) if deployment else None + if residency: + residency.active_requests = max(0, residency.active_requests - 1) + if residency.state == "busy": + residency.state = "warm" + elif residency.state == "loading": + residency.state = "cold" + residency.transition_reason = "LAB_RUN_CANCELLED_BEFORE_LOAD" + residency.last_used_at = now + + def _finish( + self, + run: LabExecutionRun, + status: str, + *, + failure_code: str | None = None, + ) -> None: + run.status = status + run.failure_code = failure_code + run.finished_at = _now() + self._set_gateway_state(run, status, failure_code=failure_code) + self._delete_payload(run) + + def start( + self, + request: LabExecutionStartRequest, + *, + actor_subject: str, + idempotency_key: str, + ) -> LabExecutionRunResponse: + if not actor_subject or len(actor_subject) > 255: + raise ServingError( + 401, "OPERATOR_CONTEXT_REQUIRED", "operator principal is unavailable" + ) + if not _IDEMPOTENCY_KEY.fullmatch(idempotency_key): + raise ServingError(422, "LAB_IDEMPOTENCY_KEY_INVALID", "idempotency key is invalid") + fingerprint = self._fingerprint(request) + existing = self.session.scalar( + select(LabExecutionRun).where( + LabExecutionRun.actor_subject == actor_subject, + LabExecutionRun.idempotency_key == idempotency_key, + ) + ) + if existing is not None: + if existing.request_fingerprint != fingerprint: + raise ServingError( + 409, + "LAB_IDEMPOTENCY_CONFLICT", + "idempotency key was already used for a different LAB request", + ) + return self._response(existing) + + deployment, target_snapshot, embedding_input = self._selected_target(request) + payload = embedding_input.payload + inputs = payload.inputs() + input_sha256 = _sha256(inputs) + encoded_input = _canonical_json(payload.model_dump(mode="json")) + run_id = uuid4() + context = DispatchContext.for_operator(actor_subject, run_id) + now = _now() + payload_reference = self.serving.payload_store.put( + {"input": inputs, "input_type": payload.input_type}, + request.limits.retention_seconds, + ) + gateway_request = GatewayRequest( + request_id=uuid4(), + service_client_id=None, + capability_key="rag.embedding", + capability_version=1, + capability_deployment_id=deployment.id, + compute_node_id=deployment.compute_node_id, + status="queued", + priority="lab", + input_sha256=input_sha256, + input_count=len(inputs), + decision_evidence={ + "dispatch_context": context.provenance(), + "operator_route_id": str(request.experiment_route_id), + "input_persisted": False, + "input_type": payload.input_type, + "lab_run_evidence": { + "schema_version": 1, + "status": "accepted", + "capability": "rag.embedding@1", + "operation_id": str(run_id), + "target": target_snapshot, + "input": { + "sha256": input_sha256, + "item_count": len(inputs), + "content_logged": False, + }, + }, + }, + ) + self.session.add(gateway_request) + try: + self.session.flush() + run = LabExecutionRun( + id=run_id, + actor_subject=actor_subject, + idempotency_key=idempotency_key, + request_fingerprint=fingerprint, + status="accepted", + capability="rag.embedding@1", + target_snapshot=target_snapshot, + input_sha256=input_sha256, + input_count=len(inputs), + input_encoded_bytes=len(encoded_input), + payload_reference=payload_reference, + input_payload_expires_at=now + timedelta(seconds=request.limits.retention_seconds), + gateway_request_id=gateway_request.id, + timeout_seconds=request.limits.timeout_seconds, + queue_timeout_seconds=request.limits.queue_timeout_seconds, + retention_seconds=request.limits.retention_seconds, + created_at=now, + ) + self.session.add(run) + self.session.commit() + except IntegrityError: + self.session.rollback() + winner = self.session.scalar( + select(LabExecutionRun).where( + LabExecutionRun.actor_subject == actor_subject, + LabExecutionRun.idempotency_key == idempotency_key, + ) + ) + self.serving.payload_store.delete(payload_reference) + if winner is not None and winner.request_fingerprint == fingerprint: + return self._response(winner) + if winner is not None: + raise ServingError( + 409, + "LAB_IDEMPOTENCY_CONFLICT", + "idempotency key was already used for a different LAB request", + ) from None + raise ServingError(409, "STATE_CONFLICT", "LAB run could not be persisted") from None + except Exception: + self.session.rollback() + self.serving.payload_store.delete(payload_reference) + raise + self.session.refresh(run) + return self._response(run) + + def _active_job(self, run: LabExecutionRun) -> ServingJob | None: + return self.session.scalar( + select(ServingJob) + .where(ServingJob.lab_execution_run_id == run.id) + .order_by(ServingJob.created_at.desc()) + .limit(1) + ) + + def _input(self, run: LabExecutionRun) -> LabEmbeddingInput | None: + if not run.payload_reference or _aware(run.input_payload_expires_at) <= _now(): + return None + payload = self.serving.payload_store.get(run.payload_reference) + if not payload: + return None + values = payload.get("input") + input_type = payload.get("input_type", "raw") + if ( + not isinstance(values, list) + or not values + or len(values) != run.input_count + or any(not isinstance(value, str) for value in values) + or input_type not in {"raw", "query", "document"} + or _sha256(values) != run.input_sha256 + ): + return None + try: + return LabEmbeddingInput.model_validate( + { + "capability": "rag.embedding", + "payload": {"input": values, "input_type": input_type}, + } + ) + except ValueError: + return None + + def _revalidate_target( + self, run: LabExecutionRun, input_value: LabEmbeddingInput + ) -> CapabilityDeployment: + deployment_id = UUID(str(run.target_snapshot["deployment_id"])) + route_id = UUID(str(run.target_snapshot["experiment_route_id"])) + deployment, _ = self.serving._admit_operator_embedding(input_value, deployment_id, route_id) + current_snapshot = self.serving._operator_lab_target_snapshot(deployment, route_id) + if current_snapshot != run.target_snapshot: + raise ServingError( + 409, "LAB_TARGET_CHANGED", "selected LAB target changed after run acceptance" + ) + return deployment + + def _add_job( + self, + run: LabExecutionRun, + deployment: CapabilityDeployment, + gateway: GatewayRequest, + operation: str, + *, + now: datetime, + ) -> ServingJob: + key = _sha256({"run_id": str(run.id), "operation": operation}) + existing = self.session.scalar(select(ServingJob).where(ServingJob.idempotency_key == key)) + if existing is not None: + return existing + job = ServingJob( + capability_deployment_id=deployment.id, + compute_node_id=deployment.compute_node_id, + gateway_request_id=gateway.id, + lab_execution_run_id=run.id, + operation=operation, + status="queued", + priority="lab", + idempotency_key=key, + payload_reference=(run.payload_reference if operation == "invoke" else None), + attempt_count=0, + result_summary={}, + created_at=now, + updated_at=now, + ) + self.session.add(job) + self.session.flush() + return job + + def _queue_invoke( + self, + run: LabExecutionRun, + deployment: CapabilityDeployment, + gateway: GatewayRequest, + *, + now: datetime, + ) -> bool: + if self._input(run) is None: + self._finish(run, "failed", failure_code="REQUEST_PAYLOAD_EXPIRED") + self._release_reservation(run, failed=True) + return False + if self.serving.repo.queued_job_count(deployment.id) >= deployment.max_queue_depth: + return False + if ( + self.serving.repo.active_gateway_requests() + > self.serving.settings.scheduler_global_queue_limit + ): + return False + job = self._add_job(run, deployment, gateway, "invoke", now=now) + lease = self.session.scalar( + select(ServingGpuLease) + .where(ServingGpuLease.request_id == gateway.request_id) + .order_by(ServingGpuLease.created_at.desc()) + .limit(1) + ) + if lease: + lease.serving_job_id = job.id + run.status = "queued" + gateway.status = "running" + self._set_gateway_state(run, "queued") + self.session.commit() + return True + + def _admit_and_queue(self, run: LabExecutionRun, now: datetime) -> bool: + input_value = self._input(run) + if input_value is None: + self._finish(run, "failed", failure_code="REQUEST_PAYLOAD_EXPIRED") + self.session.commit() + return True + try: + deployment = self._revalidate_target(run, input_value) + except ServingError as exc: + self._finish(run, "failed", failure_code=exc.code) + self.session.commit() + return True + gateway = self._gateway(run) + if gateway is None: + self._finish(run, "failed", failure_code="LAB_EVIDENCE_MISSING") + self.session.commit() + return True + if self.serving.repo.queued_job_count(deployment.id) >= deployment.max_queue_depth: + return False + if ( + self.serving.repo.active_gateway_requests() + > self.serving.settings.scheduler_global_queue_limit + ): + return False + self.serving.repo.lock_accelerator(deployment.accelerator_id) + residency = self.serving.repo.residency(deployment.id, lock=True) + if residency is None: + self._finish(run, "failed", failure_code="SCHEDULER_STATE_STALE") + self.session.commit() + return True + if residency.active_requests >= deployment.max_concurrency: + return False + if residency.state not in _RESIDENT_STATES and residency.state != "cold": + if residency.state in {"loading", "busy", "unloading", "draining"}: + return False + self._finish(run, "failed", failure_code="LAB_TARGET_UNAVAILABLE") + self.session.commit() + return True + plan = self.serving._placement_plan( + deployment, + priority="lab", + deadline_remaining_ms=float(run.timeout_seconds * 1000), + request_id=gateway.request_id, + dry_run=True, + persist=False, + commit=False, + ) + if plan.verdict == PlacementVerdict.QUEUE.value: + self.session.commit() + return False + if plan.verdict == PlacementVerdict.ADMIT_AFTER_EVICTION.value: + self._finish(run, "failed", failure_code="LAB_EVICTION_REQUIRED") + self.session.commit() + return True + if plan.verdict != PlacementVerdict.ADMIT.value: + code = { + PlacementVerdict.REJECT_HEALTH.value: "LAB_TARGET_UNAVAILABLE", + PlacementVerdict.REJECT_POLICY.value: "LAB_POLICY_BLOCKED", + PlacementVerdict.REJECT_CAPACITY.value: "LAB_CAPACITY_BLOCKED", + }.get(plan.verdict, "LAB_ADMISSION_BLOCKED") + self._finish(run, "failed", failure_code=code) + self.session.commit() + return True + + context = DispatchContext.for_operator(run.actor_subject, run.id) + deadline = _aware(run.created_at) + timedelta(seconds=run.timeout_seconds) + lease = ServingGpuLease( + accelerator_id=deployment.accelerator_id, + capability_deployment_id=deployment.id, + request_id=gateway.request_id, + reserved_vram_bytes=plan.required_vram_bytes, + priority="lab", + state="active", + owner=context.lease_owner, + created_at=now, + acquired_at=now, + heartbeat_at=now, + expires_at=deadline, + lease_type=( + "request_execution" + if residency.state in _RESIDENT_STATES + else "residency_reservation" + ), + materialized_vram_bytes=( + residency.measured_resident_vram_bytes if residency.state in _RESIDENT_STATES else 0 + ), + generation=residency.generation, + ) + self.session.add(lease) + gateway.status = "running" + gateway.decision_evidence = { + **(gateway.decision_evidence or {}), + "dispatch_context": context.provenance(), + "placement": { + "verdict": plan.verdict, + "decision_fingerprint": plan.decision_fingerprint, + "required_vram_bytes": plan.required_vram_bytes, + "reason_codes": plan.reason_codes, + }, + } + run.dispatch_started_at = now + if residency.state == "cold": + residency.state = "loading" + residency.transition_started_at = now + residency.transition_reason = "LAB_RUN_LOAD" + residency.generation += 1 + operation = "load" + run.status = "preparing" + else: + residency.state = "busy" + operation = "invoke" + run.status = "queued" + residency.active_requests += 1 + residency.last_used_at = now + job = self._add_job(run, deployment, gateway, operation, now=now) + self.session.flush() + lease.serving_job_id = job.id + lab_evidence = (gateway.decision_evidence or {}).get("lab_run_evidence", {}) + if isinstance(lab_evidence, dict): + gateway.decision_evidence = { + **gateway.decision_evidence, + "lab_run_evidence": {**lab_evidence, "status": run.status}, + } + self.session.commit() + return True + + def _drive_preparing(self, run: LabExecutionRun, now: datetime) -> bool: + gateway = self._gateway(run) + deployment = ( + self.serving.repo.deployment(gateway.capability_deployment_id) + if gateway and gateway.capability_deployment_id + else None + ) + if gateway is None or deployment is None: + self._finish(run, "failed", failure_code="LAB_EVIDENCE_MISSING") + self.session.commit() + return True + jobs = list( + self.session.scalars( + select(ServingJob) + .where(ServingJob.lab_execution_run_id == run.id) + .order_by(ServingJob.created_at.desc()) + ) + ) + latest = jobs[0] if jobs else None + if latest is None: + self._finish(run, "interrupted", failure_code="LAB_DISPATCH_STATE_INCOMPLETE") + self.session.commit() + return True + if latest.status in {"queued", "running"}: + return False + if latest.operation != "load" or latest.status != "completed": + self._finish(run, "failed", failure_code=latest.failure_code or "RESIDENCY_LOAD_FAILED") + self._release_reservation(run, failed=True) + self.session.commit() + return True + input_value = self._input(run) + if input_value is None: + self._finish(run, "failed", failure_code="REQUEST_PAYLOAD_EXPIRED") + self._release_reservation(run, failed=True) + self.session.commit() + return True + try: + selected = self._revalidate_target(run, input_value) + except ServingError as exc: + self._finish(run, "failed", failure_code=exc.code) + self._release_reservation(run, failed=True) + self.session.commit() + return True + if _aware(run.created_at) + timedelta(seconds=run.timeout_seconds) <= now: + self._finish(run, "failed", failure_code="REQUEST_TIMEOUT") + self._release_reservation(run, failed=True) + self.session.commit() + return True + self._queue_invoke(run, selected, gateway, now=now) + return run.status == "queued" + + def _expire_run(self, run: LabExecutionRun, now: datetime) -> bool: + gateway = self._gateway(run) + if gateway is None: + self._finish(run, "failed", failure_code="LAB_EVIDENCE_MISSING") + self.session.commit() + return True + job = self._active_job(run) + if job and job.status == "running": + run.status = "cancel_requested" + run.failure_code = "REQUEST_TIMEOUT" + run.cancellation_requested_at = run.cancellation_requested_at or now + self._set_gateway_state(run, "cancel_requested", failure_code="REQUEST_TIMEOUT") + self._delete_payload(run) + self.session.commit() + return True + # Do not lock/update a queued job here: claim and completion lock job then run. + # The terminal run state fences a concurrent claim, which will mark that job + # failed after acquiring the same locks in the established order. + self._release_reservation(run, failed=True) + self._finish(run, "failed", failure_code="REQUEST_TIMEOUT") + self.session.commit() + return True + + def _interrupt_stale_job(self, job_id: UUID, now: datetime) -> bool: + job = self.session.scalar( + select(ServingJob).where(ServingJob.id == job_id).with_for_update() + ) + if ( + job is None + or job.lab_execution_run_id is None + or job.status != "running" + or job.lease_expires_at is None + or _aware(job.lease_expires_at) > now + ): + self.session.rollback() + return False + run = self.session.scalar( + select(LabExecutionRun) + .where(LabExecutionRun.id == job.lab_execution_run_id) + .with_for_update() + ) + if run is None or run.status in _TERMINAL_STATUSES: + self.session.rollback() + return False + job.status = "failed" + job.failure_code = "LAB_WORKER_LEASE_STALE" + job.finished_at = now + job.lease_token_hash = None + job.lease_expires_at = None + gateway = self._gateway(run) + deployment = ( + self.serving.repo.deployment(gateway.capability_deployment_id) + if gateway and gateway.capability_deployment_id + else None + ) + if deployment: + deployment.health_status = "unavailable" + residency = self.serving.repo.residency(deployment.id, lock=True) + if residency: + residency.state = "failed" + residency.failure_code = "LAB_WORKER_LEASE_STALE" + residency.failure_message = None + residency.transition_reason = "LAB_WORKER_LEASE_STALE" + self._finish(run, "interrupted", failure_code="LAB_WORKER_LEASE_STALE") + # The GPU reservation is intentionally left until its own expiry. A missing + # worker heartbeat is not proof that the process has stopped using the device. + self.session.commit() + return True + + def reconcile(self) -> dict[str, int]: + now = _now() + interrupted = 0 + stale_job_ids = list( + self.session.scalars( + select(ServingJob.id) + .where( + ServingJob.lab_execution_run_id.is_not(None), + ServingJob.status == "running", + ServingJob.lease_expires_at <= now, + ) + .order_by(ServingJob.lease_expires_at) + .limit(100) + ) + ) + for job_id in stale_job_ids: + interrupted += self._interrupt_stale_job(job_id, now) + driven = 0 + candidate_ids = list( + self.session.scalars( + select(LabExecutionRun.id) + .where(LabExecutionRun.status.in_(("accepted", "preparing", "queued"))) + .order_by(LabExecutionRun.created_at) + .limit(100) + ) + ) + for run_id in candidate_ids: + run = self.session.scalar( + select(LabExecutionRun) + .where( + LabExecutionRun.id == run_id, + LabExecutionRun.status.in_(("accepted", "preparing", "queued")), + ) + .with_for_update(skip_locked=True) + ) + if run is None: + self.session.rollback() + continue + now = _now() + if _aware(run.created_at) + timedelta(seconds=run.timeout_seconds) <= now: + driven += self._expire_run(run, now) + continue + if run.status in {"accepted", "queued"} and ( + _aware(run.created_at) + timedelta(seconds=run.queue_timeout_seconds) <= now + ): + self._expire_run(run, now) + driven += 1 + continue + if run.status == "accepted": + driven += self._admit_and_queue(run, now) + elif run.status == "preparing": + driven += self._drive_preparing(run, now) + elif run.status == "queued": + job = self._active_job(run) + if job is None or job.status not in {"queued", "running"}: + self._finish(run, "interrupted", failure_code="LAB_DISPATCH_STATE_INCOMPLETE") + self.session.commit() + driven += 1 + else: + self.session.rollback() + return {"runs_driven": driven, "stale_runs_interrupted": interrupted} + + def get(self, run_id: UUID, *, actor_subject: str) -> LabExecutionRunResponse: + return self._response(self._owned(run_id, actor_subject)) + + def cancel(self, run_id: UUID, *, actor_subject: str) -> LabExecutionRunResponse: + # Check ownership before taking locks so a foreign id stays indistinguishable from a + # missing run. Lock order is job then run, matching claim and worker completion. + self._owned(run_id, actor_subject) + job = self.session.scalar( + select(ServingJob) + .where( + ServingJob.lab_execution_run_id == run_id, + ServingJob.status.in_(("queued", "running")), + ) + .order_by(ServingJob.created_at.desc()) + .limit(1) + .with_for_update() + ) + run = self.session.scalar( + select(LabExecutionRun) + .where( + LabExecutionRun.id == run_id, + LabExecutionRun.actor_subject == actor_subject, + ) + .with_for_update() + ) + if run is None: + raise ServingError(404, "LAB_RUN_NOT_FOUND", "LAB run was not found") + if run.status in _TERMINAL_STATUSES: + self.session.commit() + return self._response(run) + now = _now() + if job is not None and job.status == "running": + # Inference is synchronous inside the worker. The run remains nonterminal until + # the worker reaches a safe boundary and acknowledges cancellation. + run.status = "cancel_requested" + run.cancellation_requested_at = run.cancellation_requested_at or now + self._delete_payload(run) + self._set_gateway_state(run, "cancel_requested") + else: + if job is not None: + job.status = "cancelled" + job.failure_code = "CANCELLED" + job.finished_at = now + job.lease_token_hash = None + job.lease_expires_at = None + job.payload_reference = None + self._release_reservation(run) + self._finish(run, "cancelled") + run.cancellation_requested_at = run.cancellation_requested_at or now + run.cancellation_acknowledged_at = None + self.session.commit() + if job is not None and job.status == "cancelled": + # The worker never claimed this job, so no worker acknowledgement is expected. + pass + self.session.refresh(run) + return self._response(run) diff --git a/backend/src/modelforge_api/services/lifecycle.py b/backend/src/modelforge_api/services/lifecycle.py index 29beac7..5b2c6f6 100644 --- a/backend/src/modelforge_api/services/lifecycle.py +++ b/backend/src/modelforge_api/services/lifecycle.py @@ -75,6 +75,9 @@ from modelforge_api.persistence.models import ( RuntimeProbe, RuntimeProfile, ) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.lifecycle_serving import LifecycleServing +from modelforge_api.services.serving import ServingError class LifecycleError(RuntimeError): @@ -94,6 +97,13 @@ def _now() -> datetime: return datetime.now(UTC) +def _serving_changed_at(operation: LifecycleOperation) -> str: + if operation.finished_at is None: + raise LifecycleError(409, "SERVING_JOURNAL_INVALID", "committed serving timestamp is missing") + value = operation.finished_at + return (value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)).isoformat() + + DEFAULT_POLICIES: tuple[dict[str, Any], ...] = ( { "key": "lab-promotion", @@ -305,6 +315,18 @@ class LifecycleService: "SUBJECT_TARGET_MISMATCH", "approval target does not match lifecycle subject", ) + if request.evidence.serving_transition is not None: + selected = request.evidence.serving_transition + if request.evidence.production_validation != "SATISFIED": + raise LifecycleError(409, "SERVING_PRODUCTION_VALIDATION_REQUIRED", "actual project serving requires satisfied production validation") + if (request.environment != "PRODUCTION" or policy.scope != "CAPABILITY_PRODUCTION" + or request.target_type != "CAPABILITY_DEPLOYMENT" + or request.target_ref != str(selected.candidate_deployment_id) + or request.requested_transition != "STABLE" + or request.evidence.capability_deployment_id != selected.candidate_deployment_id + or request.evidence.production_execution_approval_id != selected.production_approval_id + or request.evidence.rollback_target_ref != str(selected.current_deployment_id)): + raise LifecycleError(422, "SERVING_APPROVAL_MISMATCH", "serving transition requires exact production approval scope and targets") resolved = self._resolve_evidence(request.evidence.model_dump(mode="json")) snapshot = { "declared": request.evidence.model_dump(mode="json"), @@ -400,7 +422,9 @@ class LifecycleService: item.status = ApprovalStatus.REJECTED event_type = "APPROVAL_REJECTED" elif action == "revoke": - if item.status != ApprovalStatus.APPROVED: + if item.status != ApprovalStatus.APPROVED and not ( + item.status == ApprovalStatus.STALE and item.approved_by is not None + ): raise LifecycleError(409, "APPROVAL_NOT_APPROVED", "only approval can be revoked") item.status = ApprovalStatus.REVOKED event_type = "APPROVAL_REVOKED" @@ -468,13 +492,29 @@ class LifecycleService: "APPROVAL_MIGRATION_MISMATCH", "promotion plan migration differs from approved evidence", ) + serving_contract = approval.evidence_snapshot.get("resolved", {}).get("serving_transition") + if "serving_transition" in request.affected_identities: + raise LifecycleError(422, "SERVING_CONTRACT_SERVER_OWNED", "serving contract is derived from approved evidence") + if serving_contract is not None: + if (request.canary_strategy or request.migration_class not in {"transparent", "behavioral"} + or subject.target_type != "CAPABILITY_DEPLOYMENT" or subject.environment != "PRODUCTION" + or subject.target_ref != str(request.candidate_deployment_id) + or subject.state not in {"LAB_READY", "PROMOTION_ELIGIBLE"} + or request.desired_state != "STABLE"): + raise LifecycleError(422, "SERVING_PLAN_UNSUPPORTED", "only reviewed same-space stable replacement is supported") + contract = LifecycleServing(self.session).validate(serving_contract) + request = request.model_copy(update={ + "affected_identities": {**request.affected_identities, "serving_transition": serving_contract}, + "project_consumers": sorted({value["project_id"] for value in contract.bindings.values()}), + }) try: intermediate = ( DeploymentLifecycleState.CANARY if request.canary_strategy else request.desired_state ) - assert_deployment_transition(DeploymentLifecycleState(subject.state), intermediate) + if serving_contract is None: + assert_deployment_transition(DeploymentLifecycleState(subject.state), intermediate) except ValueError as exc: raise LifecycleError(422, "INVALID_LIFECYCLE_TRANSITION", str(exc)) from exc if request.migration_class == "requires_reindex": @@ -635,6 +675,11 @@ class LifecycleService: raise LifecycleError(500, "SUBJECT_REFERENCE_MISSING", "plan subject is unavailable") if subject.version != request.expected_subject_version: raise LifecycleError(409, "VERSION_CONFLICT", "lifecycle subject changed concurrently") + serving_contract = plan.affected_identities.get("serving_transition") + if serving_contract is not None: + LifecycleServing(self.session).validate(serving_contract) + elif subject.environment == "PRODUCTION" and subject.target_type == "CAPABILITY_DEPLOYMENT" and plan.desired_state == "STABLE": + raise LifecycleError(409, "SERVING_TRANSITION_REQUIRED", "operational stable promotion requires the approved serving adapter") plan_version = plan.version snapshot_payload = { "subject_id": str(subject.id), @@ -645,7 +690,10 @@ class LifecycleService: "version": subject.version, "rollback_target_ref": plan.rollback_target_ref, "affected_identities": plan.affected_identities, + "superseded_by_ref": subject.superseded_by_ref, } + if serving_contract is not None: + snapshot_payload["serving_transition"] = serving_contract snapshot = LifecycleRollbackSnapshot( promotion_plan_id=plan.id, snapshot=snapshot_payload, @@ -741,7 +789,8 @@ class LifecycleService: return self._operation_response(operation) def observe_canary( - self, operation_id: uuid.UUID, observation: CanaryObservation + self, operation_id: uuid.UUID, observation: CanaryObservation, + *, audit_context: AuditContext | None = None, ) -> LifecycleOperationResponse: operation = self._operation(operation_id) if operation.stage != OperationStage.VERIFYING: @@ -751,16 +800,32 @@ class LifecycleService: ) if not canary or canary.status != CanaryStatus.RUNNING: raise LifecycleError(409, "CANARY_NOT_RUNNING", "canary is not running") + plan = self._plan(operation.promotion_plan_id) + subject = self._require_manual_canary_rehearsal(plan) + context = audit_context or AuditContext( + actor_type="manual_lab_rehearsal", actor_id="local-lab-rehearsal", + ) canary.request_count += observation.request_count canary.error_count += observation.error_count canary.latency_p95_ms = observation.latency_p95_ms - canary.result = observation.model_dump(mode="json") + canary.result = { + **observation.model_dump(mode="json"), + "provenance": { + "source": "MANUAL_LAB_REHEARSAL", + "authoritative": False, + "actor": context.actor_id, + "correlation_id": context.correlation_id, + }, + } trigger = self._canary_trigger(canary, observation) if trigger: canary.status = CanaryStatus.ABORTED canary.abort_trigger = trigger canary.finished_at = _now() - self._rollback(operation, trigger, "SYSTEM", "Automatic canary abort") + self._rollback( + operation, trigger, context.actor_id, + "Automatic abort from manual LAB rehearsal observation", + ) event_type = "CANARY_ABORTED" elif canary.request_count >= canary.target_request_count: canary.status = CanaryStatus.READY_FOR_PROMOTION_REVIEW @@ -768,18 +833,14 @@ class LifecycleService: event_type = "CANARY_READY_FOR_PROMOTION_REVIEW" else: event_type = "CANARY_OBSERVED" - plan = self._plan(operation.promotion_plan_id) - subject = self.session.get(LifecycleSubject, plan.subject_id) - if subject is None: - raise LifecycleError(500, "SUBJECT_REFERENCE_MISSING", "plan subject is unavailable") self._event( event_type, subject.target_type, subject.target_ref, subject.state, canary.status, - "lifecycle-engine", - "SYSTEM", + context.actor_id, + "MANUAL_LAB_REHEARSAL", self._approval(plan.approval_request_id).policy_revision_id, [str(canary.id)], trigger or "Bounded canary observation recorded without payload content", @@ -790,14 +851,64 @@ class LifecycleService: }, change_id=str(operation.id), ) + AuditWriter(self.session, context=context).write( + action="lifecycle.manual_lab_canary_observed", + resource_type="lifecycle_operation", + resource_id=str(operation.id), + details={ + "source": "MANUAL_LAB_REHEARSAL", + "canary_id": str(canary.id), + "status": canary.status, + "authoritative": False, + }, + ) self.session.commit() return self._operation_response(operation) + def _require_manual_canary_rehearsal( + self, plan: LifecyclePromotionPlan, + ) -> LifecycleSubject: + """No manual or legacy canary evidence can qualify an operational promotion.""" + subject = self.session.get(LifecycleSubject, plan.subject_id) + approval = self.session.get(LifecycleApprovalRequest, plan.approval_request_id) + policy = self.session.get(LifecyclePolicyRevision, approval.policy_revision_id) if ( + approval is not None + ) else None + if ( + subject is None or approval is None or policy is None + or subject.target_type != "LAB_REHEARSAL" + or subject.environment != "LAB" + or approval.environment != "LAB" + or policy.scope != "LAB_PROMOTION" + or plan.desired_state != "LAB_STABLE" + or approval.subject_id != subject.id + or approval.target_type != subject.target_type + or approval.target_ref != subject.target_ref + or approval.requested_transition != plan.desired_state + ): + raise LifecycleError( + 409, "MANUAL_CANARY_REHEARSAL_ONLY", + "Canary observations and commit require an explicit LAB rehearsal; " + "authoritative operational canary evidence ingestion is unavailable", + ) + return subject + def commit_operation( self, operation_id: uuid.UUID, decision: ApprovalDecision ) -> LifecycleOperationResponse: operation = self._operation(operation_id) plan = self._plan(operation.promotion_plan_id) + if operation.stage == OperationStage.COMMITTED: + transition = plan.affected_identities.get("serving_transition") + if transition is not None: + snapshot = self.session.scalar(select(LifecycleRollbackSnapshot).where( + LifecycleRollbackSnapshot.promotion_plan_id == plan.id)) + if (snapshot is None or _hash(snapshot.snapshot) != snapshot.snapshot_digest + or snapshot.snapshot.get("serving_transition") != transition): + raise LifecycleError(409, "SERVING_SNAPSHOT_INVALID", "exact installed serving snapshot is required") + LifecycleServing(self.session).installed(transition, _serving_changed_at(operation)) + self.session.commit() + return self._operation_response(operation) if operation.stage != OperationStage.VERIFYING: raise LifecycleError( 409, "OPERATION_NOT_REVIEWABLE", "operation is not ready for review" @@ -805,6 +916,8 @@ class LifecycleService: canary = self.session.scalar( select(LifecycleCanaryRun).where(LifecycleCanaryRun.operation_id == operation.id) ) + if canary or plan.canary_strategy: + self._require_manual_canary_rehearsal(plan) if canary and canary.status != CanaryStatus.READY_FOR_PROMOTION_REVIEW: raise LifecycleError( 409, @@ -823,14 +936,26 @@ class LifecycleService: raise LifecycleError( 409, "ROLLBACK_SNAPSHOT_MISSING", "immutable rollback snapshot is unavailable" ) + serving_contract = plan.affected_identities.get("serving_transition") + serving_changed_at = None + if serving_contract is not None: + approval = self._approval(plan.approval_request_id) + self._refresh_approval(approval) + if approval.status != ApprovalStatus.APPROVED or _hash(approval.evidence_snapshot) != approval.evidence_fingerprint: + raise LifecycleError(409, "APPROVAL_STALE", "serving approval is no longer current") + if snapshot.snapshot.get("serving_transition") != serving_contract or _hash(snapshot.snapshot) != snapshot.snapshot_digest: + raise LifecycleError(409, "SERVING_SNAPSHOT_INVALID", "exact immutable serving snapshot is required") + serving_changed_at = _now() + LifecycleServing(self.session).switch(serving_contract, serving_changed_at.isoformat()) expected_state = ( DeploymentLifecycleState.CANARY if plan.canary_strategy else plan.current_state ) try: - assert_deployment_transition( - DeploymentLifecycleState(expected_state), - DeploymentLifecycleState(plan.desired_state), - ) + if serving_contract is None: + assert_deployment_transition( + DeploymentLifecycleState(expected_state), + DeploymentLifecycleState(plan.desired_state), + ) except ValueError as exc: raise LifecycleError(409, "STATE_CHANGED", str(exc)) from exc previous = str(expected_state) @@ -862,7 +987,7 @@ class LifecycleService: ) self.session.refresh(subject) operation.stage = OperationStage.COMMITTED - operation.finished_at = _now() + operation.finished_at = serving_changed_at or _now() operation.version += 1 plan.status = PromotionPlanStatus.COMMITTED plan.version += 1 @@ -1142,6 +1267,14 @@ class LifecycleService: def _resolve_evidence(self, declared: dict[str, Any]) -> dict[str, Any]: facts: dict[str, Any] = {} + if declared.get("serving_transition") is not None: + from modelforge_api.domain.lifecycle_contracts import ServingTransitionSelection + + if declared.get("production_validation") != "SATISFIED": + raise LifecycleError(409, "SERVING_PRODUCTION_VALIDATION_REQUIRED", "actual project serving requires satisfied production validation") + facts["serving_transition"] = LifecycleServing(self.session).prepare( + ServingTransitionSelection.model_validate(declared["serving_transition"]) + ).model_dump(mode="json") for field, model in ( ("artifact_set_id", ArtifactSet), ("runtime_profile_id", RuntimeProfile), @@ -1359,8 +1492,21 @@ class LifecycleService: change_id=str(item.id), ) return - current_resolved = self._resolve_evidence(item.evidence_snapshot["declared"]) - if _hash(current_resolved) != _hash(item.evidence_snapshot["resolved"]): + stale_details: dict[str, Any] = {} + current_resolved: dict[str, Any] | None + try: + current_resolved = self._resolve_evidence(item.evidence_snapshot["declared"]) + except (LifecycleError, ServingError) as exc: + # A historical serving approval must remain inspectable after its route + # changes. Expected qualification failures invalidate authority; internal + # errors still propagate rather than being disguised as stale evidence. + if (item.evidence_snapshot["declared"].get("serving_transition") is None + or exc.status_code not in {404, 409, 422}): + raise + current_resolved = None + stale_details = {"validation_code": exc.code, "validation_reason": str(exc)} + item.blockers = sorted(set(item.blockers) | {exc.code}) + if current_resolved is None or _hash(current_resolved) != _hash(item.evidence_snapshot["resolved"]): previous = item.status item.status = ApprovalStatus.STALE item.stale_at = now @@ -1375,7 +1521,7 @@ class LifecycleService: item.policy_revision_id, [], "Referenced evidence changed after approval request creation", - {}, + stale_details, change_id=str(item.id), ) @@ -1461,6 +1607,7 @@ class LifecycleService: ) -> None: started = time.perf_counter() plan = self._plan(operation.promotion_plan_id) + self._approval(plan.approval_request_id) # Same approval-before-route lock order as commit. snapshot = self.session.scalar( select(LifecycleRollbackSnapshot).where( LifecycleRollbackSnapshot.promotion_plan_id == plan.id @@ -1492,6 +1639,21 @@ class LifecycleService: ) return snapshot_version = int(snapshot.snapshot["version"]) + serving_contract = plan.affected_identities.get("serving_transition") + was_committed = operation.stage == OperationStage.COMMITTED + if serving_contract is not None: + if (snapshot.snapshot.get("serving_transition") != serving_contract + or _hash(snapshot.snapshot) != snapshot.snapshot_digest): + raise LifecycleError(409, "SERVING_SNAPSHOT_INVALID", "exact serving snapshot is unavailable") + # A subject CAS failure below rolls back the route in the same transaction. + # A later route is preserved rather than guessed away. + try: + LifecycleServing(self.session).restore(serving_contract, + _serving_changed_at(operation) if was_committed else _now().isoformat(), + committed=was_committed) + except Exception: + self.session.rollback() + raise expected_version = snapshot_version + ( 2 if operation.stage == OperationStage.COMMITTED else 1 ) @@ -1511,6 +1673,9 @@ class LifecycleService: ) ) if rows_affected(restored) != 1: + if serving_contract is not None: + self.session.rollback() + raise LifecycleError(409, "ROLLBACK_VERSION_CONFLICT", "lifecycle subject changed; serving state was preserved") operation.stage = OperationStage.FAILED operation.failure_code = "ROLLBACK_VERSION_CONFLICT" operation.failure_details = { @@ -1581,6 +1746,26 @@ class LifecycleService: 500, "RETENTION_POLICY_MISSING", "rollback retention policy missing" ) retained_until = _now() + timedelta(days=policy.minimum_rollback_days) + transition = plan.affected_identities.get("serving_transition") + if transition is not None: + prior = transition["current"] + for kind, ref in (("ARTIFACT_SET", prior["artifact_set_id"]), + ("RUNTIME_PROFILE", prior["runtime_profile_id"]), + ("CAPABILITY_DEPLOYMENT", prior["id"])): + retained = self.session.scalar(select(LifecycleRetentionRecord).where( + LifecycleRetentionRecord.target_type == kind, + LifecycleRetentionRecord.target_ref == ref)) + if retained is None: + self.session.add(LifecycleRetentionRecord( + policy_revision_id=policy.id, target_type=kind, target_ref=ref, + state=RetentionState.ROLLBACK_RETAINED, retained_until=retained_until, + legal_hold=False, reason="Exact prior serving identity retained for approved rollback", + created_at=_now())) + else: + previous_until = retained.retained_until + if previous_until is None or previous_until.replace(tzinfo=UTC) < retained_until: + retained.retained_until = retained_until + retained.state = RetentionState.ROLLBACK_RETAINED existing = self.session.scalar( select(LifecycleRetentionRecord).where( LifecycleRetentionRecord.target_type == "ROLLBACK_TARGET", @@ -1856,19 +2041,19 @@ class LifecycleService: ) def _approval(self, approval_id: uuid.UUID) -> LifecycleApprovalRequest: - item = self.session.get(LifecycleApprovalRequest, approval_id) + item = self.session.get(LifecycleApprovalRequest, approval_id, with_for_update=True, populate_existing=True) if not item: raise LifecycleError(404, "APPROVAL_NOT_FOUND", "approval request not found") return item def _plan(self, plan_id: uuid.UUID) -> LifecyclePromotionPlan: - item = self.session.get(LifecyclePromotionPlan, plan_id) + item = self.session.get(LifecyclePromotionPlan, plan_id, with_for_update=True, populate_existing=True) if not item: raise LifecycleError(404, "PROMOTION_PLAN_NOT_FOUND", "promotion plan not found") return item def _operation(self, operation_id: uuid.UUID) -> LifecycleOperation: - item = self.session.get(LifecycleOperation, operation_id) + item = self.session.get(LifecycleOperation, operation_id, with_for_update=True, populate_existing=True) if not item: raise LifecycleError(404, "LIFECYCLE_OPERATION_NOT_FOUND", "operation not found") return item diff --git a/backend/src/modelforge_api/services/lifecycle_serving.py b/backend/src/modelforge_api/services/lifecycle_serving.py new file mode 100644 index 0000000..dd36fc7 --- /dev/null +++ b/backend/src/modelforge_api/services/lifecycle_serving.py @@ -0,0 +1,337 @@ +"""Transaction-only serving adapter for approved lifecycle plans. + +This adapter never commits, invokes inference, or changes a consumer index. +The lifecycle engine owns the approval, journal, transaction and rollback. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from typing import Any, NoReturn +from uuid import UUID + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from modelforge_api.domain.lifecycle_contracts import ( + ServingTransitionContract, + ServingTransitionSelection, +) +from modelforge_api.domain.serving import ProductionApprovalCreate, SupplyChainReview +from modelforge_api.persistence.models import ( + Accelerator, + ArtifactSet, + CapabilityContract, + CapabilityDeployment, + CapabilityExperimentRoute, + CapabilityResourceEnvelope, + ComputeNode, + DeploymentCandidate, + EvaluationComparison, + EvaluationRun, + ProductionExecutionApproval, + Project, + ProjectBinding, + ProjectFitEvidence, + ResidencyAllocation, + RuntimeCompatibilityAssessment, + RuntimeProbe, + SelectedModelComparison, + ServiceClient, +) +from modelforge_api.persistence.serving_repository import ServingRepository +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.selected_model_comparison import SelectedModelComparisonService +from modelforge_api.services.serving import ServingService +from modelforge_api.settings import get_settings + + +def _fail(code: str, message: str) -> NoReturn: + # The parent service imports this adapter; defer its public boundary error. + from modelforge_api.services.lifecycle import LifecycleError + + raise LifecycleError(409, code, message) + + +def _json(value: Any) -> Any: + if isinstance(value, datetime): + return value.replace(tzinfo=UTC).isoformat() if value.tzinfo is None else value.astimezone(UTC).isoformat() + return str(value) if isinstance(value, UUID) else value + + +def digest(value: Any) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), + default=_json, allow_nan=False).encode()).hexdigest() + + +ROUTE_FIELDS = ( + "id", "capability_contract_id", "deployment_candidate_id", "production_approval_id", + "execution_approval_id", "embedding_space_id", "artifact_set_id", "runtime_profile_id", + "compute_node_id", "accelerator_id", "channel", "status", "production", "routing_weight", + "fallback_policy", "residency_policy", "keep_warm_seconds", "max_concurrency", "max_queue_depth", + "config_fingerprint", "provenance", "rollback_policy", "promoted_at", "draining_at", "deprecated_at", +) + + +class LifecycleServing: + def __init__(self, session: Session) -> None: + self.session = session + self.repo = ServingRepository(session) + + @staticmethod + def route_snapshot(row: CapabilityDeployment) -> dict[str, Any]: + return {field: _json(getattr(row, field)) for field in ROUTE_FIELDS} + + def _deployment(self, identifier: UUID) -> CapabilityDeployment: + row = self.session.get(CapabilityDeployment, identifier, with_for_update=True, populate_existing=True) + if row is None: + _fail("SERVING_TARGET_MISSING", "exact serving deployment is unavailable") + return row + + def _lock_accelerators(self, selection: ServingTransitionSelection) -> None: + node_ids = list(self.session.scalars(select(CapabilityDeployment.compute_node_id).where( + CapabilityDeployment.id.in_((selection.current_deployment_id, selection.candidate_deployment_id))))) + # Hardware inventory flushes compute_nodes before updating accelerators. + # Native admission does not acquire a node lock while holding its accelerator. + self.session.scalars(select(ComputeNode).where(ComputeNode.id.in_(node_ids)).order_by( + ComputeNode.id).with_for_update().execution_options(populate_existing=True)).all() + identifiers = list(self.session.scalars(select(CapabilityDeployment.accelerator_id).where( + CapabilityDeployment.id.in_((selection.current_deployment_id, selection.candidate_deployment_id))))) + self.session.scalars(select(Accelerator).where(Accelerator.id.in_(identifiers)).order_by( + Accelerator.id).with_for_update()).all() + + def _bindings(self, contract_id: UUID) -> dict[str, dict[str, Any]]: + # Lock even deprecated bindings: a concurrent reactivation changes the blast radius. + bindings = list(self.session.scalars(select(ProjectBinding).where( + ProjectBinding.capability_contract_id == contract_id + ).order_by(ProjectBinding.id).with_for_update().execution_options(populate_existing=True))) + result = {} + for binding in bindings: + project = self.session.get(Project, binding.project_id, with_for_update=True, populate_existing=True) + clients = list(self.session.scalars(select(ServiceClient).where( + ServiceClient.project_binding_id == binding.id + ).order_by(ServiceClient.id).with_for_update().execution_options(populate_existing=True))) + if binding.channel != "stable" or binding.deprecated_at is not None: + continue + if project is None: + _fail("SERVING_BINDING_MISSING", "binding project disappeared") + result[str(binding.id)] = { + "project_id": str(binding.project_id), "project_active": project.active, + "priority": binding.priority, "optional": binding.optional, + "fallback_policy": binding.fallback_policy, "migration_support": binding.migration_support, + "slo": binding.slo, "benchmark_requirements": binding.benchmark_requirements, + "clients": [{"id": str(client.id), "status": client.status, + "disabled_at": _json(client.disabled_at), + "capabilities": client.allowed_capabilities} for client in clients], + } + if not result or len(result) > 1000: + _fail("SERVING_BINDINGS_UNBOUNDED", "transition requires one to 1000 exact stable bindings") + return result + + def _experiment_routes(self, candidate_id: UUID) -> dict[str, dict[str, Any]]: + return {str(row.id): {"route_key": row.route_key, "status": row.status, + "capability_contract_id": str(row.capability_contract_id)} + for row in self.session.scalars(select(CapabilityExperimentRoute).where( + CapabilityExperimentRoute.capability_deployment_id == candidate_id + ).order_by(CapabilityExperimentRoute.id).with_for_update().execution_options(populate_existing=True))} + + def prepare(self, selection: ServingTransitionSelection) -> ServingTransitionContract: + # Obtain the contract before any deployment locks to match other route writers. + candidate_ref = self.session.get(CapabilityDeployment, selection.candidate_deployment_id) + if candidate_ref is None: + _fail("SERVING_TARGET_MISSING", "candidate deployment is unavailable") + contract = self.repo.lock_contract_route(candidate_ref.capability_contract_id) + if contract is None or contract.deprecated_at is not None: + _fail("SERVING_CONTRACT_UNAVAILABLE", "capability contract is unavailable") + if contract.serving_generation != selection.expected_route_generation: + _fail("SERVING_ROUTE_CHANGED", "stable route generation changed after selection") + # Match inventory node->accelerator and admission accelerator->residency/binding. + self._lock_accelerators(selection) + current = self._deployment(selection.current_deployment_id) + candidate = self._deployment(selection.candidate_deployment_id) + if (current.id == candidate.id or current.capability_contract_id != contract.id + or not current.production or current.status != "stable" or current.channel != "stable" + or candidate.production or candidate.status != "candidate" or candidate.channel != "experiment"): + _fail("SERVING_SELECTION_INVALID", "exact stable current and nonproduction candidate are required") + if current.embedding_space_id is None or current.embedding_space_id != candidate.embedding_space_id: + _fail("SERVING_REINDEX_REQUIRED", "changed embedding identity requires trusted consumer reindex and cutover") + experiment_routes = self._experiment_routes(candidate.id) + bindings = self._bindings(contract.id) + if set(bindings) != {str(identifier) for identifier in selection.comparison_ids}: + _fail("SERVING_BINDING_EVIDENCE_REQUIRED", "every affected stable binding requires its own comparison") + if len(set(selection.comparison_ids.values())) != len(bindings): + _fail("SERVING_COMPARISON_REUSED", "one project comparison cannot authorize another binding") + comparison_service = SelectedModelComparisonService(self.session, AuditContext("system", "lifecycle-serving")) + evidence = {} + for binding_id, comparison_id in sorted(selection.comparison_ids.items(), key=lambda item: str(item[0])): + comparison = self.session.get(SelectedModelComparison, comparison_id, with_for_update=True, populate_existing=True) + latest = self.session.scalar(select(SelectedModelComparison.id).where( + SelectedModelComparison.project_binding_id == binding_id + ).order_by(SelectedModelComparison.created_at.desc(), SelectedModelComparison.id.desc()).limit(1)) + if (comparison is None or comparison.status != "completed" or comparison.id != latest + or comparison.project_binding_id != binding_id + or comparison.current_deployment_id != current.id or comparison.candidate_deployment_id != candidate.id + or comparison.finished_at is None or comparison.result_digest is None): + _fail("SERVING_COMPARISON_STALE", "latest completed exact current/candidate comparison is required") + comparison_service._revalidate(comparison) + evaluated = self.session.get(EvaluationComparison, comparison.evaluation_comparison_id) + if (evaluated is None or evaluated.comparability != "comparable" + or evaluated.promotion_eligibility != "eligible" or evaluated.critical_regressions + or evaluated.baseline_run_id != comparison.baseline_run_id + or evaluated.candidate_run_id != comparison.candidate_run_id): + _fail("SERVING_COMPARISON_NOT_ELIGIBLE", "project comparison does not qualify replacement") + for run_id in (comparison.baseline_run_id, comparison.candidate_run_id): + run = self.session.get(EvaluationRun, run_id) + if run is None or run.status != "completed" or run.error_count or run.completed_cases != run.expected_cases: + _fail("SERVING_RUN_NOT_COMPLETE", "both comparison legs must be complete and error-free") + fit = self.session.scalar(select(ProjectFitEvidence).where( + ProjectFitEvidence.project_binding_id == binding_id, + ProjectFitEvidence.capability_deployment_id == candidate.id, + ).order_by(ProjectFitEvidence.created_at.desc(), ProjectFitEvidence.id.desc()).limit(1) + .with_for_update().execution_options(populate_existing=True)) + if fit is not None and (fit.recommendation != "PROMOTION_ELIGIBLE" + or fit.production_validation != "SATISFIED" or fit.engineering_integration != "PASS" + or fit.critical_errors or fit.blockers or fit.case_count < 1): + _fail("SERVING_PROJECT_FIT_BLOCKED", "latest exact binding/candidate project-fit evidence blocks production") + evidence[str(binding_id)] = {"id": str(comparison.id), "pins": comparison.pins, + "result_digest": comparison.result_digest, "evaluation_comparison_id": str(evaluated.id), + "baseline_run_id": str(evaluated.baseline_run_id), "candidate_run_id": str(evaluated.candidate_run_id), + "project_fit": ({"id": str(fit.id), "evidence_digest": fit.evidence_digest, + "record_digest": digest({column.name: _json(getattr(fit, column.name)) + for column in fit.__table__.columns})} if fit is not None else None), + "evaluation_digest": digest({column.name: _json(getattr(evaluated, column.name)) + for column in evaluated.__table__.columns})} + approval = self._production_evidence(candidate, contract, selection.production_approval_id) + return ServingTransitionContract(selection=selection, capability_contract_id=contract.id, + current=self.route_snapshot(current), candidate=self.route_snapshot(candidate), + experiment_routes=experiment_routes, + bindings=bindings, comparisons=evidence, production_approval_fingerprint=approval.evidence_fingerprint) + + def _production_evidence(self, candidate: CapabilityDeployment, contract: CapabilityContract, + approval_id: UUID) -> ProductionExecutionApproval: + approval = self.session.get(ProductionExecutionApproval, approval_id, + with_for_update=True, populate_existing=True) + if (approval is None or approval.status != "approved" or approval.revoked_at is not None + or approval.deployment_candidate_id != candidate.deployment_candidate_id + or approval.capability_contract_id != contract.id + or approval.artifact_set_id != candidate.artifact_set_id + or approval.runtime_profile_id != candidate.runtime_profile_id + or approval.compute_node_id != candidate.compute_node_id): + _fail("SERVING_PRODUCTION_APPROVAL_INVALID", "exact current production execution approval is required") + # Immutable identity fields are already sealed. Mutable qualification/revocation + # fields must remain locked until the containing lifecycle transaction commits. + source = self.session.get(DeploymentCandidate, candidate.deployment_candidate_id, + with_for_update=True, populate_existing=True) + if source is None: + _fail("SERVING_TARGET_MISSING", "candidate provenance disappeared") + self.session.get(RuntimeProbe, source.runtime_probe_id, with_for_update=True, populate_existing=True) + self.session.get(RuntimeCompatibilityAssessment, source.compatibility_assessment_id, + with_for_update=True, populate_existing=True) + self.session.get(ArtifactSet, candidate.artifact_set_id, with_for_update=True, populate_existing=True) + self.session.get(ComputeNode, candidate.compute_node_id, with_for_update=True, populate_existing=True) + for artifact in self.repo.set_artifacts(candidate.artifact_set_id): + self.session.refresh(artifact, with_for_update=True) + # Reuse the existing production validation engine, without its create/promote commits. + settings = get_settings() + serving = ServingService(self.session, settings, ManifestRegistry(settings.config_root), None) + facts, _, probe, _, _ = serving._approval_facts(candidate.deployment_candidate_id, contract, + ProductionApprovalCreate(capability="rag.embedding", contract_version=1, + approved_by=approval.approved_by, reason=approval.reason, + supply_chain_review=SupplyChainReview.model_validate(approval.supply_chain_evidence), + deployment_config=approval.deployment_config)) + if digest(facts) != approval.evidence_fingerprint: + _fail("SERVING_PRODUCTION_APPROVAL_STALE", "production probe or supply-chain evidence changed") + envelope = self.session.scalar(select(CapabilityResourceEnvelope).where( + CapabilityResourceEnvelope.capability_deployment_id == candidate.id + ).with_for_update().execution_options(populate_existing=True)) + if envelope is None or envelope.stale or envelope.runtime_probe_id != probe.id: + _fail("SERVING_ENVELOPE_STALE", "candidate requires its exact current measured envelope") + residency = self.session.scalar(select(ResidencyAllocation).where( + ResidencyAllocation.capability_deployment_id == candidate.id + ).with_for_update().execution_options(populate_existing=True)) + if (residency is None or residency.compute_node_id != candidate.compute_node_id + or residency.accelerator_id != candidate.accelerator_id + or residency.state not in {"cold", "ready", "warm"}): + _fail("SERVING_RESIDENCY_UNAVAILABLE", "candidate has no settled schedulable runtime residency") + return approval + + def validate(self, payload: dict[str, Any]) -> ServingTransitionContract: + expected = ServingTransitionContract.model_validate(payload) + actual = self.prepare(expected.selection) + if actual != expected: + _fail("SERVING_PLAN_STALE", "approved serving identities or affected bindings changed") + return expected + + def _generation(self, contract_id: UUID, expected: int) -> None: + changed = self.session.execute(update(CapabilityContract).where( + CapabilityContract.id == contract_id, CapabilityContract.serving_generation == expected + ).values(serving_generation=expected + 1).returning(CapabilityContract.id)).scalar_one_or_none() + if changed is None: + _fail("SERVING_ROUTE_CHANGED", "stable route generation changed concurrently") + + @staticmethod + def after(contract: ServingTransitionContract, changed_at: str) -> tuple[dict[str, Any], dict[str, Any]]: + old = {**contract.current, "status": "draining", "draining_at": changed_at, "routing_weight": 0} + new = {**contract.candidate, "status": "stable", "channel": "stable", "production": True, + "production_approval_id": str(contract.selection.production_approval_id), + "promoted_at": changed_at, "routing_weight": 100} + return old, new + + def switch(self, payload: dict[str, Any], changed_at: str) -> None: + contract = self.validate(payload) + self._generation(contract.capability_contract_id, contract.selection.expected_route_generation) + current = self._deployment(contract.selection.current_deployment_id) + candidate = self._deployment(contract.selection.candidate_deployment_id) + current.status, current.draining_at = "draining", datetime.fromisoformat(changed_at) + current.routing_weight = 0 + self.session.flush() # Release the one-stable partial unique index before activating the next. + candidate.status, candidate.channel, candidate.production = "stable", "stable", True + candidate.production_approval_id = contract.selection.production_approval_id + candidate.routing_weight = 100 + candidate.promoted_at = datetime.fromisoformat(changed_at) + self.session.flush() + + def installed(self, payload: dict[str, Any], changed_at: str, *, committed: bool = True) -> tuple[ServingTransitionContract, CapabilityDeployment, CapabilityDeployment]: + """Verify exact persisted result for retry/rollback, without switching anything.""" + contract = ServingTransitionContract.model_validate(payload) + route = self.repo.lock_contract_route(contract.capability_contract_id) + expected_generation = contract.selection.expected_route_generation + int(committed) + if route is None or route.serving_generation != expected_generation: + _fail("SERVING_ROLLBACK_CONFLICT", "later stable route generation must be preserved") + self._lock_accelerators(contract.selection) + current = self._deployment(contract.selection.current_deployment_id) + candidate = self._deployment(contract.selection.candidate_deployment_id) + expected = self.after(contract, changed_at) if committed else (contract.current, contract.candidate) + if ((self.route_snapshot(current), self.route_snapshot(candidate)) != expected + or self._bindings(contract.capability_contract_id) != contract.bindings + or self._experiment_routes(candidate.id) != contract.experiment_routes): + _fail("SERVING_ROLLBACK_CONFLICT", "serving state or affected bindings changed after the snapshot") + return contract, current, candidate + + def restore(self, payload: dict[str, Any], changed_at: str, *, committed: bool) -> None: + contract, current, candidate = self.installed(payload, changed_at, committed=committed) + if not committed: + return + route = self.session.get(CapabilityContract, contract.capability_contract_id) + if route is None or current.production_approval_id is None: + _fail("SERVING_ROLLBACK_APPROVAL_MISSING", "prior serving authority is unavailable") + self._production_evidence(current, route, current.production_approval_id) + expected_generation = contract.selection.expected_route_generation + 1 + self._generation(contract.capability_contract_id, expected_generation) + candidate.status = str(contract.candidate["status"]) + candidate.channel = str(contract.candidate["channel"]) + candidate.production = bool(contract.candidate["production"]) + candidate.routing_weight = int(contract.candidate["routing_weight"]) + candidate.production_approval_id = (UUID(contract.candidate["production_approval_id"]) + if contract.candidate["production_approval_id"] else None) + candidate.promoted_at = (datetime.fromisoformat(contract.candidate["promoted_at"]) + if contract.candidate["promoted_at"] else None) + self.session.flush() + current.status = str(contract.current["status"]) + current.routing_weight = int(contract.current["routing_weight"]) + current.draining_at = (datetime.fromisoformat(contract.current["draining_at"]) + if contract.current["draining_at"] else None) + self.session.flush() diff --git a/backend/src/modelforge_api/services/manifest_registry.py b/backend/src/modelforge_api/services/manifest_registry.py index 82f5c98..4b45c08 100644 --- a/backend/src/modelforge_api/services/manifest_registry.py +++ b/backend/src/modelforge_api/services/manifest_registry.py @@ -73,6 +73,10 @@ class ManifestRegistry: ProjectManifest.model_validate(self._read_yaml(path)) for path in sorted((self.config_root / "projects").glob("*.yaml")) ] + project_ids = [item.project.id for item in manifests] + if len(project_ids) != len(set(project_ids)): + raise ValueError("duplicate project manifest identity") + capabilities = self.capabilities() by_identity = {(item.capability, item.version): item for item in capabilities} capability_versions = set(by_identity) diff --git a/backend/src/modelforge_api/services/migration_engine.py b/backend/src/modelforge_api/services/migration_engine.py index 5f09ac1..b16e4dd 100644 --- a/backend/src/modelforge_api/services/migration_engine.py +++ b/backend/src/modelforge_api/services/migration_engine.py @@ -459,11 +459,13 @@ class MigrationEngineService: } return set() - def plans(self) -> list[MigrationPlanResponse]: + def plans(self, limit: int = 200) -> list[MigrationPlanResponse]: return [ self._plan_response(item) for item in self.session.scalars( - select(MigrationPlan).order_by(MigrationPlan.created_at.desc()) + select(MigrationPlan) + .order_by(MigrationPlan.created_at.desc(), MigrationPlan.id.desc()) + .limit(limit) ) ] diff --git a/backend/src/modelforge_api/services/model_changes.py b/backend/src/modelforge_api/services/model_changes.py new file mode 100644 index 0000000..2e44e97 --- /dev/null +++ b/backend/src/modelforge_api/services/model_changes.py @@ -0,0 +1,813 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal, cast +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from modelforge_api.domain.model_changes import ( + ChangeAcquisition, + ChangeImpact, + ChangeModelIdentity, + ChangeProgressStep, + ModelChangeCandidateLink, + ModelChangeCreate, + ModelChangeEvidenceLink, + ModelChangeList, + ModelChangeReject, + ModelChangeResponse, + ModelChangeStatus, +) +from modelforge_api.domain.selected_replacement import ReplacementPreview +from modelforge_api.persistence.model_change import ModelChangeCase +from modelforge_api.persistence.models import ( + ArtifactJob, + ArtifactSet, + Capability, + CapabilityContract, + CapabilityDeployment, + DiscoveryCandidateAssessment, + DownloadPlan, + GatewayRequest, + LifecyclePromotionPlan, + MigrationPlan, + Model, + ModelRevision, + Project, + ProjectBinding, + SelectedModelComparison, + ServiceClient, +) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.evaluation import EvaluationError +from modelforge_api.services.lifecycle import LifecycleError +from modelforge_api.services.selected_replacement import SelectedReplacementService +from modelforge_api.services.serving import ServingError + +ProgressState = Literal["done", "current", "upcoming", "blocked"] + + +class ModelChangeError(ValueError): + def __init__(self, code: str, message: str, status_code: int = 409) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _gate(value: object) -> str: + if not isinstance(value, dict): + return "unknown" + text = " ".join( + str(value.get(key, "")) for key in ("status", "state", "verdict", "result") + ).lower() + if any(word in text for word in ("block", "reject", "deny", "fail", "incompat")): + return "blocked" + if any( + word in text for word in ("approved", "clear", "verified", "compatible", "pass", "safe") + ): + return "clear" + return "review" + + +class ModelChangeService: + """User workflow over authoritative ModelForge evidence; never executes lifecycle work itself.""" + + def __init__(self, session: Session, audit_context: AuditContext) -> None: + self.session = session + self.audit_context = audit_context + + def _error(self, code: str, message: str, status: int = 409) -> ModelChangeError: + return ModelChangeError(code, message, status) + + def _audit( + self, + row: ModelChangeCase, + action: str, + details: dict[str, object] | None = None, + ) -> None: + AuditWriter(self.session, context=self.audit_context).write( + action, + "model_change_case", + str(row.id), + { + "project_binding_id": str(row.project_binding_id), + "current_deployment_id": str(row.current_deployment_id), + "candidate_deployment_id": ( + str(row.candidate_deployment_id) if row.candidate_deployment_id else None + ), + "status": row.status, + "version": row.version, + **(details or {}), + }, + ) + + def _row(self, identifier: UUID, *, lock: bool = False) -> ModelChangeCase: + query = select(ModelChangeCase).where(ModelChangeCase.id == identifier) + if lock: + query = query.with_for_update().execution_options(populate_existing=True) + row = self.session.scalar(query) + if row is None: + raise self._error("MODEL_CHANGE_NOT_FOUND", "Model change case was not found", 404) + return row + + def _binding_context( + self, binding_id: UUID + ) -> tuple[ProjectBinding, Project, CapabilityContract, Capability]: + binding = self.session.get(ProjectBinding, binding_id) + project = self.session.get(Project, binding.project_id) if binding else None + contract = ( + self.session.get(CapabilityContract, binding.capability_contract_id) if binding else None + ) + capability = self.session.get(Capability, contract.capability_id) if contract else None + if ( + binding is None + or binding.deprecated_at is not None + or binding.channel != "stable" + or project is None + or not project.active + or contract is None + or contract.deprecated_at is not None + or capability is None + ): + raise self._error( + "MODEL_CHANGE_BINDING_INVALID", + "The selected project task is not an active stable binding", + 422, + ) + return binding, project, contract, capability + + def _stable_deployment(self, contract_id: UUID) -> CapabilityDeployment | None: + return self.session.scalar( + select(CapabilityDeployment) + .where( + CapabilityDeployment.capability_contract_id == contract_id, + CapabilityDeployment.production.is_(True), + CapabilityDeployment.status == "stable", + CapabilityDeployment.deprecated_at.is_(None), + ) + .order_by( + CapabilityDeployment.promoted_at.desc(), + CapabilityDeployment.created_at.desc(), + ) + .limit(1) + ) + + def _identity( + self, + *, + model_id: UUID | None = None, + revision_id: UUID | None = None, + artifact_set_id: UUID | None = None, + deployment_id: UUID | None = None, + ) -> ChangeModelIdentity | None: + deployment = self.session.get(CapabilityDeployment, deployment_id) if deployment_id else None + if deployment is not None: + artifact_set_id = deployment.artifact_set_id + artifact = self.session.get(ArtifactSet, artifact_set_id) if artifact_set_id else None + if artifact is not None: + revision_id = artifact.revision_id + revision = self.session.get(ModelRevision, revision_id) if revision_id else None + if revision is not None: + model_id = revision.model_id + model = self.session.get(Model, model_id) if model_id else None + if not any((deployment, artifact, revision, model)): + return None + return ChangeModelIdentity( + model_id=model.id if model else model_id, + display_name=model.display_name if model else None, + revision_id=revision.id if revision else revision_id, + resolved_commit_sha=revision.resolved_commit_sha if revision else None, + artifact_set_id=artifact.id if artifact else artifact_set_id, + deployment_id=deployment.id if deployment else deployment_id, + ) + + def _current_identity(self, row: ModelChangeCase) -> ChangeModelIdentity: + identity = self._identity(deployment_id=row.current_deployment_id) + if identity is None: + raise self._error( + "MODEL_CHANGE_CURRENT_IDENTITY_MISSING", + "The exact model identity behind the current deployment is incomplete", + ) + return identity + + def _candidate_identity(self, row: ModelChangeCase) -> ChangeModelIdentity | None: + return self._identity( + model_id=row.candidate_model_id, + revision_id=row.candidate_revision_id, + artifact_set_id=row.candidate_artifact_set_id, + deployment_id=row.candidate_deployment_id, + ) + + def _acquisition(self, row: ModelChangeCase) -> ChangeAcquisition: + assessment = ( + self.session.get(DiscoveryCandidateAssessment, row.discovery_assessment_id) + if row.discovery_assessment_id + else None + ) + plan = self.session.get(DownloadPlan, row.download_plan_id) if row.download_plan_id else None + job = self.session.get(ArtifactJob, row.artifact_job_id) if row.artifact_job_id else None + storage_bytes = plan.total_size_bytes if plan else None + if storage_bytes is None and row.candidate_artifact_set_id: + artifact = self.session.get(ArtifactSet, row.candidate_artifact_set_id) + storage_bytes = artifact.total_size_bytes if artifact else None + fit = "unknown" + if assessment is not None and isinstance(assessment.gpu_fit, dict): + fit = str( + assessment.gpu_fit.get("fit") + or assessment.gpu_fit.get("status") + or assessment.gpu_fit.get("verdict") + or "unknown" + ) + return ChangeAcquisition( + discovery_assessment_id=assessment.id if assessment else None, + security_gate=_gate(assessment.security_state) if assessment else "unknown", + license_gate=_gate(assessment.license_state) if assessment else "unknown", + hardware_fit=fit, + storage_bytes=storage_bytes, + download_plan_id=plan.id if plan else None, + artifact_job_id=job.id if job else None, + download_status=job.status if job else (plan.status if plan else None), + ) + + def _successful_project_request_after( + self, binding_id: UUID, deployment_id: UUID, since: datetime | None + ) -> bool: + clients = list( + self.session.scalars( + select(ServiceClient.id).where( + ServiceClient.project_binding_id == binding_id, + ServiceClient.status == "active", + ServiceClient.disabled_at.is_(None), + ) + ) + ) + if not clients: + return False + query = select(GatewayRequest.id).where( + GatewayRequest.service_client_id.in_(clients), + GatewayRequest.capability_deployment_id == deployment_id, + GatewayRequest.status == "completed", + GatewayRequest.finished_at.is_not(None), + ) + if since is not None: + query = query.where(GatewayRequest.finished_at >= since) + return ( + self.session.scalar(query.order_by(GatewayRequest.finished_at.desc()).limit(1)) + is not None + ) + + def _preview(self, row: ModelChangeCase) -> ReplacementPreview | None: + if row.selected_comparison_id is None: + return None + try: + return SelectedReplacementService(self.session).preview(row.selected_comparison_id) + except (EvaluationError, LifecycleError, ServingError): + # The detailed subsystem remains authoritative. A stale preview simply keeps this + # user projection from claiming readiness until the linked evidence is refreshed. + return None + + def _derived_status(self, row: ModelChangeCase) -> ModelChangeStatus: + if row.closed_at is not None and row.status in {"rolled_back", "rejected"}: + return cast(ModelChangeStatus, row.status) + binding, _project, contract, _capability = self._binding_context(row.project_binding_id) + lifecycle = ( + self.session.get(LifecyclePromotionPlan, row.lifecycle_plan_id) + if row.lifecycle_plan_id + else None + ) + if lifecycle is not None and lifecycle.status == "ROLLED_BACK": + return "rolled_back" + stable = self._stable_deployment(contract.id) + if row.candidate_deployment_id and stable and stable.id == row.candidate_deployment_id: + since = stable.promoted_at or row.activated_at or row.updated_at + if self._successful_project_request_after(binding.id, stable.id, since): + return "checked" + return "active" + if lifecycle is not None and lifecycle.status == "COMMITTED": + return "active" + if lifecycle is not None and lifecycle.status == "APPROVED": + return "ready_to_activate" + comparison = ( + self.session.get(SelectedModelComparison, row.selected_comparison_id) + if row.selected_comparison_id + else None + ) + if comparison is not None: + if comparison.status == "completed": + preview = self._preview(row) + if preview is not None and not preview.same_embedding_space: + return "migration_required" + if preview is not None and preview.selection is not None: + return "ready_to_activate" + return "comparison_available" + if comparison.status in {"pending", "running", "cancel_requested"}: + return "tested" + if row.candidate_deployment_id: + return "ready_to_test" + job = self.session.get(ArtifactJob, row.artifact_job_id) if row.artifact_job_id else None + if job is not None and job.status == "completed": + return "downloaded" + plan = self.session.get(DownloadPlan, row.download_plan_id) if row.download_plan_id else None + if plan is not None and plan.status == "completed": + return "downloaded" + assessment = ( + self.session.get(DiscoveryCandidateAssessment, row.discovery_assessment_id) + if row.discovery_assessment_id + else None + ) + if assessment is not None and assessment.status.lower() not in { + "blocked", + "rejected", + "failed", + }: + return "source_checked" + if any((row.candidate_model_id, row.candidate_revision_id, row.candidate_artifact_set_id)): + return "found" + return "searching" + + def _impact(self, row: ModelChangeCase) -> ChangeImpact: + _binding, project, _contract, _capability = self._binding_context(row.project_binding_id) + affected = [project.name] + blockers: list[str] = [] + migration_required = False + migration_kind = "none" + rollback_available = bool(row.candidate_deployment_id) + preview = self._preview(row) + if preview is not None: + affected = list(dict.fromkeys(item.project_name for item in preview.affected_bindings)) or affected + blockers = list(dict.fromkeys(item.message for item in preview.blockers)) + migration_required = not preview.same_embedding_space + migration_kind = "reindex" if migration_required else "none" + rollback_available = preview.current_deployment_id is not None + migration = ( + self.session.get(MigrationPlan, row.migration_plan_id) if row.migration_plan_id else None + ) + if migration is not None: + migration_kind = str(migration.migration_class).lower() + migration_required = migration_kind not in {"transparent", "none"} + lifecycle = ( + self.session.get(LifecyclePromotionPlan, row.lifecycle_plan_id) + if row.lifecycle_plan_id + else None + ) + downtime = False + application_change = False + if lifecycle is not None: + details = lifecycle.impact_analysis if isinstance(lifecycle.impact_analysis, dict) else {} + downtime = bool(details.get("downtime_expected", details.get("downtime", False))) + application_change = str(lifecycle.migration_class).lower() == "schema_breaking" + rollback_available = bool(lifecycle.rollback_target_ref) + if lifecycle.project_consumers: + affected = list(dict.fromkeys([*affected, *lifecycle.project_consumers])) + return ChangeImpact( + affected_projects=affected, + affected_project_count=len(affected), + application_change_required=application_change, + migration_required=migration_required, + migration_kind=migration_kind, + downtime_expected=downtime, + rollback_available=rollback_available, + blockers=blockers, + ) + + def _next_action(self, status: ModelChangeStatus) -> str: + return { + "searching": "Kandidaat zoeken", + "found": "Bron controleren", + "source_checked": "Downloaden voor test", + "downloaded": "Klaarzetten om te testen", + "ready_to_test": "Test uitvoeren", + "tested": "Vergelijk met huidig model", + "comparison_available": "Voorbereiden om te wisselen", + "migration_required": "Herindexering of migratie voorbereiden", + "ready_to_activate": "Veilig activeren", + "active": "Gezondheid controleren", + "checked": "Geen actie nodig", + "rolled_back": "Afgerond — teruggerold", + "rejected": "Afgerond — afgewezen", + }[status] + + def _progress(self, status: ModelChangeStatus) -> list[ChangeProgressStep]: + labels = [ + ("candidate", "Kandidaat"), + ("source", "Broncontrole"), + ("download", "Download"), + ("test", "Test"), + ("compare", "Vergelijking"), + ("impact", "Impact & migratie"), + ("activate", "Activeren"), + ("verify", "Controle"), + ] + rank = { + "searching": 0, + "found": 0, + "source_checked": 1, + "downloaded": 2, + "ready_to_test": 3, + "tested": 4, + "comparison_available": 5, + "migration_required": 5, + "ready_to_activate": 6, + "active": 7, + "checked": 8, + "rolled_back": 8, + "rejected": 8, + }[status] + steps: list[ChangeProgressStep] = [] + for index, (key, label) in enumerate(labels): + state: ProgressState = ( + "done" + if index < rank + else "current" + if index == rank and rank < len(labels) + else "upcoming" + ) + if status == "migration_required" and key == "impact": + state = "blocked" + steps.append(ChangeProgressStep(key=key, label=label, state=state)) + return steps + + def _response(self, row: ModelChangeCase) -> ModelChangeResponse: + binding, project, contract, capability = self._binding_context(row.project_binding_id) + status = self._derived_status(row) + return ModelChangeResponse( + id=row.id, + project_binding_id=binding.id, + project_id=project.id, + project_key=project.key, + project_name=project.name, + capability=f"{capability.key}@{contract.version}", + status=status, + stored_status=cast(ModelChangeStatus, row.status), + status_reason=row.status_reason, + version=row.version, + current=self._current_identity(row), + candidate=self._candidate_identity(row), + acquisition=self._acquisition(row), + selected_comparison_id=row.selected_comparison_id, + migration_plan_id=row.migration_plan_id, + lifecycle_plan_id=row.lifecycle_plan_id, + impact=self._impact(row), + next_action=self._next_action(status), + progress=self._progress(status), + created_by=row.created_by, + updated_by=row.updated_by, + activated_at=row.activated_at, + checked_at=row.checked_at, + closed_at=row.closed_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + def _remember_derived_status(self, row: ModelChangeCase) -> None: + status = self._derived_status(row) + if status != row.status: + row.status = status + row.status_reason = self._next_action(status) + + def create(self, request: ModelChangeCreate) -> ModelChangeResponse: + binding, project, contract, capability = self._binding_context(request.project_binding_id) + self.session.scalar( + select(ProjectBinding).where(ProjectBinding.id == binding.id).with_for_update() + ) + existing = self.session.scalar( + select(ModelChangeCase).where( + ModelChangeCase.project_binding_id == binding.id, + ModelChangeCase.closed_at.is_(None), + ) + ) + if existing is not None: + return self._response(existing) + current = self._stable_deployment(contract.id) + if current is None: + raise self._error( + "MODEL_CHANGE_CURRENT_UNAVAILABLE", + "No healthy stable model deployment is available for this project task", + 422, + ) + row = ModelChangeCase( + project_binding_id=binding.id, + current_deployment_id=current.id, + status="searching", + status_reason=request.reason, + version=1, + created_by=self.audit_context.actor_id, + updated_by=self.audit_context.actor_id, + context={ + "project_id": str(project.id), + "project_key": project.key, + "capability": f"{capability.key}@{contract.version}", + "reason": request.reason, + }, + ) + self.session.add(row) + try: + self.session.flush() + self._audit(row, "MODEL_CHANGE_CASE_CREATED") + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise self._error( + "MODEL_CHANGE_ALREADY_OPEN", + "Another model change is already open for this project task", + ) from exc + self.session.refresh(row) + return self._response(row) + + def get(self, identifier: UUID) -> ModelChangeResponse: + return self._response(self._row(identifier)) + + def list( + self, + *, + project_key: str | None = None, + status: str | None = None, + project_binding_id: UUID | None = None, + open_only: bool = False, + limit: int = 100, + offset: int = 0, + ) -> ModelChangeList: + if not 1 <= limit <= 200: + raise self._error("MODEL_CHANGE_PAGE_INVALID", "Limit must be between 1 and 200", 422) + if offset < 0: + raise self._error("MODEL_CHANGE_OFFSET_INVALID", "Offset must be zero or greater", 422) + query = select(ModelChangeCase) + if project_key: + query = ( + query.join(ProjectBinding, ProjectBinding.id == ModelChangeCase.project_binding_id) + .join(Project, Project.id == ProjectBinding.project_id) + .where(Project.key == project_key) + ) + if status: + query = query.where(ModelChangeCase.status == status) + if project_binding_id is not None: + query = query.where(ModelChangeCase.project_binding_id == project_binding_id) + if open_only: + query = query.where(ModelChangeCase.closed_at.is_(None)) + total = self.session.scalar(select(func.count()).select_from(query.subquery())) or 0 + rows = self.session.scalars( + query.order_by(ModelChangeCase.updated_at.desc(), ModelChangeCase.id.asc()) + .offset(offset) + .limit(limit) + ).all() + return ModelChangeList(items=[self._response(row) for row in rows], total=total) + + def link_candidate( + self, identifier: UUID, request: ModelChangeCandidateLink + ) -> ModelChangeResponse: + row = self._row(identifier, lock=True) + if row.closed_at is not None: + raise self._error("MODEL_CHANGE_CLOSED", "This model change is already closed") + if row.version != request.expected_version: + raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before editing it") + _binding, _project, contract, _capability = self._binding_context(row.project_binding_id) + deployment = ( + self.session.get(CapabilityDeployment, request.candidate_deployment_id) + if request.candidate_deployment_id + else None + ) + if deployment is not None and ( + deployment.capability_contract_id != contract.id + or deployment.id == row.current_deployment_id + or deployment.production + or deployment.channel not in {"experiment", "lab", "candidate"} + or deployment.status not in {"candidate", "approved", "lab_ready"} + or deployment.deprecated_at is not None + ): + raise self._error( + "MODEL_CHANGE_CANDIDATE_DEPLOYMENT_INVALID", + "The candidate deployment is not a safe non-production target for this project task", + 422, + ) + + artifact_set_id = request.candidate_artifact_set_id or ( + deployment.artifact_set_id if deployment else None + ) + plan = self.session.get(DownloadPlan, request.download_plan_id) if request.download_plan_id else None + if plan is not None: + if artifact_set_id is not None and plan.artifact_set_id != artifact_set_id: + raise self._error( + "MODEL_CHANGE_DOWNLOAD_MISMATCH", "Download plan belongs to another candidate", 422 + ) + artifact_set_id = plan.artifact_set_id + job = self.session.get(ArtifactJob, request.artifact_job_id) if request.artifact_job_id else None + if job is not None: + if plan is None: + plan = self.session.get(DownloadPlan, job.plan_id) + if plan is None or job.plan_id != plan.id or ( + artifact_set_id is not None and plan.artifact_set_id != artifact_set_id + ): + raise self._error( + "MODEL_CHANGE_JOB_MISMATCH", "Download job belongs to another candidate", 422 + ) + artifact_set_id = plan.artifact_set_id + + artifact = self.session.get(ArtifactSet, artifact_set_id) if artifact_set_id else None + if artifact_set_id and artifact is None: + raise self._error("MODEL_CHANGE_ARTIFACT_NOT_FOUND", "Candidate artifact set was not found", 404) + revision_id = request.candidate_revision_id or (artifact.revision_id if artifact else None) + revision = self.session.get(ModelRevision, revision_id) if revision_id else None + if revision_id and revision is None: + raise self._error("MODEL_CHANGE_REVISION_NOT_FOUND", "Candidate version was not found", 404) + model_id = request.candidate_model_id or (revision.model_id if revision else None) + model = self.session.get(Model, model_id) if model_id else None + if model_id and model is None: + raise self._error("MODEL_CHANGE_MODEL_NOT_FOUND", "Candidate model was not found", 404) + if revision and model_id and revision.model_id != model_id: + raise self._error( + "MODEL_CHANGE_IDENTITY_MISMATCH", "Candidate version belongs to another model", 422 + ) + if artifact and revision_id and artifact.revision_id != revision_id: + raise self._error( + "MODEL_CHANGE_IDENTITY_MISMATCH", "Candidate files belong to another version", 422 + ) + + assessment = ( + self.session.get(DiscoveryCandidateAssessment, request.discovery_assessment_id) + if request.discovery_assessment_id + else None + ) + if request.discovery_assessment_id and assessment is None: + raise self._error( + "MODEL_CHANGE_ASSESSMENT_NOT_FOUND", "Candidate source review was not found", 404 + ) + if assessment is not None: + if assessment.model_id is not None and model_id is not None and assessment.model_id != model_id: + raise self._error( + "MODEL_CHANGE_ASSESSMENT_MISMATCH", "Source review belongs to another model", 422 + ) + if revision is not None and assessment.resolved_commit_sha != revision.resolved_commit_sha: + raise self._error( + "MODEL_CHANGE_ASSESSMENT_MISMATCH", + "Source review belongs to another exact version", + 422, + ) + + row.candidate_model_id = model_id + row.candidate_revision_id = revision_id + row.candidate_artifact_set_id = artifact_set_id + row.candidate_deployment_id = ( + deployment.id if deployment else request.candidate_deployment_id or row.candidate_deployment_id + ) + row.discovery_assessment_id = ( + assessment.id if assessment else request.discovery_assessment_id or row.discovery_assessment_id + ) + row.download_plan_id = plan.id if plan else request.download_plan_id or row.download_plan_id + row.artifact_job_id = job.id if job else request.artifact_job_id or row.artifact_job_id + row.version += 1 + row.updated_by = self.audit_context.actor_id + self._remember_derived_status(row) + self._audit(row, "MODEL_CHANGE_CANDIDATE_LINKED") + self.session.commit() + self.session.refresh(row) + return self._response(row) + + def link_evidence( + self, identifier: UUID, request: ModelChangeEvidenceLink + ) -> ModelChangeResponse: + row = self._row(identifier, lock=True) + if row.closed_at is not None: + raise self._error("MODEL_CHANGE_CLOSED", "This model change is already closed") + if row.version != request.expected_version: + raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before editing it") + binding, project, _contract, _capability = self._binding_context(row.project_binding_id) + comparison = ( + self.session.get(SelectedModelComparison, request.selected_comparison_id) + if request.selected_comparison_id + else None + ) + if request.selected_comparison_id: + if comparison is None: + raise self._error("MODEL_CHANGE_COMPARISON_NOT_FOUND", "Comparison was not found", 404) + if ( + comparison.project_binding_id != binding.id + or comparison.current_deployment_id != row.current_deployment_id + or row.candidate_deployment_id is None + or comparison.candidate_deployment_id != row.candidate_deployment_id + ): + raise self._error( + "MODEL_CHANGE_COMPARISON_MISMATCH", + "Comparison does not belong to this project and exact current/candidate pair", + 422, + ) + + migration = ( + self.session.get(MigrationPlan, request.migration_plan_id) + if request.migration_plan_id + else None + ) + if request.migration_plan_id: + if migration is None: + raise self._error("MODEL_CHANGE_MIGRATION_NOT_FOUND", "Migration plan was not found", 404) + if migration.project_binding_id != binding.id or migration.project_id != project.id: + raise self._error( + "MODEL_CHANGE_MIGRATION_MISMATCH", "Migration plan belongs to another project task", 422 + ) + if row.candidate_deployment_id: + candidate = self.session.get(CapabilityDeployment, row.candidate_deployment_id) + if ( + candidate + and candidate.embedding_space_id + and migration.target_space_id != candidate.embedding_space_id + ): + raise self._error( + "MODEL_CHANGE_MIGRATION_MISMATCH", + "Migration target does not match the candidate embedding space", + 422, + ) + + lifecycle = ( + self.session.get(LifecyclePromotionPlan, request.lifecycle_plan_id) + if request.lifecycle_plan_id + else None + ) + if request.lifecycle_plan_id: + if lifecycle is None: + raise self._error("MODEL_CHANGE_LIFECYCLE_NOT_FOUND", "Activation plan was not found", 404) + if ( + row.candidate_deployment_id is None + or lifecycle.candidate_deployment_id != row.candidate_deployment_id + ): + raise self._error( + "MODEL_CHANGE_LIFECYCLE_MISMATCH", "Activation plan belongs to another candidate", 422 + ) + if ( + str(project.id) not in {str(value) for value in lifecycle.project_consumers} + and project.name not in lifecycle.project_consumers + ): + impacted = lifecycle.impact_analysis if isinstance(lifecycle.impact_analysis, dict) else {} + project_ids = impacted.get("project_ids", []) + project_refs = ( + {str(value) for value in project_ids} if isinstance(project_ids, list) else set() + ) + if project_refs and str(project.id) not in project_refs: + raise self._error( + "MODEL_CHANGE_LIFECYCLE_MISMATCH", + "Activation plan does not affect this project", + 422, + ) + + if comparison is not None: + row.selected_comparison_id = comparison.id + if migration is not None: + row.migration_plan_id = migration.id + if lifecycle is not None: + row.lifecycle_plan_id = lifecycle.id + row.version += 1 + row.updated_by = self.audit_context.actor_id + self._remember_derived_status(row) + self._audit(row, "MODEL_CHANGE_EVIDENCE_LINKED") + self.session.commit() + self.session.refresh(row) + return self._response(row) + + def refresh(self, identifier: UUID, expected_version: int) -> ModelChangeResponse: + row = self._row(identifier, lock=True) + if row.version != expected_version: + raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before updating status") + before = row.status + derived = self._derived_status(row) + if derived != row.status: + row.status = derived + row.status_reason = self._next_action(derived) + row.version += 1 + row.updated_by = self.audit_context.actor_id + if derived in {"active", "checked"} and row.activated_at is None: + row.activated_at = _now() + if derived == "checked" and row.checked_at is None: + row.checked_at = _now() + if derived == "rolled_back": + row.closed_at = _now() + self._audit(row, "MODEL_CHANGE_STATUS_REFRESHED", {"from": before, "to": derived}) + self.session.commit() + self.session.refresh(row) + return self._response(row) + + def reject(self, identifier: UUID, request: ModelChangeReject) -> ModelChangeResponse: + row = self._row(identifier, lock=True) + if row.version != request.expected_version: + raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before rejecting it") + if row.closed_at is not None: + return self._response(row) + if self._derived_status(row) in {"active", "checked"}: + raise self._error( + "MODEL_CHANGE_ALREADY_ACTIVE", + "An active change must be rolled back through the controlled lifecycle flow", + ) + row.status = "rejected" + row.status_reason = request.reason + row.closed_at = _now() + row.updated_by = self.audit_context.actor_id + row.version += 1 + self._audit(row, "MODEL_CHANGE_CASE_REJECTED", {"reason": request.reason}) + self.session.commit() + self.session.refresh(row) + return self._response(row) diff --git a/backend/src/modelforge_api/services/model_update_polling.py b/backend/src/modelforge_api/services/model_update_polling.py new file mode 100644 index 0000000..be402aa --- /dev/null +++ b/backend/src/modelforge_api/services/model_update_polling.py @@ -0,0 +1,76 @@ +"""Low-volume metadata checks for explicitly selected models; never model execution.""" + +import asyncio +import threading +import uuid +from datetime import UTC, datetime, timedelta + +import structlog +from sqlalchemy import Engine, or_, select +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import ModelUpdateWatch +from modelforge_api.services.acquisition import AcquisitionService +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.model_updates import ModelUpdateService +from modelforge_api.services.registry import RegistryConflict +from modelforge_api.services.secret_slots import SecretAwareHuggingFaceProvider +from modelforge_api.settings import Settings + +logger = structlog.get_logger() + + +class ModelUpdatePollingService: + """One due watch per minute per API process, with database-fenced per-watch admission.""" + + def __init__(self, settings: Settings, engine: Engine) -> None: + self.settings = settings + self.engine = engine + self._stop = threading.Event() + + def stop(self) -> None: + self._stop.set() + + def service(self, session: Session) -> ModelUpdateService: + provider = SecretAwareHuggingFaceProvider(session, self.settings) + acquisition = AcquisitionService( + session, self.settings, provider, + credential_configured=provider.credential_configured, + ) + return ModelUpdateService(session, acquisition, AuditContext( + "automation", "selected-model-update-monitor", str(uuid.uuid4()), + )) + + def run_once(self) -> None: + if self._stop.is_set() or not self.settings.model_update_monitor_enabled: + return + now = datetime.now(UTC) + cutoff = now - timedelta(seconds=self.settings.model_update_check_interval_seconds) + try: + with Session(self.engine) as session: + watch_id = session.scalar(select(ModelUpdateWatch.id).where( + ModelUpdateWatch.enabled.is_(True), + or_(ModelUpdateWatch.last_checked_at.is_(None), + ModelUpdateWatch.last_checked_at <= cutoff), + or_(ModelUpdateWatch.check_until.is_(None), + ModelUpdateWatch.check_until < now), + ).order_by(ModelUpdateWatch.last_checked_at.asc().nulls_first(), + ModelUpdateWatch.id).limit(1)) + if watch_id is None: + return + # The service rechecks due/enabled state in its atomic claim, so another + # API process completing this watch cannot cause an immediate second poll. + self.service(session).check(watch_id, if_checked_before=cutoff) + except RegistryConflict: + # A manual action or another process won admission. Its state remains authoritative. + return + except Exception as error: # noqa: BLE001 - keep the background loop alive; never log credentials + logger.error("model_update_monitor_check_failed", error_type=type(error).__name__) + + async def run(self) -> None: + while not self._stop.is_set(): + await asyncio.to_thread(self.run_once) + for _ in range(60): + if self._stop.is_set(): + return + await asyncio.sleep(1) diff --git a/backend/src/modelforge_api/services/model_updates.py b/backend/src/modelforge_api/services/model_updates.py new file mode 100644 index 0000000..f6eaa7b --- /dev/null +++ b/backend/src/modelforge_api/services/model_updates.py @@ -0,0 +1,195 @@ +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, or_, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from modelforge_api.domain.model_updates import ( + ModelUpdateNoticeResponse, + ModelWatchCreate, + ModelWatchResponse, + ModelWatchUpdate, +) +from modelforge_api.persistence.models import ( + Model, + ModelRevision, + ModelUpdateNotice, + ModelUpdateWatch, +) +from modelforge_api.services.acquisition import AcquisitionService, ProviderBoundaryError +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.registry import RegistryConflict, RegistryNotFound + + +class ModelUpdateService: + def __init__(self, session: Session, acquisition: AcquisitionService, context: AuditContext) -> None: + self.session = session + self.acquisition = acquisition + self.audit = AuditWriter(session, context=context) + self.acquisition.audit = self.audit + + def watches(self, limit: int = 50, offset: int = 0) -> list[ModelWatchResponse]: + return [ModelWatchResponse.model_validate(item) for item in self.session.scalars( + select(ModelUpdateWatch).order_by(ModelUpdateWatch.id).limit(limit).offset(offset) + )] + + def create(self, request: ModelWatchCreate) -> ModelWatchResponse: + model = self.session.get(Model, request.model_id) + revision = self.session.get(ModelRevision, request.baseline_revision_id) + if model is None or revision is None: + raise RegistryNotFound("model or baseline revision not found") + if model.source_type != "huggingface" or revision.model_id != model.id: + raise RegistryConflict("watch requires a Hugging Face model and its own exact revision") + existing = self.session.scalar(select(ModelUpdateWatch).where( + ModelUpdateWatch.model_id == model.id, + ModelUpdateWatch.upstream_ref == request.upstream_ref, + )) + if existing: + if existing.baseline_revision_id != revision.id: + raise RegistryConflict("watch baseline requires an explicit tracking update; review the existing watch") + return ModelWatchResponse.model_validate(existing) + item = ModelUpdateWatch(id=uuid.uuid4(), model_id=model.id, + baseline_revision_id=revision.id, baseline_commit_sha=revision.resolved_commit_sha, + upstream_ref=request.upstream_ref) + # The audit chain lock serializes admission, including the global watch limit. + self.audit.write("MODEL_WATCH_CREATED", "model_update_watch", str(item.id), + {"model_id": str(model.id), "baseline_revision_id": str(revision.id)}) + if (self.session.scalar(select(func.count()).select_from(ModelUpdateWatch)) or 0) >= 100: + self.session.rollback() + raise RegistryConflict("maximum of 100 explicitly selected model watches reached") + self.session.add(item) + try: + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise RegistryConflict("watch already exists; refresh the watch list") from exc + return ModelWatchResponse.model_validate(item) + + def change(self, watch_id: uuid.UUID, request: ModelWatchUpdate) -> ModelWatchResponse: + item = self.session.scalar(select(ModelUpdateWatch).where( + ModelUpdateWatch.id == watch_id).with_for_update().execution_options(populate_existing=True)) + if item is None: + raise RegistryNotFound("model watch not found") + if item.version != request.expected_version or item.baseline_revision_id != request.expected_baseline_revision_id: + raise RegistryConflict("model watch changed; refresh before changing tracking") + revision = self.session.get(ModelRevision, request.baseline_revision_id) + if revision is None: + raise RegistryNotFound("baseline revision not found") + if revision.model_id != item.model_id: + raise RegistryConflict("watch baseline must belong to the same model") + baseline_changed = revision.id != item.baseline_revision_id + until = item.check_until + if until is not None and until.tzinfo is None: + until = until.replace(tzinfo=UTC) + if baseline_changed and item.check_token is not None and until is not None and until > datetime.now(UTC): + raise RegistryConflict("wait for the active check or pause the watch before changing its baseline") + if not baseline_changed and item.enabled == request.enabled: + return ModelWatchResponse.model_validate(item) + previous = {"baseline_revision_id": str(item.baseline_revision_id), + "baseline_commit_sha": item.baseline_commit_sha, "enabled": item.enabled, + "version": item.version} + item.enabled = request.enabled + item.baseline_revision_id = revision.id + item.baseline_commit_sha = revision.resolved_commit_sha + item.version += 1 + if not item.enabled or baseline_changed: + # Any result from the old check must fail its token fence, even after resume. + item.check_token = None + item.check_until = None + if baseline_changed: + item.last_checked_at = None + item.last_error_code = None + self.audit.write("MODEL_WATCH_TRACKING_CHANGED", "model_update_watch", str(item.id), { + "previous": previous, "baseline_revision_id": str(revision.id), + "baseline_commit_sha": item.baseline_commit_sha, "enabled": item.enabled, + "version": item.version, "reason": request.reason.strip(), + }) + self.session.commit() + return ModelWatchResponse.model_validate(item) + + def check(self, watch_id: uuid.UUID, *, if_checked_before: datetime | None = None) -> ModelWatchResponse: + item = self.session.get(ModelUpdateWatch, watch_id) + if item is None: + raise RegistryNotFound("model watch not found") + now = datetime.now(UTC) + token = uuid.uuid4() + claim = update(ModelUpdateWatch).where( + ModelUpdateWatch.id == watch_id, + ModelUpdateWatch.enabled.is_(True), + or_(ModelUpdateWatch.check_until.is_(None), ModelUpdateWatch.check_until < now), + ) + if if_checked_before is not None: + claim = claim.where(or_(ModelUpdateWatch.last_checked_at.is_(None), + ModelUpdateWatch.last_checked_at <= if_checked_before)) + claimed = self.session.execute(claim.values(check_until=now + timedelta(minutes=5), check_token=token) + .execution_options(synchronize_session="fetch").returning(ModelUpdateWatch.id)).first() + if claimed is None: + self.session.rollback() + raise RegistryConflict("model watch is paused, not due, or a check is already in progress") + self.session.commit() + self.session.refresh(item) + if not item.enabled or item.check_token != token: + raise RegistryConflict("model watch check superseded; refresh its status") + model_id, upstream_ref, baseline_sha = item.model_id, item.upstream_ref, item.baseline_commit_sha + error = None + candidate = None + try: + snapshot = self.acquisition.refresh_model(model_id, upstream_ref) + candidate = self.session.scalar(select(ModelRevision).where( + ModelRevision.model_id == model_id, + ModelRevision.resolved_commit_sha == snapshot.resolved_commit_sha, + )) + if candidate is None: + raise RegistryConflict("upstream refresh did not resolve an exact revision") + except ProviderBoundaryError: + error = "upstream_unavailable" + except Exception: + self.session.rollback() + self.session.execute(update(ModelUpdateWatch).where( + ModelUpdateWatch.id == watch_id, ModelUpdateWatch.check_token == token, + ).values(check_token=None, check_until=None, last_checked_at=datetime.now(UTC), + last_error_code="check_failed")) + self.audit.write("MODEL_WATCH_CHECK_FAILED", "model_update_watch", str(watch_id), + {"error_code": "check_failed"}, outcome="blocked") + self.session.commit() + raise + # An expired check cannot overwrite a subsequent check's state or create a stale notice. + finished = self.session.execute(update(ModelUpdateWatch).where( + ModelUpdateWatch.id == watch_id, ModelUpdateWatch.check_token == token, + ).values(check_token=None, check_until=None, last_checked_at=datetime.now(UTC), + last_error_code=error).returning(ModelUpdateWatch.id)).first() + if finished is None: + self.session.rollback() + raise RegistryConflict("model watch check superseded; refresh its status") + if candidate and candidate.resolved_commit_sha != baseline_sha: + existing = self.session.scalar(select(ModelUpdateNotice).where( + ModelUpdateNotice.watch_id == watch_id, + ModelUpdateNotice.candidate_commit_sha == candidate.resolved_commit_sha, + )) + if existing is None: + self.session.add(ModelUpdateNotice(watch_id=watch_id, model_id=model_id, + baseline_commit_sha=baseline_sha, + candidate_revision_id=candidate.id, candidate_commit_sha=candidate.resolved_commit_sha)) + self.audit.write("MODEL_WATCH_CHECKED", "model_update_watch", str(watch_id), + {"error_code": error}, outcome="blocked" if error else "success") + self.session.commit() + self.session.refresh(item) + return ModelWatchResponse.model_validate(item) + + def notices(self, limit: int = 50, offset: int = 0) -> list[ModelUpdateNoticeResponse]: + return [ModelUpdateNoticeResponse.model_validate(item) for item in self.session.scalars( + select(ModelUpdateNotice).order_by(ModelUpdateNotice.discovered_at.desc(), ModelUpdateNotice.id) + .limit(limit).offset(offset) + )] + + def dismiss(self, notice_id: uuid.UUID) -> ModelUpdateNoticeResponse: + item = self.session.scalar(select(ModelUpdateNotice).where( + ModelUpdateNotice.id == notice_id).with_for_update()) + if item is None: + raise RegistryNotFound("model update notice not found") + if item.status != "dismissed": + item.status = "dismissed" + self.audit.write("MODEL_UPDATE_DISMISSED", "model_update_notice", str(item.id), {}) + self.session.commit() + return ModelUpdateNoticeResponse.model_validate(item) diff --git a/backend/src/modelforge_api/services/node_agent.py b/backend/src/modelforge_api/services/node_agent.py index 3ea6646..ead1a89 100644 --- a/backend/src/modelforge_api/services/node_agent.py +++ b/backend/src/modelforge_api/services/node_agent.py @@ -361,6 +361,7 @@ class NodeAgentService: raw: str, *, update_last_used: bool, + lock_node: bool = False, ) -> tuple[NodeCredential, ComputeNode]: credential = self.repository.credential(credential_id) if credential is None or credential.revoked_at is not None: @@ -369,7 +370,11 @@ class NodeAgentService: raise AgentAuthenticationError("invalid node credential") if credential.scope != "node.publish": raise AgentAuthorizationError("node credential scope is not authorized for publishing") - node = self.repository.node(credential.compute_node_id) + node = ( + self.repository.node_for_update(credential.compute_node_id) + if lock_node + else self.repository.node(credential.compute_node_id) + ) if node is None: raise AgentAuthenticationError("credential node no longer exists") if node.decommissioned_at is not None: @@ -396,8 +401,13 @@ class NodeAgentService: ) def authenticate(self, authorization: str | None) -> tuple[NodeCredential, ComputeNode]: - identity, _evidence = self.authenticate_with_evidence(authorization) - return identity + credential_id, raw = self._parse_credential(authorization) + return self._resolve_authenticated_node( + credential_id, + raw, + update_last_used=True, + lock_node=True, + ) def reuse_authentication( self, @@ -415,6 +425,7 @@ class NodeAgentService: credential_id, raw, update_last_used=False, + lock_node=True, ) if node.id != evidence.node_id: raise AgentAuthenticationError("credential node identity changed") @@ -430,8 +441,23 @@ class NodeAgentService: if node.decommissioned_at is not None: raise AgentConflictError("decommissioned compute node cannot publish agent state") + def _locked_publishable_node(self, node: ComputeNode) -> ComputeNode: + """Fence an authenticated agent write against concurrent lifecycle changes. + + Agent routes also take this row lock while revalidating authentication. Keeping the same + node-first lock order here protects direct service callers and makes each state publisher + independently fail closed if its caller holds a stale ORM instance. + """ + current = self.repository.node_for_update(node.id) + if current is None: + raise AgentNotFoundError("compute node no longer exists") + self._assert_not_decommissioned(current) + if not current.enabled: + raise AgentAuthorizationError("compute node is disabled") + return current + def heartbeat(self, node: ComputeNode, request: HeartbeatRequest) -> ObservationAck: - self._assert_not_decommissioned(node) + node = self._locked_publishable_node(node) self._assert_identity(node, request.identity_key) if request.metadata.protocol_version != self.settings.agent_protocol_version: raise AgentConflictError("agent protocol version is incompatible") @@ -450,7 +476,7 @@ class NodeAgentService: return ObservationAck(accepted=True, received_at=now) def publish_inventory(self, node: ComputeNode, report: InventoryReport) -> ObservationAck: - self._assert_not_decommissioned(node) + node = self._locked_publishable_node(node) self._assert_identity(node, report.identity_key) if report.host.identity_key != node.key: raise AgentAuthorizationError("host inventory identity does not match credential node") @@ -517,7 +543,7 @@ class NodeAgentService: return observation.value if observation.availability.value == "known" else None def publish_telemetry(self, node: ComputeNode, report: TelemetryReport) -> ObservationAck: - self._assert_not_decommissioned(node) + node = self._locked_publishable_node(node) self._assert_identity(node, report.identity_key) if report.protocol_version != self.settings.agent_protocol_version: raise AgentConflictError("agent protocol version is incompatible") @@ -640,19 +666,12 @@ class NodeAgentService: if node.decommissioned_at is not None: raise AgentConflictError("decommissioned compute node cannot be updated") values = update.model_dump(exclude_unset=True) - was_enabled = node.enabled + if "enabled" in values: + raise AgentConflictError( + "node enabled state requires an impact preview and typed confirmation" + ) for key, value in values.items(): setattr(node, key, value) - if "enabled" in values and values["enabled"] != was_enabled: - node.liveness_state = ( - NodeLiveness.OFFLINE if values["enabled"] else NodeLiveness.DISABLED - ) - self._audit( - "NODE_ENABLED" if values["enabled"] else "NODE_DISABLED", - "compute_node", - str(node.id), - {}, - ) self.session.commit() return node diff --git a/backend/src/modelforge_api/services/node_decommission.py b/backend/src/modelforge_api/services/node_decommission.py index 3dd22ff..71661b8 100644 --- a/backend/src/modelforge_api/services/node_decommission.py +++ b/backend/src/modelforge_api/services/node_decommission.py @@ -4,7 +4,7 @@ import hashlib import json import uuid from datetime import UTC, datetime -from typing import Any +from typing import Any, Literal from sqlalchemy import delete, func, select, text, update from sqlalchemy.exc import IntegrityError @@ -18,6 +18,13 @@ from modelforge_api.domain.node_decommission import ( NodeDecommissionPreview, NodeDecommissionResult, ) +from modelforge_api.domain.node_lifecycle import ( + NodeLifecycleBlocker, + NodeLifecycleChange, + NodeLifecyclePreview, + NodeLifecyclePreviewRequest, + NodeLifecycleResult, +) from modelforge_api.persistence.models import ( Accelerator, AcceleratorTelemetryLatest, @@ -102,7 +109,7 @@ class NodeDecommissionService: def _node(self, node_id: uuid.UUID, *, lock: bool = False) -> ComputeNode: statement = select(ComputeNode).where(ComputeNode.id == node_id) if lock: - statement = statement.with_for_update() + statement = statement.with_for_update().execution_options(populate_existing=True) node = self.session.scalar(statement) if node is None: raise NodeDecommissionError( @@ -740,6 +747,199 @@ class NodeDecommissionService: dependent_records=dependent_records, ) + def _lifecycle_preview( + self, node: ComputeNode, request: NodeLifecyclePreviewRequest + ) -> NodeLifecyclePreview: + dependency_snapshot = self._snapshot(node) + blockers: list[NodeLifecycleBlocker] = [] + active_credential_ids = sorted( + str(value) + for value in self.session.scalars( + select(NodeCredential.id).where( + NodeCredential.compute_node_id == node.id, + NodeCredential.revoked_at.is_(None), + ) + ) + ) + + if node.decommissioned_at is not None: + blockers.append( + NodeLifecycleBlocker( + code="node_decommissioned", + message="decommissioned node identities are terminal and cannot be enabled or disabled", + record_type="compute_node", + count=1, + resource_ids=[str(node.id)], + ) + ) + elif node.enabled == request.target_enabled: + blockers.append( + NodeLifecycleBlocker( + code="already_in_requested_state", + message="node is already in the requested enabled state", + record_type="compute_node", + count=1, + resource_ids=[str(node.id)], + ) + ) + elif not request.target_enabled: + # A live heartbeat is expected before an administrator disables a node. Every other + # decommission dependency is conservatively treated as a blocker for temporary disable. + blockers.extend( + NodeLifecycleBlocker.model_validate(item.model_dump()) + for item in dependency_snapshot.blockers + if item.code != "node_online" + ) + elif node.observation_source == "remote_agent" and not active_credential_ids: + blockers.append( + NodeLifecycleBlocker( + code="active_node_credential_missing", + message="remote node has no active credential; rotate or re-enroll before enabling", + record_type="node_credential", + count=1, + resource_ids=[], + ) + ) + + digest_payload = { + "base_dependency_digest": dependency_snapshot.dependency_digest, + "node_id": str(node.id), + "target_enabled": request.target_enabled, + "active_credential_ids": active_credential_ids, + "blockers": [item.model_dump(mode="json") for item in blockers], + } + dependency_digest = hashlib.sha256( + json.dumps(digest_payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return NodeLifecyclePreview( + node_id=node.id, + persisted_identity=node.key, + hostname=node.hostname, + display_name=node.display_name or node.hostname, + current_enabled=node.enabled, + current_liveness=node.liveness_state, + target_enabled=request.target_enabled, + node_generation=node.generation, + dependency_digest=dependency_digest, + can_apply=not blockers, + blockers=blockers, + agent_evidence_after_enable=( + "awaiting_fresh_authenticated_heartbeat" + if request.target_enabled and node.observation_source == "remote_agent" + else "unchanged" + ), + ) + + def preview_enabled_change( + self, node_id: uuid.UUID, request: NodeLifecyclePreviewRequest + ) -> NodeLifecyclePreview: + return self._lifecycle_preview(self._node(node_id), request) + + def apply_enabled_change( + self, node_id: uuid.UUID, request: NodeLifecycleChange + ) -> NodeLifecycleResult: + node = self._node(node_id, lock=True) + if node.generation != request.expected_generation: + raise NodeDecommissionError( + 409, + "node_generation_conflict", + "node generation changed after lifecycle preview", + {"expected": request.expected_generation, "actual": node.generation}, + ) + if request.confirmation not in {node.key, node.hostname, node.display_name}: + raise NodeDecommissionError( + 409, + "node_lifecycle_confirmation_mismatch", + "typed confirmation must exactly match the persisted identity, hostname, or display name", + ) + + self._lock_dependency_tables() + preview = self._lifecycle_preview( + node, NodeLifecyclePreviewRequest(target_enabled=request.target_enabled) + ) + if preview.dependency_digest != request.dependency_digest: + raise NodeDecommissionError( + 409, + "node_lifecycle_preview_stale", + "node state or dependencies changed after impact review; generate a fresh preview", + {"preview": preview.model_dump(mode="json")}, + ) + if not preview.can_apply: + raise NodeDecommissionError( + 409, + "node_lifecycle_blocked", + "node lifecycle transition is blocked until the listed dependencies are resolved", + {"blockers": [item.model_dump(mode="json") for item in preview.blockers]}, + ) + + target_liveness: Literal["offline", "disabled"] = ( + "offline" if request.target_enabled else "disabled" + ) + values: dict[str, Any] = { + "enabled": request.target_enabled, + "generation": ComputeNode.generation + 1, + "liveness_state": target_liveness, + } + if request.target_enabled and node.observation_source == "remote_agent": + # Previous protocol/heartbeat facts must not make a re-enabled node look ready. A new + # authenticated, protocol-compatible heartbeat will publish current evidence again. + values.update( + agent_protocol_version=None, + agent_capabilities=[], + agent_version=None, + agent_started_at=None, + last_heartbeat_at=None, + last_connection_error="node enabled; awaiting fresh authenticated heartbeat", + ) + claimed = rows_affected( + self.session.execute( + update(ComputeNode) + .where( + ComputeNode.id == node_id, + ComputeNode.generation == request.expected_generation, + ComputeNode.enabled == preview.current_enabled, + ComputeNode.decommissioned_at.is_(None), + ) + .values(**values) + .execution_options(synchronize_session=False) + ) + ) + if not claimed: + self.session.rollback() + raise NodeDecommissionError( + 409, + "node_generation_conflict", + "node changed while the lifecycle transition was being claimed", + {"expected": request.expected_generation}, + ) + + AuditWriter(self.session, "operator", request.operator).write( + "NODE_ENABLED" if request.target_enabled else "NODE_DISABLED", + "compute_node", + str(node.id), + { + "operator": request.operator, + "reason": request.reason, + "previous_enabled": preview.current_enabled, + "enabled": request.target_enabled, + "generation": request.expected_generation + 1, + "dependency_digest": request.dependency_digest, + "liveness": target_liveness, + }, + ) + self.session.commit() + return NodeLifecycleResult( + node_id=node.id, + enabled=request.target_enabled, + generation=request.expected_generation + 1, + liveness=target_liveness, + agent_evidence_state=( + "awaiting_fresh_authenticated_heartbeat" + if request.target_enabled and node.observation_source == "remote_agent" + else "unchanged" + ), + ) + def preview(self, node_id: uuid.UUID) -> NodeDecommissionPreview: return self._snapshot(self._node(node_id)) diff --git a/backend/src/modelforge_api/services/observability.py b/backend/src/modelforge_api/services/observability.py index 215ef8d..c8d94a7 100644 --- a/backend/src/modelforge_api/services/observability.py +++ b/backend/src/modelforge_api/services/observability.py @@ -14,6 +14,8 @@ from sqlalchemy.orm import Session from modelforge_api.db import rows_affected from modelforge_api.domain.observability import ( + ActiveSchedulerLeaseInventoryResponse, + ActiveSchedulerLeaseResponse, AlertAction, AlertResponse, AlertRuleCreate, @@ -74,6 +76,7 @@ from modelforge_api.persistence.models import ( StorageRoot, StorageVolumeState, ) +from modelforge_api.services.alert_notifications import enqueue_alert_transition from modelforge_api.services.audit import AuditWriter from modelforge_api.settings import Settings, get_settings @@ -1157,6 +1160,133 @@ class ObservabilityService: ) ] + def active_scheduler_leases( + self, + *, + limit: int = 100, + sort_by: Literal["expires_at", "created_at", "priority"] = "expires_at", + order: Literal["asc", "desc"] = "asc", + observed_at: datetime | None = None, + ) -> ActiveSchedulerLeaseInventoryResponse: + """Read current, unexpired scheduler leases without touching reconciliation state.""" + + now = observed_at or _now() + sort_column = { + "expires_at": ServingGpuLease.expires_at, + "created_at": ServingGpuLease.created_at, + "priority": ServingGpuLease.priority, + }[sort_by] + primary_order = sort_column.asc() if order == "asc" else sort_column.desc() + rows = self.session.execute( + select( + ServingGpuLease, + Accelerator.compute_node_id, + GatewayRequest.status, + GatewayRequest.capability_deployment_id, + GatewayRequest.compute_node_id, + ProjectBinding.project_id, + ) + .outerjoin(Accelerator, Accelerator.id == ServingGpuLease.accelerator_id) + .outerjoin(GatewayRequest, GatewayRequest.request_id == ServingGpuLease.request_id) + .outerjoin(ServiceClient, ServiceClient.id == GatewayRequest.service_client_id) + .outerjoin(ProjectBinding, ProjectBinding.id == ServiceClient.project_binding_id) + .where( + ServingGpuLease.state.in_(("pending", "granted", "active", "releasing")), + ServingGpuLease.expires_at > now, + ) + .order_by(primary_order, ServingGpuLease.id.asc()) + .limit(limit + 1) + ).all() + truncated = len(rows) > limit + selected = rows[:limit] + lease_states: dict[str, Literal["PENDING", "GRANTED", "ACTIVE", "RELEASING"]] = { + "pending": "PENDING", + "granted": "GRANTED", + "active": "ACTIVE", + "releasing": "RELEASING", + } + lease_types: dict[ + str, Literal["RESIDENCY_RESERVATION", "REQUEST_EXECUTION", "LOAD_TRANSITION"] + ] = { + "residency_reservation": "RESIDENCY_RESERVATION", + "request_execution": "REQUEST_EXECUTION", + "load_transition": "LOAD_TRANSITION", + } + known_priorities = { + "production", + "interactive", + "background", + "benchmark", + "lab", + "maintenance", + } + def linked_workload_status( + lease: ServingGpuLease, + request_status: str | None, + request_deployment_id: uuid.UUID | None, + request_node_id: uuid.UUID | None, + node_id: uuid.UUID | None, + ) -> Literal["QUEUED", "RUNNING", "UNKNOWN"]: + if ( + request_status is None + or request_deployment_id != lease.capability_deployment_id + or request_node_id is None + or request_node_id != node_id + or node_id is None + ): + return "UNKNOWN" + status = request_status.casefold() + if status == "queued" and lease.state in {"pending", "granted"}: + return "QUEUED" + if status == "running" and lease.state == "active": + return "RUNNING" + return "UNKNOWN" + + inventory = [ + ActiveSchedulerLeaseResponse( + lease_id=lease.id, + project_id=project_id, + node_id=node_id, + accelerator_id=lease.accelerator_id, + deployment_id=lease.capability_deployment_id, + lease_state=lease_states[lease.state], + workload_status=linked_workload_status( + lease, + request_status, + request_deployment_id, + request_node_id, + node_id, + ), + priority=( + lease.priority.upper() + if lease.priority in known_priorities + else "UNKNOWN" + ), + lease_type=lease_types.get(lease.lease_type, "UNKNOWN"), + reserved_vram_bytes=lease.reserved_vram_bytes, + materialized_vram_bytes=lease.materialized_vram_bytes, + created_at=_aware(lease.created_at), + acquired_at=_aware(lease.acquired_at) if lease.acquired_at else None, + heartbeat_at=_aware(lease.heartbeat_at) if lease.heartbeat_at else None, + expires_at=_aware(lease.expires_at), + ) + for ( + lease, + node_id, + request_status, + request_deployment_id, + request_node_id, + project_id, + ) in selected + ] + return ActiveSchedulerLeaseInventoryResponse( + observed_at=now, + completeness="TRUNCATED" if truncated else "COMPLETE", + limit=limit, + returned_count=len(inventory), + leases=inventory, + ) + def capacity_trend(self, node_id: uuid.UUID, hours: int = 24) -> TrendResponse: start = _now() - timedelta(hours=hours) rows = list( @@ -1732,18 +1862,19 @@ class ObservabilityService: ) self.session.add(alert) self.session.flush() - self.session.add( - AlertHistoryEvent( - alert_id=alert.id, - from_state=None, - to_state=initial.value, - actor="observability-engine", - reason="Signal condition detected", - evidence=signal["details"], - occurred_at=now, - ) + event = AlertHistoryEvent( + alert_id=alert.id, + from_state=None, + to_state=initial.value, + actor="observability-engine", + reason="Signal condition detected", + evidence=signal["details"], + occurred_at=now, ) + self.session.add(event) + self.session.flush() if initial is AlertState.FIRING: + enqueue_alert_transition(self.session, self.settings, alert, event) self._correlate_incident(alert, signal, now) return alert.last_seen_at = now @@ -1817,17 +1948,19 @@ class ObservabilityService: alert.resolved_at = now rule = self.session.get(AlertRuleRevision, alert.rule_id) alert.cooldown_until = now + timedelta(seconds=rule.cooldown_seconds if rule else 0) - self.session.add( - AlertHistoryEvent( - alert_id=alert.id, - from_state=previous, - to_state=state.value, - actor=actor, - reason=reason, - evidence=evidence, - occurred_at=now, - ) + event = AlertHistoryEvent( + alert_id=alert.id, + from_state=previous, + to_state=state.value, + actor=actor, + reason=reason, + evidence=evidence, + occurred_at=now, ) + self.session.add(event) + if state in {AlertState.FIRING, AlertState.RESOLVED}: + self.session.flush() + enqueue_alert_transition(self.session, self.settings, alert, event) def _suppressed_until( self, rule: AlertRuleRevision, signal: dict[str, Any], now: datetime diff --git a/backend/src/modelforge_api/services/observability_polling.py b/backend/src/modelforge_api/services/observability_polling.py index c038377..3e80d29 100644 --- a/backend/src/modelforge_api/services/observability_polling.py +++ b/backend/src/modelforge_api/services/observability_polling.py @@ -9,6 +9,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from modelforge_api.domain.observability import metrics +from modelforge_api.services.alert_notifications import dispatch_pending from modelforge_api.services.observability import ObservabilityService from modelforge_api.settings import Settings @@ -30,11 +31,20 @@ class ObservabilityPollingService: try: with Session(self.engine) as session: service = ObservabilityService(session, self.settings) - service.ensure_defaults() - service.collect_capacity() - service.evaluate_slos() - service.evaluate_alerts() - service.apply_retention() + evaluation_enabled = ( + self.settings.observability_monitor_enabled + or self.settings.alert_notifications_enabled + ) + if evaluation_enabled: + service.ensure_defaults() + if self.settings.observability_monitor_enabled: + service.collect_capacity() + service.evaluate_slos() + if evaluation_enabled: + service.evaluate_alerts() + if self.settings.observability_monitor_enabled: + service.apply_retention() + dispatch_pending(session, self.settings) metrics.gauge("modelforge_observability_degraded", {}, 0) except (SQLAlchemyError, OSError, RuntimeError, ValueError) as error: metrics.gauge("modelforge_observability_degraded", {}, 1) diff --git a/backend/src/modelforge_api/services/operator_identities.py b/backend/src/modelforge_api/services/operator_identities.py new file mode 100644 index 0000000..3670825 --- /dev/null +++ b/backend/src/modelforge_api/services/operator_identities.py @@ -0,0 +1,254 @@ +"""Online personal-key lifecycle, isolated from deployment and machine identities.""" +from __future__ import annotations + +import hashlib +import re +import secrets +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from typing import cast + +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Principal, PrincipalRole +from modelforge_api.db import rows_affected +from modelforge_api.domain.managed_identity import ( + DeploymentIdentityMetadata, + ManagedCredentialMetadata, + ManagedIdentityChange, + ManagedIdentityCreate, + ManagedIdentityIssued, + ManagedIdentityList, + ManagedIdentityMetadata, + ManagedIdentityResult, + ManagedIdentityRevoke, + ManagedIdentityRotate, + Role, +) +from modelforge_api.domain.operator_identity import parse_operator_identities +from modelforge_api.persistence.models import ( + ManagedOperatorCredential, + ManagedOperatorCredentialActivation, + ManagedOperatorIdentity, + ManagedOperatorState, +) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.settings import Settings + + +class ManagedIdentityError(RuntimeError): + def __init__(self, status_code: int, code: str, message: str) -> None: + super().__init__(message) + self.status_code, self.code, self.message = status_code, code, message + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value + + +def _usable(credential: ManagedOperatorCredential, identity: ManagedOperatorIdentity, + activated: bool) -> bool: + return (identity.enabled and activated and credential.revoked_at is None + and _aware(credential.expires_at) > datetime.now(UTC)) + + +def authenticate_managed_operator(session: Session, token: str) -> Principal: + if not re.fullmatch(r"mfopm_[A-Za-z0-9_-]{43}", token): + raise ManagedIdentityError(401, "MANAGED_CREDENTIAL_INVALID", "invalid operator credential") + credential = session.scalar(select(ManagedOperatorCredential).where( + ManagedOperatorCredential.token_sha256 == hashlib.sha256(token.encode()).hexdigest(), + )) + identity = session.get(ManagedOperatorIdentity, credential.identity_slug) if credential else None + activation = session.get(ManagedOperatorCredentialActivation, credential.id) if credential else None + if credential is None or identity is None or not _usable(credential, identity, activation is not None): + raise ManagedIdentityError(401, "MANAGED_CREDENTIAL_INVALID", "invalid operator credential") + credential.last_used_at = datetime.now(UTC) + return Principal( + subject=f"managed:{identity.slug}", role=PrincipalRole(identity.role), + authentication_method="managed_operator_key", credential_id=str(credential.id), + expires_at=_aware(credential.expires_at), + ) + + +class ManagedIdentityService: + def __init__(self, session: Session, principal: Principal, correlation_id: str) -> None: + self.session, self.principal = session, principal + self.audit = AuditWriter(session, context=AuditContext( + "operator", principal.subject, correlation_id, + )) + + def _admin(self) -> None: + if self.principal.role != PrincipalRole.ADMIN: + raise ManagedIdentityError(403, "MANAGED_ADMIN_REQUIRED", "administrator role required") + + @contextmanager + def _mutation(self) -> Iterator[ManagedOperatorState]: + self._admin() + try: + # Updating the singleton serializes every administration transaction, including two + # administrators removing one another. It also locks SQLite's test write transaction. + claimed = self.session.execute(update(ManagedOperatorState).where( + ManagedOperatorState.singleton_id == 1, + ).values(revision=ManagedOperatorState.revision)) + if rows_affected(claimed) != 1: + raise ManagedIdentityError(503, "MANAGED_IDENTITY_STATE_MISSING", "identity state unavailable") + self.session.expire_all() + state = self.session.get(ManagedOperatorState, 1) + if state is None: + raise ManagedIdentityError(503, "MANAGED_IDENTITY_STATE_MISSING", "identity state unavailable") + if self.principal.authentication_method == "managed_operator_key": + try: + credential_id = uuid.UUID(self.principal.credential_id or "") + except ValueError as exc: + raise ManagedIdentityError(401, "MANAGED_CREDENTIAL_INVALID", "invalid operator credential") from exc + credential = self.session.get(ManagedOperatorCredential, credential_id) + identity = self.session.get(ManagedOperatorIdentity, credential.identity_slug) if credential else None + active = self.session.get(ManagedOperatorCredentialActivation, credential_id) + if (credential is None or identity is None or identity.role != "admin" + or self.principal.subject != f"managed:{identity.slug}" + or not _usable(credential, identity, active is not None)): + raise ManagedIdentityError(403, "MANAGED_ADMIN_NO_LONGER_AUTHORIZED", "administrator authorization changed") + yield state + self.session.flush() + usable_admin = self.session.scalar(select(ManagedOperatorIdentity.slug).join( + ManagedOperatorCredential, + ManagedOperatorCredential.identity_slug == ManagedOperatorIdentity.slug, + ).join(ManagedOperatorCredentialActivation, + ManagedOperatorCredentialActivation.credential_id == ManagedOperatorCredential.id).where( + ManagedOperatorIdentity.role == "admin", ManagedOperatorIdentity.enabled.is_(True), + ManagedOperatorCredential.revoked_at.is_(None), + ManagedOperatorCredential.expires_at > datetime.now(UTC), + ).limit(1)) + if usable_admin is None: + raise ManagedIdentityError(409, "LAST_MANAGED_ADMIN", "retain a usable managed administrator; recovery requires an external administrator to issue a new admin key") + state.revision += 1 + self.session.commit() + except Exception: + self.session.rollback() + raise + + def _identity(self, slug: str, version: int) -> ManagedOperatorIdentity: + identity = self.session.get(ManagedOperatorIdentity, slug) + if identity is None: + raise ManagedIdentityError(404, "MANAGED_IDENTITY_NOT_FOUND", "managed identity not found") + if identity.version != version: + raise ManagedIdentityError(409, "MANAGED_IDENTITY_VERSION_CONFLICT", "identity changed; reload before retrying") + return identity + + def _metadata(self, identity: ManagedOperatorIdentity) -> ManagedIdentityMetadata: + credentials = list(self.session.scalars(select(ManagedOperatorCredential).where( + ManagedOperatorCredential.identity_slug == identity.slug, + ).order_by(ManagedOperatorCredential.created_at.desc(), ManagedOperatorCredential.id.desc()).limit(10))) + active = set(self.session.scalars(select(ManagedOperatorCredentialActivation.credential_id).where( + ManagedOperatorCredentialActivation.credential_id.in_([item.id for item in credentials]), + ))) + return ManagedIdentityMetadata( + subject=f"managed:{identity.slug}", slug=identity.slug, role=cast(Role, identity.role), + enabled=identity.enabled, version=identity.version, created_at=_aware(identity.created_at), + credentials=[ManagedCredentialMetadata( + id=str(item.id), expires_at=_aware(item.expires_at), + revoked_at=_aware(item.revoked_at) if item.revoked_at else None, + superseded_at=_aware(item.superseded_at) if item.superseded_at else None, + last_used_at=_aware(item.last_used_at) if item.last_used_at else None, + activated=item.id in active, usable=_usable(item, identity, item.id in active), + ) for item in credentials], + ) + + def list(self, settings: Settings | None = None) -> ManagedIdentityList: + self._admin() + state = self.session.get(ManagedOperatorState, 1) + if state is None: + raise ManagedIdentityError(503, "MANAGED_IDENTITY_STATE_MISSING", "identity state unavailable") + identities = list(self.session.scalars(select(ManagedOperatorIdentity).order_by(ManagedOperatorIdentity.slug).limit(100))) + document = settings.operator_identities if settings else None + deployed = parse_operator_identities(document.get_secret_value()) if document else () + return ManagedIdentityList(revision=state.revision, initialized=state.revision > 0, + identities=[self._metadata(item) for item in identities], + deployment_identities=[DeploymentIdentityMetadata( + subject=item.subject, role=item.role, credential_id=item.credential_id, + expires_at=item.expires_at, + ) for item in deployed]) + + def _issue(self, identity: ManagedOperatorIdentity, days: int) -> str: + token = "mfopm_" + secrets.token_urlsafe(32) + now = datetime.now(UTC) + credential = ManagedOperatorCredential( + id=uuid.uuid4(), identity_slug=identity.slug, + token_sha256=hashlib.sha256(token.encode()).hexdigest(), + expires_at=now + timedelta(days=days), created_at=now, + ) + self.session.add(credential) + self.session.flush() + self.session.add(ManagedOperatorCredentialActivation(credential_id=credential.id, activated_at=now)) + return token + + def _audit(self, action: str, identity: ManagedOperatorIdentity, reason: str) -> None: + self.audit.write(action, "managed_operator_identity", f"managed:{identity.slug}", { + "role": identity.role, "enabled": identity.enabled, "version": identity.version, + "initiating_credential_id": self.principal.credential_id, "reason": reason, + }) + + def create(self, request: ManagedIdentityCreate) -> ManagedIdentityIssued: + with self._mutation() as state: + if state.revision != request.expected_revision: + raise ManagedIdentityError(409, "MANAGED_IDENTITY_VERSION_CONFLICT", "registry changed; reload before retrying") + if self.session.get(ManagedOperatorIdentity, request.slug): + raise ManagedIdentityError(409, "MANAGED_IDENTITY_EXISTS", "managed subject already exists") + count = self.session.scalar(select(func.count()).select_from(ManagedOperatorIdentity)) or 0 + if count >= 100: + raise ManagedIdentityError(409, "MANAGED_IDENTITY_LIMIT", "maximum 100 managed identities") + if count == 0 and request.role != "admin": + raise ManagedIdentityError(409, "FIRST_MANAGED_ADMIN_REQUIRED", "first managed identity must be an administrator") + identity = ManagedOperatorIdentity(slug=request.slug, role=request.role, + enabled=True, version=1, created_at=datetime.now(UTC)) + self.session.add(identity) + self.session.flush() + token = self._issue(identity, request.expires_in_days) + self._audit("managed_identity.created", identity, request.reason) + result = ManagedIdentityIssued(revision=state.revision + 1, identity=self._metadata(identity), one_time_key=token) + return result + + def rotate(self, slug: str, request: ManagedIdentityRotate) -> ManagedIdentityIssued: + with self._mutation() as state: + identity = self._identity(slug, request.expected_version) + now = datetime.now(UTC) + credentials = list(self.session.scalars(select(ManagedOperatorCredential).where( + ManagedOperatorCredential.identity_slug == slug, ManagedOperatorCredential.revoked_at.is_(None), + ))) + for credential in credentials: + if credential.superseded_at is not None or request.overlap_seconds == 0: + credential.revoked_at = now + else: + credential.superseded_at = now + credential.expires_at = min(_aware(credential.expires_at), now + timedelta(seconds=request.overlap_seconds)) + self.session.flush() + token = self._issue(identity, request.expires_in_days) + identity.version += 1 + self._audit("managed_identity.rotated", identity, request.reason) + result = ManagedIdentityIssued(revision=state.revision + 1, identity=self._metadata(identity), one_time_key=token) + return result + + def change(self, slug: str, request: ManagedIdentityChange) -> ManagedIdentityResult: + with self._mutation() as state: + identity = self._identity(slug, request.expected_version) + identity.role, identity.enabled = request.role, request.enabled + identity.version += 1 + self._audit("managed_identity.changed", identity, request.reason) + result = ManagedIdentityResult(revision=state.revision + 1, identity=self._metadata(identity)) + return result + + def revoke(self, slug: str, request: ManagedIdentityRevoke) -> ManagedIdentityResult: + with self._mutation() as state: + identity = self._identity(slug, request.expected_version) + if request.confirm_subject != f"managed:{slug}": + raise ManagedIdentityError(422, "MANAGED_IDENTITY_CONFIRMATION_REQUIRED", "type the exact managed subject to revoke all keys") + self.session.execute(update(ManagedOperatorCredential).where( + ManagedOperatorCredential.identity_slug == slug, ManagedOperatorCredential.revoked_at.is_(None), + ).values(revoked_at=datetime.now(UTC))) + identity.version += 1 + self._audit("managed_identity.revoked", identity, request.reason) + result = ManagedIdentityResult(revision=state.revision + 1, identity=self._metadata(identity)) + return result diff --git a/backend/src/modelforge_api/services/operator_password.py b/backend/src/modelforge_api/services/operator_password.py new file mode 100644 index 0000000..7db7cb1 --- /dev/null +++ b/backend/src/modelforge_api/services/operator_password.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import re +import secrets +import time + +HASH_PREFIX = "scrypt$32768$8$1$" +SESSION_SECONDS = 900 + + +def _encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _decode(value: str, length: int) -> bytes: + if not re.fullmatch(r"[A-Za-z0-9_-]+", value): + raise ValueError("Invalid password credential encoding") + result = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + if len(result) != length or _encode(result) != value: + raise ValueError("Invalid password credential encoding") + return result + + +def parse_password_hash(encoded: str) -> tuple[bytes, bytes]: + # Accept only one fixed, bounded work factor; never execute attacker-selected KDF parameters. + if len(encoded) != 83 or not encoded.startswith(HASH_PREFIX): + raise ValueError("Invalid operator password hash") + fields = encoded[len(HASH_PREFIX) :].split("$") + if len(fields) != 2: + raise ValueError("Invalid operator password hash") + return _decode(fields[0], 16), _decode(fields[1], 32) + + +def hash_password(password: str) -> str: + if not 1 <= len(password) <= 1024: + raise ValueError("Password must contain 1 to 1024 characters") + salt = secrets.token_bytes(16) + digest = hashlib.scrypt( + password.encode("utf-8"), salt=salt, n=32768, r=8, p=1, maxmem=64 * 1024 * 1024, dklen=32 + ) + return HASH_PREFIX + _encode(salt) + "$" + _encode(digest) + + +def verify_password(password: str, encoded: str) -> bool: + salt, expected = parse_password_hash(encoded) + if not 1 <= len(password) <= 1024: + return False + actual = hashlib.scrypt( + password.encode("utf-8"), salt=salt, n=32768, r=8, p=1, maxmem=64 * 1024 * 1024, dklen=32 + ) + return hmac.compare_digest(actual, expected) + + +def _session_key(bootstrap: str, password_hash: str) -> bytes: + return hmac.digest( + bootstrap.encode(), b"modelforge-password-session-v1\0" + password_hash.encode(), "sha256" + ) + + +def issue_session(bootstrap: str, password_hash: str, *, now: int | None = None) -> tuple[str, int]: + issued = int(time.time()) if now is None else now + expires = issued + SESSION_SECONDS + body = f"{issued}.{expires}.{_encode(secrets.token_bytes(24))}" + signature = _encode( + hmac.digest(_session_key(bootstrap, password_hash), body.encode(), "sha256") + ) + return f"mfpw_{body}.{signature}", expires + + +def verify_session( + token: str, bootstrap: str, password_hash: str, *, now: int | None = None +) -> bool: + if len(token) > 160 or not token.startswith("mfpw_"): + return False + try: + issued_text, expires_text, nonce, signature = token[5:].split(".") + if not re.fullmatch(r"[0-9]{1,12}", issued_text) or not re.fullmatch( + r"[0-9]{1,12}", expires_text + ): + return False + issued, expires = int(issued_text), int(expires_text) + current = int(time.time()) if now is None else now + if not issued <= current < expires or expires - issued != SESSION_SECONDS: + return False + _decode(nonce, 24) + provided = _decode(signature, 32) + body = f"{issued_text}.{expires_text}.{nonce}" + expected = hmac.digest(_session_key(bootstrap, password_hash), body.encode(), "sha256") + return hmac.compare_digest(provided, expected) + except (ValueError, UnicodeError): + return False diff --git a/backend/src/modelforge_api/services/portfolio_registry.py b/backend/src/modelforge_api/services/portfolio_registry.py new file mode 100644 index 0000000..a3f6f9a --- /dev/null +++ b/backend/src/modelforge_api/services/portfolio_registry.py @@ -0,0 +1,545 @@ +"""Idempotent, secret-free reconciliation of repository inventory into project identities.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from typing import Any, cast + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from modelforge_api.domain.portfolio import ( + PROJECT_LINK_CLASSIFICATIONS, + PortfolioCodeEvidence, + PortfolioInventoryResponse, + PortfolioReconcileRequest, + PortfolioReconcileResponse, + PortfolioRepositoryInput, + PortfolioRepositoryView, + PortfolioRuntimeEvidence, +) +from modelforge_api.persistence.models import ( + PortfolioReconciliationRun, + PortfolioRepositoryObservation, + Project, +) +from modelforge_api.services.audit import AuditContext, AuditWriter + + +class PortfolioRegistryError(Exception): + def __init__(self, status_code: int, code: str, message: str) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + + +def _utc(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def snapshot_digest(request: PortfolioReconcileRequest) -> str: + """Hash normalized, order-independent content so replay does not depend on JSON formatting.""" + + payload = request.model_dump(mode="json") + repositories = cast(list[dict[str, Any]], payload["repositories"]) + for repository in repositories: + repository["code_evidence"] = sorted( + repository["code_evidence"], + key=lambda item: (item["path"], item["signal"], tuple(item["capabilities"])), + ) + repository["runtime_evidence"] = sorted( + repository["runtime_evidence"], key=lambda item: item["component"] + ) + for evidence in repository["code_evidence"]: + evidence["capabilities"] = sorted(evidence["capabilities"]) + payload["repositories"] = sorted(repositories, key=lambda item: item["external_repository_id"]) + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +class PortfolioRegistryService: + def __init__(self, session: Session) -> None: + self.session = session + + def inventory(self) -> PortfolioInventoryResponse: + rows = self.session.execute( + select(PortfolioRepositoryObservation, Project) + .outerjoin(Project, Project.id == PortfolioRepositoryObservation.project_id) + .order_by( + PortfolioRepositoryObservation.slug, + PortfolioRepositoryObservation.external_repository_id, + ) + ).all() + return PortfolioInventoryResponse( + repositories=[ + PortfolioRepositoryView( + id=str(repository.id), + project_key=repository.project_key, + project_name=project.name if project is not None else None, + project_id=str(project.id) if project is not None else None, + source_system=repository.source_system, + external_repository_id=repository.external_repository_id, + slug=repository.slug, + default_branch=repository.default_branch, + head_sha=repository.head_sha, + classification=repository.classification, + code_evidence=[ + PortfolioCodeEvidence.model_validate(item) + for item in repository.code_evidence + ], + runtime_evidence=[ + PortfolioRuntimeEvidence.model_validate(item) + for item in repository.runtime_evidence + ], + observed_at=_utc(repository.source_observed_at), + snapshot_id=repository.source_snapshot_id, + snapshot_digest=repository.source_snapshot_digest, + pending_head_sha=repository.pending_head_sha, + pending_default_branch=repository.pending_default_branch, + pending_project_key=repository.pending_project_key, + pending_classification=repository.pending_classification, + pending_code_evidence=[ + PortfolioCodeEvidence.model_validate(item) + for item in (repository.pending_code_evidence or []) + ], + pending_observed_at=( + _utc(repository.pending_observed_at) + if repository.pending_observed_at + else None + ), + pending_snapshot_id=repository.pending_snapshot_id, + pending_snapshot_digest=repository.pending_snapshot_digest, + last_seen_at=_utc(repository.last_seen_at), + stale_at=_utc(repository.stale_at) if repository.stale_at else None, + ) + for repository, project in rows + ] + ) + + def reconcile( + self, request: PortfolioReconcileRequest, audit_context: AuditContext + ) -> PortfolioReconcileResponse: + digest = snapshot_digest(request) + try: + existing_run = self.session.scalar( + select(PortfolioReconciliationRun).where( + PortfolioReconciliationRun.source_system == request.source_system, + PortfolioReconciliationRun.snapshot_id == request.snapshot_id, + ) + ) + if existing_run is not None: + if existing_run.payload_digest != digest: + raise PortfolioRegistryError( + 409, + "PORTFOLIO_SNAPSHOT_CONFLICT", + "snapshot ID was already used with different content", + ) + return self._run_response(existing_run, replayed=True) + + latest_complete = self.session.scalar( + select(func.max(PortfolioReconciliationRun.observed_at)).where( + PortfolioReconciliationRun.source_system == request.source_system, + PortfolioReconciliationRun.complete.is_(True), + PortfolioReconciliationRun.stale_snapshot.is_(False), + ) + ) + observation_time = _utc(request.observed_at) + latest_complete_time = _utc(latest_complete) if latest_complete else None + stale_snapshot = bool( + latest_complete_time + and ( + observation_time < latest_complete_time + or (request.complete and observation_time <= latest_complete_time) + ) + ) + counts = { + "projects_created": 0, + "repositories_created": 0, + "repositories_updated": 0, + "stale_observations": len(request.repositories) if stale_snapshot else 0, + "repositories_marked_stale": 0, + "runtime_observations_updated": 0, + "head_changes_review_required": 0, + "head_changes_accepted": 0, + } + seen_repository_ids = {item.external_repository_id for item in request.repositories} + + for item in request.repositories: + repository = self.session.scalar( + select(PortfolioRepositoryObservation).where( + PortfolioRepositoryObservation.source_system == request.source_system, + PortfolioRepositoryObservation.external_repository_id + == item.external_repository_id, + ) + ) + if item.project_key is not None: + key_owner = self.session.scalar( + select(PortfolioRepositoryObservation).where( + PortfolioRepositoryObservation.source_system == request.source_system, + PortfolioRepositoryObservation.project_key == item.project_key, + PortfolioRepositoryObservation.external_repository_id + != item.external_repository_id, + ) + ) + if key_owner is not None: + raise PortfolioRegistryError( + 409, + "PORTFOLIO_PROJECT_KEY_CONFLICT", + "project key is already proposed for another repository", + ) + project: Project | None = None + if repository is not None and repository.project_id is not None: + project = self.session.get(Project, repository.project_id) + if project is None: + raise PortfolioRegistryError( + 409, + "PORTFOLIO_PROJECT_MISSING", + "repository is linked to a missing project identity", + ) + if item.project_key is not None and project.key != item.project_key: + raise PortfolioRegistryError( + 409, + "PORTFOLIO_IDENTITY_CONFLICT", + "repository identity cannot be reassigned to another project", + ) + + source_is_newer = repository is None or observation_time > _utc( + repository.last_seen_at + ) + if not stale_snapshot and source_is_newer: + if repository is None: + project = ( + self._project_for_observation(item, counts) + if item.classification in PROJECT_LINK_CLASSIFICATIONS + else None + ) + repository = PortfolioRepositoryObservation( + project_id=project.id if project is not None else None, + project_key=item.project_key, + source_system=request.source_system, + external_repository_id=item.external_repository_id, + slug=item.slug, + default_branch=item.default_branch, + head_sha=item.head_sha, + classification=item.classification, + code_evidence=self._code_evidence(item), + runtime_evidence=[], + source_observed_at=observation_time, + source_snapshot_id=request.snapshot_id, + source_snapshot_digest=digest, + pending_head_sha=None, + pending_default_branch=None, + pending_project_key=None, + pending_classification=None, + pending_code_evidence=None, + pending_observed_at=None, + pending_snapshot_id=None, + pending_snapshot_digest=None, + last_seen_at=observation_time, + stale_at=None, + ) + self.session.add(repository) + self.session.flush() + counts["repositories_created"] += 1 + else: + repository.last_seen_at = observation_time + repository.stale_at = None + head_changed = ( + item.head_sha != repository.head_sha + or item.default_branch != repository.default_branch + ) + pending_review = repository.pending_head_sha is not None + if pending_review and item.accept_head_update and not self._pending_matches( + repository, item + ): + raise PortfolioRegistryError( + 409, + "PORTFOLIO_PENDING_HEAD_CONFLICT", + "head acceptance must match the pending repository observation", + ) + if pending_review and not item.accept_head_update: + if head_changed: + self._set_pending_head( + repository, item, request, observation_time, digest + ) + counts["head_changes_review_required"] += 1 + elif head_changed and not item.accept_head_update: + self._set_pending_head( + repository, item, request, observation_time, digest + ) + counts["head_changes_review_required"] += 1 + else: + if ( + repository.project_id is None + and item.classification in PROJECT_LINK_CLASSIFICATIONS + ): + project = self._project_for_observation(item, counts) + repository.project_id = project.id + repository.project_key = item.project_key + if repository.project_id is not None and item.project_key is not None: + linked_project = project or self.session.get( + Project, repository.project_id + ) + if linked_project is None or linked_project.key != item.project_key: + raise PortfolioRegistryError( + 409, + "PORTFOLIO_IDENTITY_CONFLICT", + "repository identity cannot be reassigned to another project", + ) + elif repository.project_id is None: + repository.project_key = item.project_key + if head_changed: + counts["head_changes_accepted"] += 1 + repository.slug = item.slug + repository.default_branch = item.default_branch + repository.head_sha = item.head_sha + repository.classification = item.classification + repository.code_evidence = self._code_evidence(item) + repository.source_observed_at = observation_time + repository.source_snapshot_id = request.snapshot_id + repository.source_snapshot_digest = digest + self._clear_pending_head(repository) + counts["repositories_updated"] += 1 + elif ( + not stale_snapshot + and repository is not None + and ( + observation_time < _utc(repository.last_seen_at) + or not self._source_matches(repository, item) + ) + ): + counts["stale_observations"] += 1 + + if repository is not None: + counts["runtime_observations_updated"] += self._merge_runtime_evidence( + repository, item.runtime_evidence + ) + + if request.complete and not stale_snapshot: + for repository in self.session.scalars( + select(PortfolioRepositoryObservation).where( + PortfolioRepositoryObservation.source_system == request.source_system + ) + ): + if repository.external_repository_id in seen_repository_ids: + continue + repository_time = _utc(repository.last_seen_at) + if observation_time <= repository_time: + if observation_time < repository_time: + counts["stale_observations"] += 1 + continue + if repository.stale_at is None or observation_time > _utc(repository.stale_at): + repository.stale_at = observation_time + counts["repositories_marked_stale"] += 1 + + run = PortfolioReconciliationRun( + source_system=request.source_system, + snapshot_id=request.snapshot_id, + payload_digest=digest, + observed_at=observation_time, + complete=request.complete, + stale_snapshot=stale_snapshot, + **counts, + ) + self.session.add(run) + self.session.flush() + AuditWriter(self.session, context=audit_context).write( + "PORTFOLIO_RECONCILED", + "portfolio_snapshot", + str(run.id), + { + "source_system": run.source_system, + "snapshot_id": run.snapshot_id, + "payload_digest": run.payload_digest, + "complete": run.complete, + "stale_snapshot": run.stale_snapshot, + **counts, + }, + ) + self.session.commit() + return self._run_response(run, replayed=False) + except PortfolioRegistryError: + self.session.rollback() + raise + except IntegrityError as exc: + self.session.rollback() + persisted_run = self.session.scalar( + select(PortfolioReconciliationRun).where( + PortfolioReconciliationRun.source_system == request.source_system, + PortfolioReconciliationRun.snapshot_id == request.snapshot_id, + ) + ) + if persisted_run is not None: + if persisted_run.payload_digest == digest: + return self._run_response(persisted_run, replayed=True) + raise PortfolioRegistryError( + 409, + "PORTFOLIO_SNAPSHOT_CONFLICT", + "snapshot ID was already used with different content", + ) from exc + raise PortfolioRegistryError( + 409, + "PORTFOLIO_IDENTITY_CONFLICT", + "repository or project identity conflicts with existing inventory", + ) from exc + except Exception: + self.session.rollback() + raise + + @staticmethod + def _project_name(item: PortfolioRepositoryInput) -> str: + return item.slug.rsplit("/", 1)[-1].replace("-", " ").replace("_", " ").title() + + def _project_for_observation( + self, item: PortfolioRepositoryInput, counts: dict[str, int] + ) -> Project: + if item.project_key is None: + raise PortfolioRegistryError( + 422, + "PORTFOLIO_PROJECT_KEY_REQUIRED", + "project key is required before linking a local consumer or provider", + ) + project = self.session.scalar(select(Project).where(Project.key == item.project_key)) + if project is None: + project = Project( + key=item.project_key, + name=self._project_name(item), + description=f"Repository inventory for {item.slug}.", + active=True, + ) + self.session.add(project) + self.session.flush() + counts["projects_created"] += 1 + linked_repository = self.session.scalar( + select(PortfolioRepositoryObservation).where( + PortfolioRepositoryObservation.project_id == project.id, + PortfolioRepositoryObservation.external_repository_id + != item.external_repository_id, + ) + ) + if linked_repository is not None: + raise PortfolioRegistryError( + 409, + "PORTFOLIO_PROJECT_ALREADY_LINKED", + "project identity is already linked to another repository", + ) + return project + + @staticmethod + def _code_evidence(item: PortfolioRepositoryInput) -> list[dict[str, Any]]: + payloads = [evidence.model_dump(mode="json") for evidence in item.code_evidence] + for evidence in payloads: + evidence["capabilities"] = sorted(evidence["capabilities"]) + return sorted( + payloads, + key=lambda evidence: ( + evidence["path"], evidence["signal"], tuple(evidence["capabilities"]) + ), + ) + + @staticmethod + def _source_matches( + repository: PortfolioRepositoryObservation, item: PortfolioRepositoryInput + ) -> bool: + return ( + repository.slug == item.slug + and repository.default_branch == item.default_branch + and repository.head_sha == item.head_sha + and repository.classification == item.classification + and repository.code_evidence == PortfolioRegistryService._code_evidence(item) + ) + + @staticmethod + def _clear_pending_head(repository: PortfolioRepositoryObservation) -> None: + repository.pending_head_sha = None + repository.pending_default_branch = None + repository.pending_project_key = None + repository.pending_classification = None + repository.pending_code_evidence = None + repository.pending_observed_at = None + repository.pending_snapshot_id = None + repository.pending_snapshot_digest = None + + @staticmethod + def _set_pending_head( + repository: PortfolioRepositoryObservation, + item: PortfolioRepositoryInput, + request: PortfolioReconcileRequest, + observed_at: datetime, + digest: str, + ) -> None: + repository.pending_head_sha = item.head_sha + repository.pending_default_branch = item.default_branch + repository.pending_project_key = item.project_key + repository.pending_classification = item.classification + repository.pending_code_evidence = PortfolioRegistryService._code_evidence(item) + repository.pending_observed_at = observed_at + repository.pending_snapshot_id = request.snapshot_id + repository.pending_snapshot_digest = digest + + @staticmethod + def _pending_matches( + repository: PortfolioRepositoryObservation, item: PortfolioRepositoryInput + ) -> bool: + return ( + repository.pending_head_sha == item.head_sha + and repository.pending_default_branch == item.default_branch + and repository.pending_project_key == item.project_key + and repository.pending_classification == item.classification + and repository.pending_code_evidence == PortfolioRegistryService._code_evidence(item) + ) + + @staticmethod + def _merge_runtime_evidence( + repository: PortfolioRepositoryObservation, + incoming: list[PortfolioRuntimeEvidence], + ) -> int: + if not incoming: + return 0 + current = { + evidence.component: evidence + for evidence in ( + PortfolioRuntimeEvidence.model_validate(item) + for item in repository.runtime_evidence + ) + } + updated = 0 + for evidence in incoming: + previous = current.get(evidence.component) + if previous is not None and _utc(evidence.observed_at) <= _utc(previous.observed_at): + continue + current[evidence.component] = evidence + updated += 1 + if updated: + repository.runtime_evidence = [ + item.model_dump(mode="json") for item in sorted(current.values(), key=lambda x: x.component) + ] + return updated + + @staticmethod + def _run_response( + run: PortfolioReconciliationRun, *, replayed: bool + ) -> PortfolioReconcileResponse: + return PortfolioReconcileResponse( + source_system=run.source_system, + snapshot_id=run.snapshot_id, + snapshot_digest=run.payload_digest, + observed_at=_utc(run.observed_at), + complete=run.complete, + replayed=replayed, + stale_snapshot=run.stale_snapshot, + projects_created=run.projects_created, + repositories_created=run.repositories_created, + repositories_updated=run.repositories_updated, + stale_observations=run.stale_observations, + repositories_marked_stale=run.repositories_marked_stale, + runtime_observations_updated=run.runtime_observations_updated, + head_changes_review_required=run.head_changes_review_required, + head_changes_accepted=run.head_changes_accepted, + ) diff --git a/backend/src/modelforge_api/services/project_registry.py b/backend/src/modelforge_api/services/project_registry.py index f1488ee..e7cc607 100644 --- a/backend/src/modelforge_api/services/project_registry.py +++ b/backend/src/modelforge_api/services/project_registry.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.orm import Session +from modelforge_api.domain.contracts import CapabilityStability from modelforge_api.persistence.models import ( Capability, CapabilityContract, @@ -15,11 +16,20 @@ from modelforge_api.services.audit import AuditWriter from modelforge_api.services.manifest_registry import ManifestRegistry +@dataclass(frozen=True) +class UnavailableProjectBinding: + project_key: str + capability: str + contract_version: int + declared_stability: CapabilityStability + + @dataclass(frozen=True) class ProjectRegistrySyncResult: projects_created: int bindings_created: int unavailable_contracts: tuple[str, ...] + unavailable_bindings: tuple[UnavailableProjectBinding, ...] def sync_project_registry( @@ -28,13 +38,19 @@ def sync_project_registry( """Materialize generic manifest projects against contracts that exist in the DB. A capability contract is lifecycle-owned and may not exist yet on a fresh control plane. - Such bindings remain in the validated manifest and are reported, never fabricated. - Existing bindings are operator-owned and are not overwritten by startup reconciliation. + Missing contracts are reported, never fabricated. Contracts declared planned or blocked also + remain unbound and are reported with their declared stability. Existing bindings are + operator-owned and are not overwritten or removed by startup reconciliation. """ audit = AuditWriter(session, "system", "project-manifest-sync") projects_created = 0 bindings_created = 0 unavailable: list[str] = [] + unavailable_bindings: list[UnavailableProjectBinding] = [] + capability_stability = { + (item.capability, item.version): item.estate.stability + for item in manifests.capabilities() + } for item in manifests.projects(): project = session.scalar(select(Project).where(Project.key == item.project.id)) if project is None: @@ -66,6 +82,17 @@ def sync_project_registry( if contract is None: unavailable.append(identity) continue + stability = capability_stability[(capability_key, binding.contract_version)] + if stability in {"planned", "blocked"}: + unavailable_bindings.append( + UnavailableProjectBinding( + project_key=item.project.id, + capability=capability_key, + contract_version=binding.contract_version, + declared_stability=stability, + ) + ) + continue existing = session.scalar( select(ProjectBinding).where( ProjectBinding.project_id == project.id, @@ -104,4 +131,14 @@ def sync_project_registry( projects_created=projects_created, bindings_created=bindings_created, unavailable_contracts=tuple(sorted(unavailable)), + unavailable_bindings=tuple( + sorted( + unavailable_bindings, + key=lambda entry: ( + entry.project_key, + entry.capability, + entry.contract_version, + ), + ) + ), ) diff --git a/backend/src/modelforge_api/services/projects.py b/backend/src/modelforge_api/services/projects.py new file mode 100644 index 0000000..0a1cb72 --- /dev/null +++ b/backend/src/modelforge_api/services/projects.py @@ -0,0 +1,237 @@ +"""Persisted, capability-only onboarding; readiness is derived from observed gateway use.""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import timedelta + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload + +from modelforge_api.domain.projects import ( + ProjectCreate, + ProjectRequirementCreate, + ProjectRequirementResponse, + ProjectServingModelResponse, + ProjectWorkspaceResponse, +) +from modelforge_api.persistence.models import ( + ArtifactSet, + Capability, + CapabilityContract, + GatewayRequest, + Model, + ModelRevision, + Project, + ProjectBinding, + ProjectOnboarding, + ServiceClient, +) +from modelforge_api.services.serving import ServingError, ServingService, _aware, _now + + +class ProjectService: + def __init__(self, serving: ServingService) -> None: + self.serving = serving + self.session = serving.session + self.audit = serving.audit + + def _project(self, key: str) -> Project: + project = self.session.scalar(select(Project).where(Project.key == key)) + if project is None: + raise ServingError(404, "PROJECT_NOT_FOUND", "project was not found") + return project + + def create(self, request: ProjectCreate) -> ProjectWorkspaceResponse: + if any(not label or len(label) > 64 for label in request.labels): + raise ServingError(422, "PROJECT_LABEL_INVALID", "labels must contain 1 to 64 characters") + try: + project = Project(key=request.key, name=request.name, description=request.description) + self.session.add(project) + self.session.flush() + self.session.add(ProjectOnboarding( + project_id=project.id, owner=request.owner, environment=request.environment, + labels=sorted(set(request.labels)), created_at=_now(), + )) + self.audit.write("PROJECT_CREATED", "project", str(project.id), { + "project_key": project.key, "environment": request.environment, + }) + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise ServingError(409, "PROJECT_EXISTS", "project slug already exists") from exc + return self.detail(project.key) + + def bind(self, key: str, request: ProjectRequirementCreate) -> ProjectWorkspaceResponse: + project = self._project(key) + if not project.active: + raise ServingError(409, "PROJECT_INACTIVE", "project is inactive") + capability, version = request.capability.rsplit("@", 1) + contract = self.serving.repo.contract(capability, int(version)) + if contract is None: + # Only reviewed versioned manifests can materialize a missing contract. + self.serving.ensure_contract(capability, int(version)) + contract = self.serving.repo.contract(capability, int(version)) + if contract is None or contract.deprecated_at: + raise ServingError(409, "CAPABILITY_UNAVAILABLE", "capability contract is unavailable") + if contract.upgrade_class == "requires_reindex" and request.migration_support != "reindex": + raise ServingError(422, "REINDEX_SUPPORT_REQUIRED", "this contract requires explicit reindex support") + if contract.upgrade_class == "schema_breaking" and request.migration_support != "schema": + raise ServingError(422, "SCHEMA_SUPPORT_REQUIRED", "this contract requires explicit schema migration support") + try: + binding = ProjectBinding( + project_id=project.id, capability_contract_id=contract.id, channel="stable", + priority=request.priority, migration_support=request.migration_support, + ) + self.session.add(binding) + self.session.flush() + self.audit.write("PROJECT_REQUIREMENT_BOUND", "project_binding", str(binding.id), { + "project_key": key, "capability": request.capability, "channel": "stable", + "migration_support": request.migration_support, + }) + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise ServingError(409, "PROJECT_BINDING_EXISTS", "this capability is already bound") from exc + return self.detail(key) + + def list( + self, limit: int | None = None, after_key: str | None = None + ) -> list[ProjectWorkspaceResponse]: + statement = select(Project).options(selectinload(Project.bindings)) + if after_key is not None: + statement = statement.where(Project.key > after_key) + if limit is not None: + statement = statement.limit(limit) + projects = list(self.session.scalars(statement.order_by(Project.key))) + if not projects: + return [] + + onboarding_by_project = { + item.project_id: item + for item in self.session.scalars( + select(ProjectOnboarding).where( + ProjectOnboarding.project_id.in_([project.id for project in projects]) + ) + ) + } + return [ + self._workspace_response( + project, + onboarding_by_project.get(project.id), + sorted(project.bindings, key=lambda binding: (binding.created_at, binding.id.hex)), + ) + for project in projects + ] + + def detail(self, key: str) -> ProjectWorkspaceResponse: + project = self._project(key) + metadata = self.session.get(ProjectOnboarding, project.id) + bindings = list(self.session.scalars(select(ProjectBinding).where( + ProjectBinding.project_id == project.id, + ).order_by(ProjectBinding.created_at, ProjectBinding.id))) + return self._workspace_response(project, metadata, bindings) + + def _workspace_response( + self, + project: Project, + metadata: ProjectOnboarding | None, + bindings: Iterable[ProjectBinding], + ) -> ProjectWorkspaceResponse: + requirements: list[ProjectRequirementResponse] = [] + clients = [] + now = _now() + freshness = now - timedelta(hours=24) + for binding in bindings: + contract = self.session.get(CapabilityContract, binding.capability_contract_id) + capability = self.session.get(Capability, contract.capability_id) if contract else None + if contract is None or capability is None: + raise ServingError(409, "PROJECT_CONTRACT_MISSING", "binding contract is unavailable") + deployment = self.serving.repo.stable_deployment(contract.id) + residency = self.serving.repo.residency(deployment.id) if deployment else None + health = self.serving._health_status(deployment, residency) if deployment and residency else "unavailable" + + artifact_set = self.session.get(ArtifactSet, deployment.artifact_set_id) if deployment else None + revision = self.session.get(ModelRevision, artifact_set.revision_id) if artifact_set else None + model = self.session.get(Model, revision.model_id) if revision else None + serving_model = None + if deployment and artifact_set and revision and model: + serving_model = ProjectServingModelResponse( + deployment_id=deployment.id, + artifact_set_id=artifact_set.id, + model_id=model.id, + model_key=model.key, + display_name=model.display_name, + upstream_source=model.upstream_source, + revision_id=revision.id, + upstream_revision=revision.upstream_revision, + resolved_commit_sha=revision.resolved_commit_sha, + runtime_profile_id=deployment.runtime_profile_id, + compute_node_id=deployment.compute_node_id, + channel=deployment.channel, + production=deployment.production, + artifact_availability=artifact_set.availability, + ) + + connection_times = [] + successes: list[GatewayRequest] = [] + has_credential = False + for client in self.session.scalars(select(ServiceClient).where( + ServiceClient.project_binding_id == binding.id, + ).order_by(ServiceClient.created_at)): + credential = self.serving.repo.active_credential(client.id) + clients.append(self.serving.client_response(client, credential)) + if (credential is None or client.status != "active" or client.disabled_at + or credential.expires_at and _aware(credential.expires_at) <= now): + continue + has_credential = True + if credential.last_used_at and _aware(credential.last_used_at) >= freshness: + connection_times.append(_aware(credential.last_used_at)) + if deployment: + success = self.session.scalar(select(GatewayRequest).where( + GatewayRequest.service_client_id == client.id, + GatewayRequest.capability_deployment_id == deployment.id, + GatewayRequest.status == "completed", + GatewayRequest.decision_evidence["service_credential_id"].as_string() == str(credential.id), + GatewayRequest.created_at >= max(freshness, _aware(credential.created_at)), + ).order_by(GatewayRequest.created_at.desc()).limit(1)) + if success: + successes.append(success) + latest = max(successes, key=lambda item: _aware(item.created_at)) if successes else None + blockers = [] + if not project.active or binding.deprecated_at or contract.deprecated_at: + blockers.append("Project, binding or contract is inactive.") + if binding.channel != "stable": + blockers.append("This planning binding requires its separate experiment workflow.") + if not deployment or health not in {"healthy", "ready_on_demand"}: + blockers.append("No healthy stable deployment is available.") + if deployment and serving_model is None: + blockers.append("Stable deployment model provenance is incomplete.") + if not has_credential: + blockers.append("Issue an active scoped credential.") + if not connection_times: + blockers.append("Verify the credential through the gateway (evidence expires after 24 hours).") + if latest is None: + blockers.append("Complete a capability request with the current credential and deployment.") + requirements.append(ProjectRequirementResponse( + id=binding.id, capability=f"{capability.key}@{contract.version}", + channel=binding.channel, priority=binding.priority, + migration_support=binding.migration_support, deprecated=bool(binding.deprecated_at), + deployment_id=deployment.id if deployment else None, deployment_health=health, + serving_model=serving_model, credential_ready=has_credential, + connectivity_checked_at=max(connection_times) if connection_times else None, + capability_checked_at=latest.created_at if latest else None, + capability_check_request_id=str(latest.request_id) if latest else None, + state="ready" if not blockers else "needs_attention", blockers=blockers, + )) + return ProjectWorkspaceResponse( + id=project.id, key=project.key, name=project.name, description=project.description, + active=project.active, owner=metadata.owner if metadata else None, + environment=metadata.environment if metadata else None, + labels=metadata.labels if metadata else [], source="operator" if metadata else "manifest", + # Engineering readiness cannot grant a production lifecycle approval. + production_eligible=None, + state="ready" if requirements and all(item.state == "ready" for item in requirements) else "needs_attention", + requirements=requirements, clients=clients, + ) diff --git a/backend/src/modelforge_api/services/recovery.py b/backend/src/modelforge_api/services/recovery.py index 5d5fed1..cf3f2b5 100644 --- a/backend/src/modelforge_api/services/recovery.py +++ b/backend/src/modelforge_api/services/recovery.py @@ -15,10 +15,11 @@ import shutil import time import uuid from collections.abc import Iterable, Iterator +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from functools import lru_cache from pathlib import Path -from typing import Any, cast +from typing import Any, Literal, cast from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session @@ -49,6 +50,8 @@ from modelforge_api.domain.recovery import ( RecoveryPolicyResponse, RecoveryReadiness, RecoveryReadinessEntry, + RecoveryRetentionDecision, + RecoveryRetentionPreview, RestoreAdvanceRequest, RestoreMode, RestoreOperationEventResponse, @@ -91,7 +94,10 @@ from modelforge_api.services.recovery_crypto import ( BackupCryptoError, ) from modelforge_api.services.recovery_fingerprint import ( + CREATIVE_RESTORE_ERROR, CURRENT_TRUTH_TABLES, + SELECTED_COMPARISON_ACTIVE_STATUSES, + SELECTED_COMPARISON_RESTORE_ERROR, audit_fingerprint_differences_are_compatible, diff_fingerprints, fingerprint_compatibility, @@ -184,6 +190,29 @@ class RecoveryError(Exception): self.details = details or {} +@dataclass(frozen=True) +class _RetentionDecision: + record: BackupSet + decision: Literal["RETAIN", "EXPIRE"] + reason: Literal[ + "ACTIVE_RESTORE_IN_PROGRESS", + "LEGAL_HOLD", + "MILESTONE", + "MINIMUM_VERIFIED_BACKUPS", + "RETENTION_NOT_REACHED", + "RETENTION_ELIGIBLE", + ] + + +@dataclass(frozen=True) +class _RetentionPlan: + observed_at: datetime + policy: RecoveryPolicyRevision | None + active_restore_in_progress: bool + protected_count: int + decisions: tuple[_RetentionDecision, ...] + + class RecoveryService: def __init__( self, @@ -377,6 +406,24 @@ class RecoveryService: 500, RecoveryFailureCode.PATH_NOT_ALLOWED.value, str(error) ) from error + def _plaintext_staging_path(self, operation: str) -> Path: + """Keep decrypted working files off the immutable backup volume.""" + + try: + root = normalise_root(self.settings.backup_restore_root) + except (RecoveryPathError, OSError) as error: + raise RecoveryError( + 500, RecoveryFailureCode.PATH_NOT_ALLOWED.value, str(error) + ) from error + backup_root = self._backup_root() + if root == backup_root or backup_root in root.parents: + raise RecoveryError( + 409, + RecoveryFailureCode.PATH_NOT_ALLOWED.value, + "plaintext staging must be outside the backup root", + ) + return root / f"database-{operation}-{uuid.uuid4().hex}.dump" + def estimate_capacity(self) -> BackupCapacityEstimate: """Backups check headroom before writing; they never fill a disk to find out.""" @@ -1021,7 +1068,7 @@ class RecoveryService: raise RecoveryError( 409, RecoveryFailureCode.ENCRYPTION_KEY_UNAVAILABLE.value, "no key available" ) - staging = path.with_name(path.name + f".verify-{uuid.uuid4().hex[:8]}") + staging = self._plaintext_staging_path("verify") try: cipher.decrypt_file(path, staging) expected = (entry.get("dependency_refs") or {}).get("plaintext_sha256") @@ -1063,12 +1110,16 @@ class RecoveryService: self.session.commit() return len(interrupted) - def apply_retention(self, observed_at: datetime | None = None) -> dict[str, int]: - """Expire aged backups, but never drop the last verified recovery point.""" + def _retention_plan( + self, + observed_at: datetime | None = None, + *, + seed_missing_policy: bool, + include_active_restore_decisions: bool, + ) -> _RetentionPlan: + """Evaluate the active retention rules without changing backup state or the filesystem.""" now = observed_at or _now() - expired = 0 - protected = 0 verified = [ item for item in self.session.scalars( @@ -1077,15 +1128,69 @@ class RecoveryService: .order_by(BackupSet.verified_at.desc()) ) ] - if self.session.scalar( + active_restore_in_progress = self.session.scalar( select(RestoreOperation) .where(RestoreOperation.state.in_(ACTIVE_RESTORE_STATES)) .limit(1) - ): - return {"expired": 0, "protected": len(verified), "skipped_active_restore": 1} - policy = self._database_policy() + ) is not None + if active_restore_in_progress and not include_active_restore_decisions: + # Preserve apply_retention's original short circuit: no policy lookup, default seeding, + # candidate scan, state change or audit event occurs during a restore. + return _RetentionPlan( + observed_at=now, + policy=None, + active_restore_in_progress=True, + protected_count=len(verified), + decisions=(), + ) + if active_restore_in_progress: + policy = self.session.scalar( + select(RecoveryPolicyRevision).where( + RecoveryPolicyRevision.key == "control-plane.database", + RecoveryPolicyRevision.active.is_(True), + ) + ) + if policy is None: + raise RecoveryError( + 500, "recovery_policy_missing", "no active database backup policy" + ) + active_decisions = tuple( + _RetentionDecision(record, "RETAIN", "ACTIVE_RESTORE_IN_PROGRESS") + for record in self.session.scalars( + select(BackupSet).where( + BackupSet.state.in_( + ( + BackupState.VERIFIED.value, + BackupState.CREATED.value, + BackupState.FAILED.value, + ) + ) + ) + ) + ) + return _RetentionPlan( + observed_at=now, + policy=policy, + active_restore_in_progress=True, + protected_count=len(verified), + decisions=active_decisions, + ) + policy = self.session.scalar( + select(RecoveryPolicyRevision).where( + RecoveryPolicyRevision.key == "control-plane.database", + RecoveryPolicyRevision.active.is_(True), + ) + ) + if policy is None and seed_missing_policy: + # Keep the established run behavior. A preview passes False so its GET path can never + # seed policy rows as a side effect. + policy = self._database_policy() + if policy is None: + raise RecoveryError(500, "recovery_policy_missing", "no active database backup policy") keep = max(policy.minimum_verified_backups, 1) protected_ids = {item.id for item in verified[:keep]} + protected = 0 + decisions: list[_RetentionDecision] = [] for record in self.session.scalars( select(BackupSet).where( BackupSet.state.in_( @@ -1093,25 +1198,103 @@ class RecoveryService: ) ) ): - expires_at = _aware(record.expires_at) - if record.legal_hold or record.milestone: + if active_restore_in_progress: + decisions.append( + _RetentionDecision(record, "RETAIN", "ACTIVE_RESTORE_IN_PROGRESS") + ) + elif record.legal_hold: protected += 1 - continue - if record.id in protected_ids: + decisions.append(_RetentionDecision(record, "RETAIN", "LEGAL_HOLD")) + elif record.milestone: protected += 1 + decisions.append(_RetentionDecision(record, "RETAIN", "MILESTONE")) + elif record.id in protected_ids: + protected += 1 + decisions.append(_RetentionDecision(record, "RETAIN", "MINIMUM_VERIFIED_BACKUPS")) + else: + expires_at = _aware(record.expires_at) + if expires_at is None or expires_at > now: + decisions.append( + _RetentionDecision(record, "RETAIN", "RETENTION_NOT_REACHED") + ) + else: + decisions.append(_RetentionDecision(record, "EXPIRE", "RETENTION_ELIGIBLE")) + return _RetentionPlan( + observed_at=now, + policy=policy, + active_restore_in_progress=active_restore_in_progress, + protected_count=protected, + decisions=tuple(decisions), + ) + + def preview_retention(self, observed_at: datetime | None = None) -> RecoveryRetentionPreview: + """Return the exact retention decisions without writing rows, audit events or files.""" + + # GET preview requests must not flush unrelated pending ORM changes in their session. + with self.session.no_autoflush: + plan = self._retention_plan( + observed_at, + seed_missing_policy=False, + include_active_restore_decisions=True, + ) + if plan.policy is None: # pragma: no cover - read-only previews fail before this point + raise RecoveryError(500, "recovery_policy_missing", "no active database backup policy") + decisions = sorted(plan.decisions, key=lambda item: item.record.backup_id) + public_decisions = [ + RecoveryRetentionDecision( + backup_id=item.record.backup_id, + current_state=BackupState(item.record.state), + decision=item.decision, + reason=item.reason, + expires_at=_aware(item.record.expires_at), + ) + for item in decisions + ] + return RecoveryRetentionPreview( + observed_at=plan.observed_at, + policy_key=plan.policy.key, + policy_revision=plan.policy.revision, + minimum_verified_backups=max(plan.policy.minimum_verified_backups, 1), + active_restore_in_progress=plan.active_restore_in_progress, + retained_backup_ids=[ + item.backup_id for item in public_decisions if item.decision == "RETAIN" + ], + expiring_backup_ids=[ + item.backup_id for item in public_decisions if item.decision == "EXPIRE" + ], + decisions=public_decisions, + ) + + def apply_retention(self, observed_at: datetime | None = None) -> dict[str, int]: + """Expire aged backups, but never drop the last verified recovery point.""" + + plan = self._retention_plan( + observed_at, + seed_missing_policy=True, + include_active_restore_decisions=False, + ) + if plan.active_restore_in_progress: + return { + "expired": 0, + "protected": plan.protected_count, + "skipped_active_restore": 1, + } + if plan.policy is None: # pragma: no cover - non-active runs seed the default policy + raise RecoveryError(500, "recovery_policy_missing", "no active database backup policy") + expired = 0 + for item in plan.decisions: + if item.decision != "EXPIRE": continue - if expires_at is None or expires_at > now: - continue - record.state = BackupState.EXPIRED.value + item.record.state = BackupState.EXPIRED.value expired += 1 self.audit.write( "BACKUP_EXPIRED", AUDIT_RESOURCE, - str(record.id), - {"backup_id": record.backup_id, "expired_at": now.isoformat()}, + str(item.record.id), + {"backup_id": item.record.backup_id, "expired_at": plan.observed_at.isoformat()}, ) self.session.commit() - return {"expired": expired, "protected": protected, "skipped_active_restore": 0} + return {"expired": expired, "protected": plan.protected_count, "skipped_active_restore": 0} def backups(self, limit: int = 100) -> list[BackupSetResponse]: rows = self.session.scalars( @@ -1654,7 +1837,7 @@ class RecoveryService: RecoveryFailureCode.ENCRYPTION_KEY_UNAVAILABLE.value, "no decryption key", ) - staging = stored.with_name(stored.name + f".restore-{uuid.uuid4().hex[:8]}") + staging = self._plaintext_staging_path("restore") cipher.decrypt_file(stored, staging) expected = (entry.dependency_refs or {}).get("plaintext_sha256") if expected and sha256_file(staging) != expected: @@ -1851,7 +2034,7 @@ class RecoveryService: "coalesce(canonical_payload, '') as canonical_payload, " "(canonical_payload is null)::text as canonical_payload_is_null " "from audit_events " - f"{cursor_sql}order by sequence, id limit {page_size}" + f"{cursor_sql}order by audit_events.sequence, audit_events.id limit {page_size}" ) rows = self.engine.query_rows( target, @@ -1863,7 +2046,8 @@ class RecoveryService: for row in rows: details = json.loads(row["details"]) if not isinstance(details, dict): - raise ValueError("restored audit details are not an object") + # This is corrupt restored content, not a caller type contract violation. + raise ValueError("restored audit details are not an object") # noqa: TRY004 sequence = int(row["sequence"]) event_id = str(uuid.UUID(row["id"])) yield AuditEventRecord( @@ -2003,8 +2187,65 @@ class RecoveryService: ) return event_hash + def _reconcile_creative_allocations(self, target: PostgresTarget) -> int: + exists = self.engine.scalar(target, + "select count(*) from information_schema.tables where table_schema='public' " + "and table_name='external_creative_allocations'") + if exists.strip() in {"0", ""}: + return 0 + affected = int(self.engine.scalar(target, + "select count(*) from external_creative_allocations where state in ('queued', 'granted')") or 0) + self.engine.scalar(target, + "update external_creative_allocations set state='uncertain', " # noqa: S608 - fixed module constant and literal status allowlist + f"decision_code='{CREATIVE_RESTORE_ERROR}' where state in ('queued', 'granted')") + return affected + + def _creative_allocation_residuals(self, target: PostgresTarget) -> int: + exists = self.engine.scalar(target, + "select count(*) from information_schema.tables where table_schema='public' " + "and table_name='external_creative_allocations'") + if exists.strip() in {"0", ""}: + return 0 + return int(self.engine.scalar(target, + "select count(*) from external_creative_allocations where state in ('queued', 'granted')") or 0) + + def _reconcile_selected_comparisons(self, target: PostgresTarget) -> dict[str, int]: + exists = self.engine.scalar(target, + "select count(*) from information_schema.tables where table_schema='public' " + "and table_name='selected_model_comparisons'") + if exists.strip() in {"0", ""}: + return {"interrupted": 0, "claims_cleared": 0} + active = _quoted(tuple(sorted(SELECTED_COMPARISON_ACTIVE_STATUSES))) + interrupted = int(self.engine.scalar(target, + f"select count(*) from selected_model_comparisons where status in ({active})") or 0) # noqa: S608 + claims = int(self.engine.scalar(target, + "select count(*) from selected_model_comparisons " + "where claim_token_hash is not null or claim_expires_at is not null") or 0) + # Preserve all timestamps/pins/results. This does not assert that an external worker stopped. + # The same deterministic transformation is committed in each source restore_projection. + self.engine.scalar(target, + f"update selected_model_comparisons set " # noqa: S608 - module-level literal status/code allowlists + f"version=case when status in ({active}) then version+1 else version end, " + f"error_code=case when status in ({active}) then '{SELECTED_COMPARISON_RESTORE_ERROR}' else error_code end, " + f"status=case when status in ({active}) then 'interrupted' else status end, " + "claim_token_hash=NULL, claim_expires_at=NULL " + f"where status in ({active}) or claim_token_hash is not null or claim_expires_at is not null") + return {"interrupted": interrupted, "claims_cleared": claims} + + def _selected_comparison_residuals(self, target: PostgresTarget) -> int: + exists = self.engine.scalar(target, + "select count(*) from information_schema.tables where table_schema='public' " + "and table_name='selected_model_comparisons'") + if exists.strip() in {"0", ""}: + return 0 + active = _quoted(tuple(sorted(SELECTED_COMPARISON_ACTIVE_STATUSES))) + return int(self.engine.scalar(target, + "select count(*) from selected_model_comparisons " # noqa: S608 - literal status allowlist + f"where status in ({active}) or claim_token_hash is not null " + "or claim_expires_at is not null") or 0) + def _reconcile_restored(self, record: RestoreOperation, plan: RestorePlan) -> dict[str, Any]: - """Clear stale current-truth rows exactly once; history is never rewritten.""" + """Clear stale truth and apply the exact comparison interruption projection once.""" target = parse_target(plan.database_destination) # Fail before the idempotency marker lookup and before any target mutation. A valid marker @@ -2045,6 +2286,21 @@ class RecoveryService: self.engine.scalar(target, f"delete from {table}") # noqa: S608 cleared[table] = int(before or 0) + # Watch history is authoritative, but an in-flight metadata check is not restored truth. + watch_table = self.engine.scalar(target, + "select count(*) from information_schema.tables where table_schema='public' " + "and table_name='model_update_watches'") + if watch_table.strip() not in {"0", ""}: + before = self.engine.scalar(target, + "select count(*) from model_update_watches where check_token is not null or check_until is not null") + self.engine.scalar(target, + "update model_update_watches set check_token=NULL, check_until=NULL") + cleared["model_update_watch_claims"] = int(before or 0) + + comparisons = self._reconcile_selected_comparisons(target) + cleared["selected_comparison_claims"] = comparisons["claims_cleared"] + cleared["creative_allocations_interrupted"] = self._reconcile_creative_allocations(target) + # Lifecycle and migration operations use a `stage` vocabulary; anything that is not a # terminal stage was in flight when the backup was taken and needs reconciliation. pending_lifecycle = int( @@ -2090,12 +2346,28 @@ class RecoveryService: ) or 0 ) + pending_notifications = int( + self.engine.scalar( + target, + "select count(*) from alert_notification_deliveries " + "where status in ('PENDING','DISPATCHING','RETRY_WAIT')", + ) + or 0 + ) # A restored alert is history, not current truth: it must be re-derived after startup. self.engine.scalar( target, "update operational_alerts set state='SUPPRESSED', " "suppressed_until = now() + interval '1 hour' where state in ('PENDING','FIRING')", ) + # A backup's in-flight webhooks cannot safely be replayed after restore: the remote side + # may already have accepted them. Preserve their evidence but require operator review. + self.engine.scalar( + target, + "update alert_notification_deliveries set status='HELD_FOR_REVIEW', " + "next_attempt_at=null, lease_expires_at=null, claim_token_hash=null " + "where status in ('PENDING','DISPATCHING','RETRY_WAIT')", + ) # Re-read immediately before the marker so concurrent target activity cannot make the # earlier proof stale. The marker CAS binds to this exact verified head. checkpoint = self._verify_restored_audit_chain(target) @@ -2113,6 +2385,8 @@ class RecoveryService: "pending_lifecycle_operations": pending_lifecycle, "pending_migration_cutovers": pending_migrations, "suppressed_alerts": firing_alerts, + "notification_deliveries_held_for_review": pending_notifications, + "interrupted_selected_comparisons": comparisons["interrupted"], }, ) return { @@ -2124,8 +2398,10 @@ class RecoveryService: "in_flight_lifecycle_operations": in_flight_lifecycle, "in_flight_migration_cutovers": in_flight_migrations, "restored_firing_alerts_suppressed": firing_alerts, + "notification_deliveries_held_for_review": pending_notifications, + "interrupted_selected_comparisons": comparisons["interrupted"], "outcome": "RECOVERY_RECONCILIATION_REQUIRED" - if pending_lifecycle or pending_migrations + if pending_lifecycle or pending_migrations or comparisons["interrupted"] or pending_notifications else "RECONCILED", } @@ -2213,6 +2489,13 @@ class RecoveryService: ).strip() not in {"0", ""} } + if self.engine.scalar(target, + "select count(*) from information_schema.tables where table_schema='public' " + "and table_name='model_update_watches'").strip() not in {"0", ""}: + stale_current_truth["model_update_watch_claims"] = int(self.engine.scalar(target, + "select count(*) from model_update_watches where check_token is not null or check_until is not null") or 0) + stale_current_truth["selected_comparison_active_or_claimed"] = self._selected_comparison_residuals(target) + stale_current_truth["creative_queued_or_granted"] = self._creative_allocation_residuals(target) gate = { "database_validated": table_count >= minimum_tables and constraint_count > 0, # A restore is not READY while a stale lease or NVML snapshot is still presented as diff --git a/backend/src/modelforge_api/services/recovery_crypto.py b/backend/src/modelforge_api/services/recovery_crypto.py index 9ca429c..f28b8ee 100644 --- a/backend/src/modelforge_api/services/recovery_crypto.py +++ b/backend/src/modelforge_api/services/recovery_crypto.py @@ -100,7 +100,9 @@ class AesGcmBackupCipher: written = 0 temporary = destination.with_name(destination.name + ".partial") try: - with source.open("rb") as reader, temporary.open("wb") as writer: + with source.open("rb") as reader, os.fdopen( + os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb" + ) as writer: if reader.read(len(MAGIC)) != MAGIC: raise BackupCryptoError( "DECRYPTION_FAILED", "payload is not a ModelForge encrypted backup" diff --git a/backend/src/modelforge_api/services/recovery_fingerprint.py b/backend/src/modelforge_api/services/recovery_fingerprint.py index afc6075..12eff1c 100644 --- a/backend/src/modelforge_api/services/recovery_fingerprint.py +++ b/backend/src/modelforge_api/services/recovery_fingerprint.py @@ -19,7 +19,7 @@ from datetime import UTC, date, datetime from decimal import Decimal from typing import Any -from sqlalchemy import Table, func, select +from sqlalchemy import Column, MetaData, Table, func, inspect, or_, select, text from sqlalchemy.engine import Connection from sqlalchemy.orm import Session @@ -31,6 +31,59 @@ LEGACY_FINGERPRINT_VERSION = "m15.1" AUDIT_CHECKPOINT_SOURCE_SCHEMA = "20260828_0022" AUDIT_CHECKPOINT_CURRENT_SCHEMA = "20260830_0024" NON_FINGERPRINT_INFRASTRUCTURE_TABLES = frozenset({"alembic_version"}) +CONFIGURATION_TABLES = frozenset({"configuration_state", "configuration_revisions"}) +CONFIGURATION_SCHEMA = "20260907_0025" +PROJECT_SCHEMA = "20260908_0026" +PROJECT_TABLES = frozenset({"project_onboarding"}) +MANAGED_IDENTITY_SCHEMA = "20260908_0027" +MANAGED_IDENTITY_TABLES = frozenset({ + "managed_operator_state", "managed_operator_identities", "managed_operator_credentials", +}) +SECRET_SCHEMA = "20260908_0028" # noqa: S105 - schema identifier +SECRET_TABLES = frozenset({"platform_secret_state", "platform_secret_versions"}) +SECRET_CURRENT_TABLES = frozenset({"platform_secret_tests", "platform_secret_activations"}) +MODEL_UPDATE_SCHEMA = "20260909_0030" +MODEL_UPDATE_TABLES = frozenset({"model_update_watches", "model_update_notices"}) +MODEL_UPDATE_TRANSIENT_COLUMNS = frozenset({"check_token", "check_until"}) +SELECTED_COMPARISON_SCHEMA = "20260909_0033" +SERVING_GENERATION_SCHEMA = "20260910_0034" +MODEL_CHANGE_SCHEMA = "20260911_0035" +MODEL_CHANGE_TABLES = frozenset({"model_change_cases"}) +PORTFOLIO_SCHEMA = "20260923_0036" +PORTFOLIO_TABLES = frozenset({"portfolio_reconciliation_runs", "portfolio_repository_observations"}) +ALERT_NOTIFICATION_SCHEMA = "20260923_0037" +ALERT_NOTIFICATION_TABLES = frozenset({"alert_notification_deliveries"}) +ALERT_NOTIFICATION_RESTORABLE_STATES = frozenset({"PENDING", "DISPATCHING", "RETRY_WAIT"}) +LAB_EXECUTION_SCHEMA = "20260924_0038" +LAB_EXECUTION_TABLES = frozenset({"lab_execution_runs"}) +BENCHMARK_RUN_SCHEMA = "20260924_0039" +BENCHMARK_RUN_MIGRATION_COLUMNS = frozenset( + { + "capability_deployment_id", + "target_snapshot", + "actor_subject", + "idempotency_key", + "request_fingerprint", + "failure_code", + "cancellation_requested_at", + "created_at", + "generation", + } +) +BENCHMARK_RUN_SCHEMA_PROJECTION = "benchmark_run_schema_projection" +BENCHMARK_RUN_SCHEMA_OBSERVED = "benchmark_runs_schema_observed" +SELECTED_COMPARISON_TABLES = frozenset({"selected_model_comparisons"}) +SELECTED_COMPARISON_TRANSIENT_COLUMNS = frozenset({"claim_token_hash", "claim_expires_at"}) +SELECTED_COMPARISON_ACTIVE_STATUSES = frozenset({"pending", "running", "cancel_requested"}) +SELECTED_COMPARISON_RESTORE_ERROR = "COMPARISON_RESTORE_INTERRUPTED" +CREATIVE_SCHEMA = "20260909_0029" +CREATIVE_GOVERNANCE_SCHEMA = "20260909_0031" +CREATIVE_ALLOCATION_TABLE = "external_creative_allocations" +CREATIVE_TABLES = frozenset({CREATIVE_ALLOCATION_TABLE, "external_creative_resources", + "external_creative_action_attestations", "external_creative_profile_observations"}) +CREATIVE_RESTORE_ERROR = "CREATIVE_RESTORE_INTERRUPTED" +DEPLOYED_SOURCE_SCHEMAS = frozenset({"20260909_0029", "20260909_0030", "20260909_0031", "20260909_0032", "20260909_0033"}) +MANAGED_ACTIVATION_TABLE = "managed_operator_credential_activations" # Column names carrying secret material never enter a fingerprint, even as a hash. SENSITIVE_COLUMN_TOKENS = ( @@ -43,6 +96,15 @@ SENSITIVE_COLUMN_TOKENS = ( ) FINGERPRINT_GROUPS: dict[str, tuple[str, ...]] = { + "alert_notifications": tuple(sorted(ALERT_NOTIFICATION_TABLES)), + "portfolio": tuple(sorted(PORTFOLIO_TABLES)), + "model_changes": tuple(sorted(MODEL_CHANGE_TABLES)), + "external_creative": tuple(sorted(CREATIVE_TABLES)), + "selected_comparisons": tuple(sorted(SELECTED_COMPARISON_TABLES)), + "model_updates": tuple(sorted(MODEL_UPDATE_TABLES)), + "platform_secrets": tuple(sorted(SECRET_TABLES)), + "managed_identities": tuple(sorted(MANAGED_IDENTITY_TABLES)), + "configuration": ("configuration_state", "configuration_revisions"), "provenance": ( "models", "model_revisions", @@ -68,6 +130,7 @@ FINGERPRINT_GROUPS: dict[str, tuple[str, ...]] = { "discovery_candidate_assessments", ), "registry": ( + "project_onboarding", "capabilities", "capability_contracts", "projects", @@ -173,6 +236,7 @@ FINGERPRINT_GROUPS: dict[str, tuple[str, ...]] = { "runtime_probes", "runtime_probe_metrics", ), + "lab_execution": tuple(sorted(LAB_EXECUTION_TABLES)), "audit": ("audit_events", "audit_chain_heads"), "recovery": ( "recovery_policy_revisions", @@ -198,6 +262,8 @@ CURRENT_TRUTH_TABLES: frozenset[str] = frozenset( "residency_allocations", "serving_gpu_leases", "gpu_leases", + MANAGED_ACTIVATION_TABLE, + *SECRET_CURRENT_TABLES, } ) @@ -224,9 +290,14 @@ def _fingerprint_table_contract( physical_tables = fingerprint.get("physical_tables") if allow_legacy_unexpected_field_absent and "unexpected_tables" not in fingerprint: unexpected_tables = [] + current_truth = CURRENT_TRUTH_TABLES + if not MANAGED_IDENTITY_TABLES & expected_tables: + current_truth = current_truth - {MANAGED_ACTIVATION_TABLE} + if not SECRET_TABLES & expected_tables: + current_truth = current_truth - SECRET_CURRENT_TABLES + expected_physical = expected_tables | current_truth if allow_legacy_unexpected_field_absent and "physical_tables" not in fingerprint: - physical_tables = sorted(expected_tables | CURRENT_TRUTH_TABLES) - expected_physical = expected_tables | CURRENT_TRUTH_TABLES + physical_tables = sorted(expected_physical) if ( not isinstance(tables, dict) or set(tables) != set(expected_tables) @@ -234,8 +305,7 @@ def _fingerprint_table_contract( or not isinstance(physical_tables, list) or any(not isinstance(name, str) for name in physical_tables) or physical_tables != sorted(set(physical_tables)) - or set(physical_tables) - NON_FINGERPRINT_INFRASTRUCTURE_TABLES - != set(expected_physical) + or set(physical_tables) - NON_FINGERPRINT_INFRASTRUCTURE_TABLES != set(expected_physical) or not isinstance(digest, str) or len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest) @@ -266,8 +336,6 @@ def _fingerprint_table_contract( ): return False else: - # A capped or content-redacted digest is useful operational evidence, but it is not - # a complete count+digest proof and can never authorize READY. return False return True @@ -283,8 +351,6 @@ def _physical_table_transition_is_exact( if not isinstance(restored_physical, list): return False if source_physical is None and introduce_checkpoint: - # Genuine m15.1 fingerprints predate this field. Their exact schema-0022 presence is - # inferred from the versioned table contract and checked on the migrated target. return True if not isinstance(source_physical, list): return False @@ -303,23 +369,1495 @@ def _has_single_complete_checkpoint(fingerprint: dict[str, Any]) -> bool: ) +def _without_configuration(fingerprint: dict[str, Any]) -> dict[str, Any]: + return { + **fingerprint, + "tables": { + key: value + for key, value in fingerprint["tables"].items() + if key not in CONFIGURATION_TABLES + }, + "physical_tables": [ + name + for name in fingerprint.get("physical_tables", []) + if name not in CONFIGURATION_TABLES + ], + } + + +def _configuration_is_empty(fingerprint: dict[str, Any]) -> bool: + empty = { + "row_count": 0, + "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", + "redacted_columns": [], + } + return all(fingerprint.get("tables", {}).get(name) == empty for name in CONFIGURATION_TABLES) + + +def _without_projects(fingerprint: dict[str, Any], *, legacy: bool) -> dict[str, Any]: + tables = {key: value for key, value in fingerprint["tables"].items() if key not in PROJECT_TABLES} + credential = tables.get("service_credentials", {}) + if legacy and "pre_onboarding" in credential: + tables["service_credentials"] = credential["pre_onboarding"] + return {**fingerprint, "tables": tables, "physical_tables": [ + name for name in fingerprint.get("physical_tables", []) if name not in PROJECT_TABLES + ]} + + +def _empty_project_upgrade(fingerprint: dict[str, Any]) -> bool: + empty = {"row_count": 0, "digest": hashlib.sha256(b"").hexdigest(), "status": "COMPLETE", "redacted_columns": []} + credential = fingerprint.get("tables", {}).get("service_credentials", {}) + legacy = credential.get("pre_onboarding") + return ( + all(fingerprint.get("tables", {}).get(name) == empty for name in PROJECT_TABLES) + and credential.get("superseded_count") == 0 + and isinstance(legacy, dict) + and legacy.get("status") == "COMPLETE" + and legacy.get("row_count") == credential.get("row_count") + and legacy.get("redacted_columns") == credential.get("redacted_columns") + ) + + +def _without_managed_identities(fingerprint: dict[str, Any]) -> dict[str, Any]: + return {**fingerprint, + "tables": {key: value for key, value in fingerprint["tables"].items() if key not in MANAGED_IDENTITY_TABLES}, + "physical_tables": [name for name in fingerprint.get("physical_tables", []) + if name not in MANAGED_IDENTITY_TABLES and name != MANAGED_ACTIVATION_TABLE]} + + +def _empty_managed_identity_upgrade(fingerprint: dict[str, Any]) -> bool: + row_hash = hashlib.sha256(_canonical_row((0, 1))).digest() + state_digest = hashlib.sha256(len(row_hash).to_bytes(4, "big") + row_hash).hexdigest() + expected = { + name: {"row_count": 1 if name == "managed_operator_state" else 0, + "digest": state_digest if name == "managed_operator_state" else hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", "redacted_columns": ["token_sha256"] if name == "managed_operator_credentials" else []} + for name in MANAGED_IDENTITY_TABLES + } + return all(fingerprint.get("tables", {}).get(name) == value for name, value in expected.items()) + + +def _without_secrets(fingerprint: dict[str, Any]) -> dict[str, Any]: + return {**fingerprint, + "tables": {name: entry for name, entry in fingerprint["tables"].items() if name not in SECRET_TABLES}, + "physical_tables": [name for name in fingerprint.get("physical_tables", []) + if name not in SECRET_TABLES | SECRET_CURRENT_TABLES]} + + +def _empty_secret_upgrade(fingerprint: dict[str, Any]) -> bool: + row_hash = hashlib.sha256(_canonical_row((False, 0, 1))).digest() + state_digest = hashlib.sha256(len(row_hash).to_bytes(4, "big") + row_hash).hexdigest() + return all(fingerprint.get("tables", {}).get(name) == { + "row_count": 1 if name == "platform_secret_state" else 0, + "digest": state_digest if name == "platform_secret_state" else hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", + "redacted_columns": ["secret_ciphertext", "secret_nonce"] if name == "platform_secret_versions" else [], + } for name in SECRET_TABLES) + + +def _pre_updates_tables() -> frozenset[str]: + return _pre_comparison_tables() - MODEL_UPDATE_TABLES + + +def _pre_comparison_tables() -> frozenset[str]: + return _core_tables() - SELECTED_COMPARISON_TABLES + + +def _without_selected_comparisons(fingerprint: dict[str, Any]) -> dict[str, Any]: + return {**fingerprint, + "tables": {name: entry for name, entry in fingerprint["tables"].items() + if name not in SELECTED_COMPARISON_TABLES}, + "physical_tables": [name for name in fingerprint.get("physical_tables", []) + if name not in SELECTED_COMPARISON_TABLES]} + + +def _empty_selected_comparison_upgrade(fingerprint: dict[str, Any]) -> bool: + empty = {"row_count": 0, "digest": hashlib.sha256(b"").hexdigest(), "status": "COMPLETE", + "redacted_columns": ["claim_token_hash"]} + return bool(fingerprint.get("tables", {}).get("selected_model_comparisons") == { + **empty, "restore_projection": empty, + }) + + +def _selected_comparison_recovery_matches(source: dict[str, Any], restored: dict[str, Any]) -> bool: + before = source.get("tables", {}).get("selected_model_comparisons", {}) + after = restored.get("tables", {}).get("selected_model_comparisons", {}) + projection = before.get("restore_projection") + actual = {name: value for name, value in after.items() if name != "restore_projection"} + return (isinstance(projection, dict) and projection == actual + and all(projection.get(name) == before.get(name) for name in ("row_count", "status", "redacted_columns")) + and after.get("restore_projection") == actual) + + +def _without_model_updates(fingerprint: dict[str, Any]) -> dict[str, Any]: + return {**fingerprint, + "tables": {name: entry for name, entry in fingerprint["tables"].items() + if name not in MODEL_UPDATE_TABLES}, + "physical_tables": [name for name in fingerprint.get("physical_tables", []) + if name not in MODEL_UPDATE_TABLES]} + + +def _empty_model_update_upgrade(fingerprint: dict[str, Any]) -> bool: + return all(fingerprint.get("tables", {}).get(name) == { + "row_count": 0, "digest": hashlib.sha256(b"").hexdigest(), "status": "COMPLETE", + "redacted_columns": ["check_token"] if name == "model_update_watches" else [], + } for name in MODEL_UPDATE_TABLES) + + +def _without_model_changes(fingerprint: dict[str, Any]) -> dict[str, Any]: + return { + **fingerprint, + "tables": { + name: entry + for name, entry in fingerprint["tables"].items() + if name not in MODEL_CHANGE_TABLES + }, + "physical_tables": [ + name + for name in fingerprint.get("physical_tables", []) + if name not in MODEL_CHANGE_TABLES + ], + } + + +def _without_portfolio(fingerprint: dict[str, Any]) -> dict[str, Any]: + return { + **fingerprint, + "tables": { + name: entry + for name, entry in fingerprint["tables"].items() + if name not in PORTFOLIO_TABLES + }, + "physical_tables": [ + name + for name in fingerprint.get("physical_tables", []) + if name not in PORTFOLIO_TABLES + ], + } + + +def _portfolio_is_empty(fingerprint: dict[str, Any]) -> bool: + empty = { + "row_count": 0, + "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", + "redacted_columns": [], + } + return all(fingerprint.get("tables", {}).get(name) == empty for name in PORTFOLIO_TABLES) + + +def _portfolio_has_rows(fingerprint: dict[str, Any]) -> bool: + tables = fingerprint.get("tables", {}) + return any( + isinstance(tables.get(name), dict) + and isinstance(tables[name].get("row_count"), int) + and tables[name].get("row_count", 0) > 0 + for name in PORTFOLIO_TABLES + ) + + +def _empty_model_change_upgrade(fingerprint: dict[str, Any]) -> bool: + empty = { + "row_count": 0, + "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", + "redacted_columns": [], + } + return all(fingerprint.get("tables", {}).get(name) == empty for name in MODEL_CHANGE_TABLES) + + +def _pre_model_change_tables() -> frozenset[str]: + return classified_tables() - MODEL_CHANGE_TABLES + + +def _core_tables() -> frozenset[str]: + return ( + _pre_model_change_tables() + - CREATIVE_TABLES + - PORTFOLIO_TABLES + - ALERT_NOTIFICATION_TABLES + - LAB_EXECUTION_TABLES + ) + + +def _without_creative(fingerprint: dict[str, Any]) -> dict[str, Any]: + """Projection seam; callers must validate the removed authoritative state first.""" + projected = {**fingerprint, + "tables": {name: entry for name, entry in fingerprint["tables"].items() if name not in CREATIVE_TABLES}} + if "physical_tables" in fingerprint: + projected["physical_tables"] = [name for name in fingerprint["physical_tables"] if name not in CREATIVE_TABLES] + return projected + + +def _deployed_tables(schema: str) -> frozenset[str]: + expected = _core_tables() + if schema < ALERT_NOTIFICATION_SCHEMA: + expected -= ALERT_NOTIFICATION_TABLES + if schema < SELECTED_COMPARISON_SCHEMA: + expected -= SELECTED_COMPARISON_TABLES + if schema < MODEL_UPDATE_SCHEMA: + expected -= MODEL_UPDATE_TABLES + if schema >= ALERT_NOTIFICATION_SCHEMA: + expected |= ALERT_NOTIFICATION_TABLES + if schema >= CREATIVE_GOVERNANCE_SCHEMA: + return expected | CREATIVE_TABLES + return expected | {CREATIVE_ALLOCATION_TABLE} + + +def _watch_controls_for_core(fingerprint: dict[str, Any]) -> dict[str, Any]: + entry = fingerprint.get("tables", {}).get("model_update_watches") + if not isinstance(entry, dict) or "hub_controls_projection" not in entry: + return fingerprint + return {**fingerprint, "tables": {**fingerprint["tables"], + "model_update_watches": entry["hub_controls_projection"]}} + + +def _valid_watch_projection(fingerprint: dict[str, Any]) -> bool: + entry = fingerprint.get("tables", {}).get("model_update_watches") + if not isinstance(entry, dict): + return False + projection = entry.get("hub_controls_projection") + if not isinstance(projection, dict): + return entry == {"row_count": 0, "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", "redacted_columns": ["check_token"]} + return all(projection.get(key) == entry.get(key) for key in ("row_count", "status", "redacted_columns")) + + +def _creative_recovery_matches(source: dict[str, Any], restored: dict[str, Any]) -> bool: + empty = {"row_count": 0, "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", "redacted_columns": []} + for name in CREATIVE_TABLES: + before, after = source.get("tables", {}).get(name), restored.get("tables", {}).get(name) + if before is None: + if after is not None and after != (dict(empty, restore_projection=empty) if name == CREATIVE_ALLOCATION_TABLE else empty): + return False + elif after is None: + return False + elif name == CREATIVE_ALLOCATION_TABLE: + projection = before.get("restore_projection", before) + actual = {key: value for key, value in after.items() if key != "restore_projection"} + if (projection != actual or after.get("restore_projection", actual) != actual + or any(projection.get(key) != before.get(key) for key in ("row_count", "status", "redacted_columns"))): + return False + elif before != after: + return False + return True + + +def _without_alert_notifications(fingerprint: dict[str, Any]) -> dict[str, Any]: + return { + **fingerprint, + "tables": { + name: entry + for name, entry in fingerprint.get("tables", {}).items() + if name not in ALERT_NOTIFICATION_TABLES + }, + "physical_tables": [ + name + for name in fingerprint.get("physical_tables", []) + if name not in ALERT_NOTIFICATION_TABLES + ], + } + + +def _alert_notifications_empty(fingerprint: dict[str, Any]) -> bool: + empty = { + "row_count": 0, + "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", + "redacted_columns": ["claim_token_hash"], + "restore_projection": { + "row_count": 0, + "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", + "redacted_columns": ["claim_token_hash"], + }, + } + return all(fingerprint.get("tables", {}).get(name) == empty for name in ALERT_NOTIFICATION_TABLES) + + +def _alert_notifications_have_rows(fingerprint: dict[str, Any]) -> bool: + return any( + isinstance(fingerprint.get("tables", {}).get(name), dict) + and int(fingerprint["tables"][name].get("row_count") or 0) > 0 + for name in ALERT_NOTIFICATION_TABLES + ) + + +def _alert_notification_recovery_matches( + source: dict[str, Any], restored: dict[str, Any] +) -> bool: + for name in ALERT_NOTIFICATION_TABLES: + before = source.get("tables", {}).get(name) + after = restored.get("tables", {}).get(name) + if not isinstance(before, dict) or not isinstance(after, dict): + return False + projection = before.get("restore_projection") + actual = {key: value for key, value in after.items() if key != "restore_projection"} + if ( + not isinstance(projection, dict) + or projection != actual + or after.get("restore_projection") != actual + or any( + projection.get(key) != before.get(key) + for key in ("row_count", "status", "redacted_columns") + ) + ): + return False + return True + + +def _generation_for_core(fingerprint: dict[str, Any]) -> dict[str, Any]: + entry = fingerprint.get("tables", {}).get("capability_contracts", {}) + projection = entry.get("serving_generation_projection") + if not isinstance(projection, dict): + return fingerprint + return {**fingerprint, "tables": {**fingerprint["tables"], "capability_contracts": projection}} + + +def _without_lab_execution(fingerprint: dict[str, Any]) -> dict[str, Any]: + """Project post-0037 schema additions only when their state remains legacy-compatible.""" + tables = { + name: entry + for name, entry in fingerprint.get("tables", {}).items() + if name not in LAB_EXECUTION_TABLES + } + serving_jobs = tables.get("serving_jobs") + if isinstance(serving_jobs, dict): + projection = serving_jobs.get("lab_execution_projection") + if isinstance(projection, dict) and isinstance(projection.get("base"), dict): + tables["serving_jobs"] = projection["base"] + if _benchmark_runs_legacy_upgrade_is_safe(fingerprint): + tables["benchmark_runs"] = fingerprint["tables"]["benchmark_runs"][ + BENCHMARK_RUN_SCHEMA_PROJECTION + ]["base"] + return { + **fingerprint, + "tables": tables, + "physical_tables": [ + name + for name in fingerprint.get("physical_tables", []) + if name not in LAB_EXECUTION_TABLES + ], + } + + +def _valid_table_digest_entry(entry: Any) -> bool: + if not isinstance(entry, dict): + return False + row_count = entry.get("row_count") + digest = entry.get("digest") + redacted_columns = entry.get("redacted_columns") + return ( + entry.get("status") == "COMPLETE" + and isinstance(row_count, int) + and not isinstance(row_count, bool) + and 0 <= row_count <= MAX_ROWS_PER_TABLE + and isinstance(digest, str) + and len(digest) == 64 + and all(character in "0123456789abcdef" for character in digest) + and isinstance(redacted_columns, list) + and all(isinstance(column, str) for column in redacted_columns) + and redacted_columns == sorted(set(redacted_columns)) + and set(entry) == {"row_count", "digest", "status", "redacted_columns"} + ) + + +def _valid_lab_execution_projection(fingerprint: dict[str, Any]) -> bool: + tables = fingerprint.get("tables") + serving_jobs = tables.get("serving_jobs") if isinstance(tables, dict) else None + projection = serving_jobs.get("lab_execution_projection") if isinstance(serving_jobs, dict) else None + if not isinstance(serving_jobs, dict) or not isinstance(projection, dict): + return False + base = projection.get("base") + run_link_count = projection.get("run_link_count") + run_link_digest = projection.get("run_link_digest") + return ( + isinstance(base, dict) + and _valid_table_digest_entry(base) + and isinstance(run_link_count, int) + and not isinstance(run_link_count, bool) + and run_link_count >= 0 + and isinstance(run_link_digest, str) + and len(run_link_digest) == 64 + and all(character in "0123456789abcdef" for character in run_link_digest) + and set(projection) == {"base", "run_link_count", "run_link_digest"} + and set(serving_jobs) == {*base, "lab_execution_projection"} + and serving_jobs.get("status") == base.get("status") + and serving_jobs.get("row_count") == base.get("row_count") + and serving_jobs.get("redacted_columns") == base.get("redacted_columns") + ) + + +def _lab_execution_upgrade_is_empty(fingerprint: dict[str, Any]) -> bool: + empty = { + "row_count": 0, + "digest": hashlib.sha256(b"").hexdigest(), + "status": "COMPLETE", + "redacted_columns": [], + } + entry = fingerprint.get("tables", {}).get("lab_execution_runs") + return ( + entry == empty + and _valid_lab_execution_projection(fingerprint) + and fingerprint["tables"]["serving_jobs"]["lab_execution_projection"][ + "run_link_count" + ] + == 0 + and fingerprint["tables"]["serving_jobs"]["lab_execution_projection"][ + "run_link_digest" + ] + == hashlib.sha256(b"").hexdigest() + ) + + +def _lab_execution_state_matches( + source: dict[str, Any], restored: dict[str, Any] +) -> bool: + if source.get("tables", {}).get("lab_execution_runs") != restored.get("tables", {}).get( + "lab_execution_runs" + ): + return False + if not _valid_lab_execution_projection(source) or not _valid_lab_execution_projection(restored): + return False + source_projection = source["tables"]["serving_jobs"]["lab_execution_projection"] + restored_projection = restored["tables"]["serving_jobs"]["lab_execution_projection"] + return all( + source_projection[key] == restored_projection[key] + for key in ("run_link_count", "run_link_digest") + ) + + +def _benchmark_runs_projection_present(fingerprint: dict[str, Any]) -> bool: + entry = fingerprint.get("tables", {}).get("benchmark_runs") + return isinstance(entry, dict) and BENCHMARK_RUN_SCHEMA_PROJECTION in entry + + +def _benchmark_runs_schema_observed(fingerprint: dict[str, Any]) -> bool: + entry = fingerprint.get("tables", {}).get("benchmark_runs") + return isinstance(entry, dict) and entry.get(BENCHMARK_RUN_SCHEMA_OBSERVED) is True + + +def _valid_benchmark_runs_projection(fingerprint: dict[str, Any]) -> bool: + entry = fingerprint.get("tables", {}).get("benchmark_runs") + projection = ( + entry.get(BENCHMARK_RUN_SCHEMA_PROJECTION) if isinstance(entry, dict) else None + ) + if not isinstance(entry, dict) or not isinstance(projection, dict): + return False + base = projection.get("base") + non_legacy_row_count = projection.get("non_legacy_row_count") + if not isinstance(base, dict): + return False + return ( + set(entry) == { + "row_count", + "digest", + "status", + "redacted_columns", + BENCHMARK_RUN_SCHEMA_PROJECTION, + } + and _valid_table_digest_entry(base) + and set(projection) == {"base", "non_legacy_row_count"} + and isinstance(non_legacy_row_count, int) + and not isinstance(non_legacy_row_count, bool) + and 0 <= non_legacy_row_count <= base["row_count"] + and all( + entry.get(key) == base.get(key) + for key in ("row_count", "status", "redacted_columns") + ) + and _valid_table_digest_entry({key: entry[key] for key in ( + "row_count", "digest", "status", "redacted_columns" + )}) + ) + + +def _benchmark_runs_legacy_upgrade_is_safe(fingerprint: dict[str, Any]) -> bool: + return ( + _valid_benchmark_runs_projection(fingerprint) + and fingerprint["tables"]["benchmark_runs"][BENCHMARK_RUN_SCHEMA_PROJECTION][ + "non_legacy_row_count" + ] + == 0 + ) + + +def _without_benchmark_runs_schema(fingerprint: dict[str, Any]) -> dict[str, Any]: + """Project migration-0039 columns away after validating their restore state.""" + tables = dict(fingerprint.get("tables", {})) + entry = tables.get("benchmark_runs") + projection = ( + entry.get(BENCHMARK_RUN_SCHEMA_PROJECTION) if isinstance(entry, dict) else None + ) + if isinstance(projection, dict) and isinstance(projection.get("base"), dict): + tables["benchmark_runs"] = projection["base"] + return {**fingerprint, "tables": tables} + + +def _lab_execution_fingerprint_present(fingerprint: dict[str, Any]) -> bool: + tables = fingerprint.get("tables") + physical_tables = fingerprint.get("physical_tables") + serving_jobs = tables.get("serving_jobs") if isinstance(tables, dict) else None + return bool( + (isinstance(tables, dict) and LAB_EXECUTION_TABLES & set(tables)) + or ( + isinstance(physical_tables, list) + and any(isinstance(name, str) and name in LAB_EXECUTION_TABLES for name in physical_tables) + ) + or ( + isinstance(serving_jobs, dict) + and ( + "lab_execution_projection" in serving_jobs + or serving_jobs.get("lab_execution_schema_observed") is True + ) + ) + ) + + def fingerprint_compatibility( + source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, +) -> FingerprintCompatibility: + schema = source_schema_revision + source_names = set(source.get("tables", {})) + restored_names = set(restored.get("tables", {})) + if _benchmark_runs_schema_observed(source) or _benchmark_runs_schema_observed(restored): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "benchmark_runs physical columns do not match a complete declared schema", + ) + source_has_benchmark_projection = _benchmark_runs_projection_present(source) + restored_has_benchmark_projection = _benchmark_runs_projection_present(restored) + if schema == BENCHMARK_RUN_SCHEMA: + if not ( + _fingerprint_table_contract(source, expected_tables=classified_tables()) + and _fingerprint_table_contract(restored, expected_tables=classified_tables()) + and _valid_benchmark_runs_projection(source) + and _valid_benchmark_runs_projection(restored) + and source["tables"]["benchmark_runs"] == restored["tables"]["benchmark_runs"] + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "schema-0039 benchmark operator history requires exact retention", + ) + base = fingerprint_compatibility( + _without_benchmark_runs_schema(source), + _without_benchmark_runs_schema(restored), + source_schema_revision=LAB_EXECUTION_SCHEMA, + ) + return FingerprintCompatibility( + base.compatible, + f"BENCHMARK_RUNS_STRICT|{base.mode}" if base.compatible else "INCOMPATIBLE", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if source_has_benchmark_projection: + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "pre-0039 source contains future benchmark operator evidence", + ) + if restored_has_benchmark_projection: + source_benchmark_runs = source.get("tables", {}).get("benchmark_runs") + if not isinstance(schema, str) or schema >= BENCHMARK_RUN_SCHEMA: + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "benchmark schema projection requires a known pre-0039 source revision", + ) + if not ( + _fingerprint_table_contract(restored, expected_tables=classified_tables()) + and _valid_table_digest_entry(source_benchmark_runs) + and _benchmark_runs_legacy_upgrade_is_safe(restored) + and source_benchmark_runs + == restored["tables"]["benchmark_runs"][BENCHMARK_RUN_SCHEMA_PROJECTION]["base"] + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "schema-0039 upgrade may add only default benchmark metadata while preserving legacy rows", + ) + base = fingerprint_compatibility( + source, + _without_benchmark_runs_schema(restored), + source_schema_revision=schema, + ) + return FingerprintCompatibility( + base.compatible, + f"BENCHMARK_RUNS_UPGRADE|{schema}|{base.mode}" if base.compatible else "INCOMPATIBLE", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if schema == LAB_EXECUTION_SCHEMA: + valid_contract = ( + _fingerprint_table_contract(source, expected_tables=classified_tables()) + and _fingerprint_table_contract(restored, expected_tables=classified_tables()) + ) + if not valid_contract or not _lab_execution_state_matches(source, restored): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "schema-0038 LAB run history and serving-job links require exact retention", + ) + base = fingerprint_compatibility( + _without_lab_execution(source), + _without_lab_execution(restored), + source_schema_revision=ALERT_NOTIFICATION_SCHEMA, + ) + return FingerprintCompatibility( + base.compatible, + f"LAB_EXECUTION_STRICT|{base.mode}" if base.compatible else "INCOMPATIBLE", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if isinstance(schema, str) and schema < LAB_EXECUTION_SCHEMA: + source_has_lab = _lab_execution_fingerprint_present(source) + restored_has_lab = _lab_execution_fingerprint_present(restored) + if source_has_lab: + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "pre-0038 source contains future LAB schema or execution evidence", + ) + if restored_has_lab: + if not ( + _fingerprint_table_contract(restored, expected_tables=classified_tables()) + and _lab_execution_upgrade_is_empty(restored) + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "schema-0038 upgrade may introduce only an empty LAB table and unlinked serving jobs", + ) + base = fingerprint_compatibility( + source, + _without_lab_execution(restored), + source_schema_revision=schema, + ) + return FingerprintCompatibility( + base.compatible, + f"LAB_EXECUTION_UPGRADE|{schema}|{base.mode}" if base.compatible else "INCOMPATIBLE", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if schema == ALERT_NOTIFICATION_SCHEMA: + if not ( + _fingerprint_table_contract(source, expected_tables=_pre_lab_execution_tables()) + and _fingerprint_table_contract(restored, expected_tables=_pre_lab_execution_tables()) + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "current alert notification history requires the complete schema-0037 table contract", + ) + base = fingerprint_compatibility( + _without_alert_notifications(source), + _without_alert_notifications(restored), + source_schema_revision=PORTFOLIO_SCHEMA, + ) + notification_match = _alert_notification_recovery_matches(source, restored) + return FingerprintCompatibility( + base.compatible and notification_match, + f"ALERT_NOTIFICATION_STRICT|{base.mode}" if base.compatible and notification_match else "INCOMPATIBLE", + base.allow_audit_checkpoint_introduction, + base.reason if notification_match else "alert deliveries differ beyond the exact pending-to-held restore projection", + ) + if ALERT_NOTIFICATION_TABLES & restored_names: + if not isinstance(schema, str) or schema >= ALERT_NOTIFICATION_SCHEMA: + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "alert notification history predates its declared migration", + ) + if not _alert_notifications_empty(restored): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "schema-0037 upgrade may introduce only empty alert notification history", + ) + if _alert_notifications_have_rows(source): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "alert notification history predates its declared migration", + ) + base = fingerprint_compatibility( + _without_alert_notifications(source), + _without_alert_notifications(restored), + source_schema_revision=schema, + ) + return FingerprintCompatibility( + base.compatible, + f"ALERT_NOTIFICATION_UPGRADE|{base.mode}", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if ALERT_NOTIFICATION_TABLES & source_names: + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "alert notification history cannot exist before schema 0037", + ) + if schema == PORTFOLIO_SCHEMA: + if not ( + _fingerprint_table_contract( + source, expected_tables=_pre_lab_execution_tables() - ALERT_NOTIFICATION_TABLES + ) + and _fingerprint_table_contract( + restored, expected_tables=_pre_lab_execution_tables() - ALERT_NOTIFICATION_TABLES + ) + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "current portfolio history requires the complete schema-0036 table contract", + ) + base = fingerprint_compatibility( + _without_portfolio(source), + _without_portfolio(restored), + source_schema_revision=MODEL_CHANGE_SCHEMA, + ) + return FingerprintCompatibility( + base.compatible, + f"PORTFOLIO_STRICT|{base.mode}", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if PORTFOLIO_TABLES & restored_names: + if _portfolio_has_rows(source): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "portfolio history predates its declared migration", + ) + if ( + not _portfolio_is_empty(restored) + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "schema-0036 upgrade may introduce only empty portfolio history", + ) + base = fingerprint_compatibility( + _without_portfolio(source), + _without_portfolio(restored), + source_schema_revision=schema, + ) + return FingerprintCompatibility( + base.compatible, + f"PORTFOLIO_UPGRADE|{base.mode}", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if _portfolio_has_rows(source): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "portfolio history cannot exist before schema 0036", + ) + if schema == MODEL_CHANGE_SCHEMA: + if not ( + _fingerprint_table_contract( + source, + expected_tables=_pre_lab_execution_tables() - PORTFOLIO_TABLES - ALERT_NOTIFICATION_TABLES, + ) + and _fingerprint_table_contract( + restored, + expected_tables=_pre_lab_execution_tables() - PORTFOLIO_TABLES - ALERT_NOTIFICATION_TABLES, + ) + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "current model change history requires the complete schema-0035 table contract", + ) + base = fingerprint_compatibility( + _without_model_changes(source), + _without_model_changes(restored), + source_schema_revision=SERVING_GENERATION_SCHEMA, + ) + return FingerprintCompatibility( + base.compatible, + f"MODEL_CHANGE_STRICT|{base.mode}", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if MODEL_CHANGE_TABLES & restored_names: + if MODEL_CHANGE_TABLES & source_names: + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "model change history predates its declared migration", + ) + if ( + not _fingerprint_table_contract( + restored, + expected_tables=_pre_lab_execution_tables() - PORTFOLIO_TABLES - ALERT_NOTIFICATION_TABLES, + ) + or not _empty_model_change_upgrade(restored) + ): + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "schema-0035 upgrade may introduce only empty model change history", + ) + base = fingerprint_compatibility( + source, + _without_model_changes(restored), + source_schema_revision=schema, + ) + return FingerprintCompatibility( + base.compatible, + f"MODEL_CHANGE_UPGRADE|{base.mode}", + base.allow_audit_checkpoint_introduction, + base.reason, + ) + if MODEL_CHANGE_TABLES & source_names: + return FingerprintCompatibility( + False, + "INCOMPATIBLE", + False, + "model change history cannot exist before schema 0035", + ) + + before = source.get("tables", {}).get("capability_contracts", {}) + after = restored.get("tables", {}).get("capability_contracts", {}) + projection = before.get("serving_generation_projection") + current = source_schema_revision == SERVING_GENERATION_SCHEMA + if current and (projection is not None or "serving_generation_projection" in after): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "current serving generation cannot be projected away") + if projection is not None and ( + not isinstance(projection, dict) or "serving_generation_projection" in after + or not all(projection.get(key) == before.get(key) + for key in ("row_count", "status", "redacted_columns")) + or projection.get("status") != "COMPLETE" + ): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "serving generation requires an exact source default-zero projection") + mapped_schema = SELECTED_COMPARISON_SCHEMA if current else source_schema_revision + contract = _deployed_fingerprint_compatibility( + _generation_for_core(source), restored, source_schema_revision=mapped_schema) + if current or projection is not None: + return FingerprintCompatibility(contract.compatible, + f"SERVING|{source_schema_revision or 'none'}|{contract.mode}", + contract.allow_audit_checkpoint_introduction, contract.reason) + return contract + + +def audit_fingerprint_differences_are_compatible( + source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if contract.mode.startswith("BENCHMARK_RUNS_STRICT|"): + base_mode = contract.mode.removeprefix("BENCHMARK_RUNS_STRICT|") + verified = fingerprint_compatibility( + source, restored, source_schema_revision=BENCHMARK_RUN_SCHEMA + ) + return ( + verified.compatible + and verified.mode == contract.mode + and source.get("tables", {}).get("benchmark_runs") + == restored.get("tables", {}).get("benchmark_runs") + and audit_fingerprint_differences_are_compatible( + _without_benchmark_runs_schema(source), + _without_benchmark_runs_schema(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("BENCHMARK_RUNS_UPGRADE|"): + _, schema, base_mode = contract.mode.split("|", 2) + verified = fingerprint_compatibility(source, restored, source_schema_revision=schema) + source_entry = source.get("tables", {}).get("benchmark_runs") + projection = ( + restored.get("tables", {}) + .get("benchmark_runs", {}) + .get(BENCHMARK_RUN_SCHEMA_PROJECTION) + ) + return ( + verified.compatible + and verified.mode == contract.mode + and not _benchmark_runs_projection_present(source) + and _benchmark_runs_legacy_upgrade_is_safe(restored) + and isinstance(projection, dict) + and source_entry == projection.get("base") + and audit_fingerprint_differences_are_compatible( + source, + _without_benchmark_runs_schema(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("LAB_EXECUTION_STRICT|"): + base_mode = contract.mode.removeprefix("LAB_EXECUTION_STRICT|") + verified = fingerprint_compatibility( + source, restored, source_schema_revision=LAB_EXECUTION_SCHEMA + ) + return ( + verified.compatible + and verified.mode == contract.mode + and _lab_execution_state_matches(source, restored) + and audit_fingerprint_differences_are_compatible( + _without_lab_execution(source), + _without_lab_execution(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("LAB_EXECUTION_UPGRADE|"): + _, schema, base_mode = contract.mode.split("|", 2) + verified = fingerprint_compatibility( + source, restored, source_schema_revision=schema + ) + return ( + verified.compatible + and verified.mode == contract.mode + and _lab_execution_upgrade_is_empty(restored) + and not _lab_execution_fingerprint_present(source) + and audit_fingerprint_differences_are_compatible( + _without_lab_execution(source), + _without_lab_execution(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("PORTFOLIO_STRICT|"): + base_mode = contract.mode.removeprefix("PORTFOLIO_STRICT|") + return ( + _fingerprint_table_contract( + source, expected_tables=_pre_lab_execution_tables() - ALERT_NOTIFICATION_TABLES + ) + and _fingerprint_table_contract( + restored, expected_tables=_pre_lab_execution_tables() - ALERT_NOTIFICATION_TABLES + ) + and all( + source["tables"][name] == restored["tables"][name] + for name in PORTFOLIO_TABLES + ) + and audit_fingerprint_differences_are_compatible( + _without_portfolio(source), + _without_portfolio(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("PORTFOLIO_UPGRADE|"): + base_mode = contract.mode.removeprefix("PORTFOLIO_UPGRADE|") + return ( + _portfolio_is_empty(restored) + and audit_fingerprint_differences_are_compatible( + _without_portfolio(source), + _without_portfolio(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("ALERT_NOTIFICATION_STRICT|"): + base_mode = contract.mode.removeprefix("ALERT_NOTIFICATION_STRICT|") + verified = fingerprint_compatibility( + source, restored, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + return ( + verified.compatible + and verified.mode == contract.mode + and _fingerprint_table_contract(source, expected_tables=_pre_lab_execution_tables()) + and _fingerprint_table_contract(restored, expected_tables=_pre_lab_execution_tables()) + and _alert_notification_recovery_matches(source, restored) + and audit_fingerprint_differences_are_compatible( + _without_alert_notifications(source), + _without_alert_notifications(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("ALERT_NOTIFICATION_UPGRADE|"): + base_mode = contract.mode.removeprefix("ALERT_NOTIFICATION_UPGRADE|") + return ( + _alert_notifications_empty(restored) + and not _alert_notifications_have_rows(source) + and audit_fingerprint_differences_are_compatible( + _without_alert_notifications(source), + _without_alert_notifications(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("MODEL_CHANGE_STRICT|"): + base_mode = contract.mode.removeprefix("MODEL_CHANGE_STRICT|") + return ( + _fingerprint_table_contract( + source, + expected_tables=_pre_lab_execution_tables() - PORTFOLIO_TABLES - ALERT_NOTIFICATION_TABLES, + ) + and _fingerprint_table_contract( + restored, + expected_tables=_pre_lab_execution_tables() - PORTFOLIO_TABLES - ALERT_NOTIFICATION_TABLES, + ) + and all( + source["tables"][name] == restored["tables"][name] + for name in MODEL_CHANGE_TABLES + ) + and audit_fingerprint_differences_are_compatible( + _without_model_changes(source), + _without_model_changes(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if contract.mode.startswith("MODEL_CHANGE_UPGRADE|"): + base_mode = contract.mode.removeprefix("MODEL_CHANGE_UPGRADE|") + return ( + _fingerprint_table_contract( + restored, + expected_tables=_pre_lab_execution_tables() - PORTFOLIO_TABLES - ALERT_NOTIFICATION_TABLES, + ) + and _empty_model_change_upgrade(restored) + and audit_fingerprint_differences_are_compatible( + source, + _without_model_changes(restored), + FingerprintCompatibility( + True, + base_mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + ) + if not contract.mode.startswith("SERVING|"): + if "serving_generation_projection" in source.get("tables", {}).get("capability_contracts", {}): + return False + return _deployed_fingerprint_differences_are_compatible(source, restored, contract) + _, schema, core_mode = contract.mode.split("|", 2) + verified = fingerprint_compatibility(source, restored, source_schema_revision=None if schema == "none" else schema) + return (verified.compatible and verified.mode == contract.mode + and _deployed_fingerprint_differences_are_compatible(_generation_for_core(source), restored, + FingerprintCompatibility(True, core_mode, contract.allow_audit_checkpoint_introduction, contract.reason))) + + +def _deployed_fingerprint_compatibility( + source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, +) -> FingerprintCompatibility: + schema = source_schema_revision + source_names = set(source.get("tables", {})) + restored_names = set(restored.get("tables", {})) + if CREATIVE_TABLES & set(restored.get("physical_tables", [])) != CREATIVE_TABLES & restored_names: + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "creative physical state lacks its exact authoritative table proof") + if isinstance(schema, str) and schema >= CREATIVE_SCHEMA: + if schema not in DEPLOYED_SOURCE_SCHEMAS or not _fingerprint_table_contract(source, expected_tables=_deployed_tables(schema)): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "deployed source table contract is incomplete") + if MODEL_UPDATE_SCHEMA <= schema < SELECTED_COMPARISON_SCHEMA and not _valid_watch_projection(source): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, + "legacy watch history requires a freshly qualified hub_controls_projection; opaque old hashes cannot be upgraded") + if schema == SELECTED_COMPARISON_SCHEMA and "hub_controls_projection" in source.get("tables", {}).get("model_update_watches", {}): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "current watch controls cannot be projected away") + elif CREATIVE_TABLES & (source_names | set(source.get("physical_tables", []))): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "creative tables predate their declared migration") + if CREATIVE_TABLES & restored_names: + restored_schema = (SELECTED_COMPARISON_SCHEMA if SELECTED_COMPARISON_TABLES & restored_names + else CREATIVE_GOVERNANCE_SCHEMA if (CREATIVE_TABLES - {CREATIVE_ALLOCATION_TABLE}) & restored_names + else MODEL_UPDATE_SCHEMA if MODEL_UPDATE_TABLES & restored_names else CREATIVE_SCHEMA) + if not _fingerprint_table_contract(restored, expected_tables=_deployed_tables(restored_schema)): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "deployed restore table contract is incomplete") + if restored_schema == SELECTED_COMPARISON_SCHEMA and "hub_controls_projection" in restored.get("tables", {}).get("model_update_watches", {}): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "current restored watch controls cannot be projected away") + if any(name not in restored_names for name in CREATIVE_TABLES & source_names): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "creative history disappeared") + introduced = {name: restored["tables"][name] for name in (CREATIVE_TABLES & restored_names) - source_names} + if not _creative_recovery_matches({"tables": {}}, {"tables": introduced}): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "new creative tables must be exactly empty") + core_schema = (SECRET_SCHEMA if schema == CREATIVE_SCHEMA + else MODEL_UPDATE_SCHEMA if isinstance(schema, str) and MODEL_UPDATE_SCHEMA <= schema < SELECTED_COMPARISON_SCHEMA else schema) + core = _core_fingerprint_compatibility(_watch_controls_for_core(_without_creative(source)), + _watch_controls_for_core(_without_creative(restored)), source_schema_revision=core_schema) + if not CREATIVE_TABLES & (source_names | restored_names): + return core + return FingerprintCompatibility(core.compatible, f"DEPLOYED|{schema or 'none'}|{core.mode}", + core.allow_audit_checkpoint_introduction, core.reason) + + +def _deployed_fingerprint_differences_are_compatible( + source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if not contract.mode.startswith("DEPLOYED|"): + if CREATIVE_TABLES & (set(source.get("tables", {})) | set(restored.get("tables", {})) + | set(source.get("physical_tables", [])) | set(restored.get("physical_tables", []))): + return False + return _core_fingerprint_differences_are_compatible(source, restored, contract) + _, schema, core_mode = contract.mode.split("|", 2) + verified = _deployed_fingerprint_compatibility(source, restored, source_schema_revision=None if schema == "none" else schema) + return (verified.compatible and verified.mode == contract.mode + and _creative_recovery_matches(source, restored) + and _core_fingerprint_differences_are_compatible( + _watch_controls_for_core(_without_creative(source)), _watch_controls_for_core(_without_creative(restored)), + FingerprintCompatibility(True, core_mode, contract.allow_audit_checkpoint_introduction, contract.reason))) + + +def _core_fingerprint_compatibility( + source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, +) -> FingerprintCompatibility: + if source_schema_revision == SELECTED_COMPARISON_SCHEMA: + valid = (_fingerprint_table_contract(source, expected_tables=_core_tables()) + and _fingerprint_table_contract(restored, expected_tables=_core_tables())) + return FingerprintCompatibility(valid, "SELECTED_COMPARISON_STRICT" if valid else "INCOMPATIBLE", + False, "comparison history requires the exact interrupted restore projection") + if SELECTED_COMPARISON_TABLES & set(restored.get("tables", {})): + if (not _fingerprint_table_contract(restored, expected_tables=_core_tables()) + or not _empty_selected_comparison_upgrade(restored)): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, + "unexpected selected comparison history during upgrade") + prior = _model_update_fingerprint_compatibility(source, _without_selected_comparisons(restored), + source_schema_revision=source_schema_revision) + return FingerprintCompatibility(prior.compatible, "SELECTED_COMPARISON_UPGRADE_" + prior.mode, + prior.allow_audit_checkpoint_introduction, prior.reason) + return _model_update_fingerprint_compatibility(source, restored, source_schema_revision=source_schema_revision) + + +def _core_fingerprint_differences_are_compatible( + source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if contract.mode == "SELECTED_COMPARISON_STRICT": + return (not contract.allow_audit_checkpoint_introduction + and _fingerprint_table_contract(source, expected_tables=_core_tables()) + and _fingerprint_table_contract(restored, expected_tables=_core_tables()) + and _selected_comparison_recovery_matches(source, restored) + and _model_update_fingerprint_differences_are_compatible( + _without_selected_comparisons(source), _without_selected_comparisons(restored), + FingerprintCompatibility(True, "MODEL_UPDATE_STRICT", False, contract.reason))) + if contract.mode.startswith("SELECTED_COMPARISON_UPGRADE_"): + return (_fingerprint_table_contract(restored, expected_tables=_core_tables()) + and _empty_selected_comparison_upgrade(restored) + and _model_update_fingerprint_differences_are_compatible( + source, _without_selected_comparisons(restored), + FingerprintCompatibility(True, contract.mode.removeprefix("SELECTED_COMPARISON_UPGRADE_"), + contract.allow_audit_checkpoint_introduction, contract.reason))) + return _model_update_fingerprint_differences_are_compatible(source, restored, contract) + + +def _model_update_fingerprint_compatibility( + source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, +) -> FingerprintCompatibility: + if source_schema_revision == MODEL_UPDATE_SCHEMA: + valid = (_fingerprint_table_contract(source, expected_tables=_pre_comparison_tables()) + and _fingerprint_table_contract(restored, expected_tables=_pre_comparison_tables())) + return FingerprintCompatibility(valid, "MODEL_UPDATE_STRICT" if valid else "INCOMPATIBLE", + False, "selected-model watch and notice history must be retained exactly") + if MODEL_UPDATE_TABLES & set(restored.get("tables", {})): + if (not _fingerprint_table_contract(restored, expected_tables=_pre_comparison_tables()) + or not _empty_model_update_upgrade(restored)): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, + "unexpected model update state during upgrade") + prior = _secret_fingerprint_compatibility(source, _without_model_updates(restored), + source_schema_revision=source_schema_revision) + return FingerprintCompatibility(prior.compatible, "MODEL_UPDATE_UPGRADE_" + prior.mode, + prior.allow_audit_checkpoint_introduction, prior.reason) + return _secret_fingerprint_compatibility(source, restored, source_schema_revision=source_schema_revision) + + +def _model_update_fingerprint_differences_are_compatible( + source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if contract.mode == "MODEL_UPDATE_STRICT": + return (not contract.allow_audit_checkpoint_introduction + and _fingerprint_table_contract(source, expected_tables=_pre_comparison_tables()) + and _fingerprint_table_contract(restored, expected_tables=_pre_comparison_tables()) + and all(source["tables"][name] == restored["tables"][name] for name in MODEL_UPDATE_TABLES) + and _secret_fingerprint_differences_are_compatible( + _without_model_updates(source), _without_model_updates(restored), + FingerprintCompatibility(True, "SECRET_STRICT", False, contract.reason))) + if contract.mode.startswith("MODEL_UPDATE_UPGRADE_"): + return (_fingerprint_table_contract(restored, expected_tables=_pre_comparison_tables()) + and _empty_model_update_upgrade(restored) + and _secret_fingerprint_differences_are_compatible( + source, _without_model_updates(restored), + FingerprintCompatibility(True, contract.mode.removeprefix("MODEL_UPDATE_UPGRADE_"), + contract.allow_audit_checkpoint_introduction, contract.reason))) + return _secret_fingerprint_differences_are_compatible(source, restored, contract) + + +def _secret_fingerprint_compatibility( + source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, +) -> FingerprintCompatibility: + if source_schema_revision == SECRET_SCHEMA: + valid = (_fingerprint_table_contract(source, expected_tables=_pre_updates_tables()) + and _fingerprint_table_contract(restored, expected_tables=_pre_updates_tables())) + return FingerprintCompatibility(valid, "SECRET_STRICT" if valid else "INCOMPATIBLE", False, + "secret version metadata must be retained exactly") + if SECRET_TABLES & set(restored.get("tables", {})): + if (not _fingerprint_table_contract(restored, expected_tables=_pre_updates_tables()) + or not _empty_secret_upgrade(restored)): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "unexpected secret state during upgrade") + old = _managed_fingerprint_compatibility(source, _without_secrets(restored), + source_schema_revision=source_schema_revision) + return FingerprintCompatibility(old.compatible, "SECRET_UPGRADE_" + old.mode, + old.allow_audit_checkpoint_introduction, old.reason) + return _managed_fingerprint_compatibility(source, restored, source_schema_revision=source_schema_revision) + + +def _secret_fingerprint_differences_are_compatible( + source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if contract.mode == "SECRET_STRICT": + return (not contract.allow_audit_checkpoint_introduction + and _fingerprint_table_contract(source, expected_tables=_pre_updates_tables()) + and _fingerprint_table_contract(restored, expected_tables=_pre_updates_tables()) + and all(source["tables"][name] == restored["tables"][name] for name in SECRET_TABLES) + and _managed_fingerprint_differences_are_compatible( + _without_secrets(source), _without_secrets(restored), + FingerprintCompatibility(True, "MANAGED_STRICT", False, contract.reason))) + if contract.mode.startswith("SECRET_UPGRADE_"): + return (_fingerprint_table_contract(restored, expected_tables=_pre_updates_tables()) + and _empty_secret_upgrade(restored) + and _managed_fingerprint_differences_are_compatible( + source, _without_secrets(restored), + FingerprintCompatibility(True, contract.mode.removeprefix("SECRET_UPGRADE_"), + contract.allow_audit_checkpoint_introduction, contract.reason))) + return _managed_fingerprint_differences_are_compatible(source, restored, contract) + + +def _managed_fingerprint_compatibility( + source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, +) -> FingerprintCompatibility: + if source_schema_revision == MANAGED_IDENTITY_SCHEMA: + valid = (_fingerprint_table_contract(source, expected_tables=(_pre_updates_tables() - SECRET_TABLES)) + and _fingerprint_table_contract(restored, expected_tables=(_pre_updates_tables() - SECRET_TABLES))) + return FingerprintCompatibility(valid, "MANAGED_STRICT" if valid else "INCOMPATIBLE", False, + "managed identity history must be retained exactly") + if MANAGED_IDENTITY_TABLES & set(restored.get("tables", {})): + if (not _fingerprint_table_contract(restored, expected_tables=(_pre_updates_tables() - SECRET_TABLES)) + or not _empty_managed_identity_upgrade(restored)): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "unexpected managed identity state during upgrade") + old = _project_fingerprint_compatibility(source, _without_managed_identities(restored), + source_schema_revision=source_schema_revision) + return FingerprintCompatibility(old.compatible, "MANAGED_UPGRADE_" + old.mode, + old.allow_audit_checkpoint_introduction, old.reason) + return _project_fingerprint_compatibility(source, restored, source_schema_revision=source_schema_revision) + + +def _managed_fingerprint_differences_are_compatible( + source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if contract.mode == "MANAGED_STRICT": + return (not contract.allow_audit_checkpoint_introduction + and _fingerprint_table_contract(source, expected_tables=(_pre_updates_tables() - SECRET_TABLES)) + and _fingerprint_table_contract(restored, expected_tables=(_pre_updates_tables() - SECRET_TABLES)) + and all(source["tables"][name] == restored["tables"][name] + for name in MANAGED_IDENTITY_TABLES) + and _project_fingerprint_differences_are_compatible( + _without_managed_identities(source), _without_managed_identities(restored), + FingerprintCompatibility(True, "PROJECT_STRICT", False, contract.reason))) + if contract.mode.startswith("MANAGED_UPGRADE_"): + return (_fingerprint_table_contract(restored, expected_tables=(_pre_updates_tables() - SECRET_TABLES)) + and _empty_managed_identity_upgrade(restored) + and _project_fingerprint_differences_are_compatible( + source, _without_managed_identities(restored), + FingerprintCompatibility(True, contract.mode.removeprefix("MANAGED_UPGRADE_"), + contract.allow_audit_checkpoint_introduction, contract.reason))) + return _project_fingerprint_differences_are_compatible(source, restored, contract) + + +def _project_fingerprint_compatibility( + source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, +) -> FingerprintCompatibility: + if source_schema_revision == PROJECT_SCHEMA: + valid = _fingerprint_table_contract(source, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES)) and _fingerprint_table_contract(restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES)) + return FingerprintCompatibility(valid, "PROJECT_STRICT" if valid else "INCOMPATIBLE", False, "schema 0026 retains onboarding and credential history exactly") + if PROJECT_TABLES & set(restored.get("tables", {})): + if not _fingerprint_table_contract(restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES)) or not _empty_project_upgrade(restored): + return FingerprintCompatibility(False, "INCOMPATIBLE", False, "unexpected project or credential state during upgrade") + old = _configuration_fingerprint_compatibility(source, _without_projects(restored, legacy=True), source_schema_revision=source_schema_revision) + return FingerprintCompatibility(old.compatible, "PROJECT_UPGRADE_" + old.mode, old.allow_audit_checkpoint_introduction, old.reason) + return _configuration_fingerprint_compatibility(source, restored, source_schema_revision=source_schema_revision) + + +def _project_fingerprint_differences_are_compatible( + source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if contract.mode == "PROJECT_STRICT": + return (not contract.allow_audit_checkpoint_introduction + and _fingerprint_table_contract(source, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES)) + and _fingerprint_table_contract(restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES)) + and all(source["tables"][name] == restored["tables"][name] + for name in PROJECT_TABLES | {"service_credentials"}) + and _configuration_fingerprint_differences_are_compatible( + _without_projects(source, legacy=False), _without_projects(restored, legacy=False), + FingerprintCompatibility(True, "CONFIGURATION_STRICT", False, contract.reason))) + if contract.mode.startswith("PROJECT_UPGRADE_"): + return (_fingerprint_table_contract(restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES)) + and _empty_project_upgrade(restored) + and _configuration_fingerprint_differences_are_compatible( + source, _without_projects(restored, legacy=True), + FingerprintCompatibility(True, contract.mode.removeprefix("PROJECT_UPGRADE_"), contract.allow_audit_checkpoint_introduction, contract.reason))) + return _configuration_fingerprint_differences_are_compatible(source, restored, contract) + + +def _configuration_fingerprint_compatibility( source: dict[str, Any], restored: dict[str, Any], *, source_schema_revision: str | None, ) -> FingerprintCompatibility: - """Return the only supported semantic-fingerprint comparison modes. + if source_schema_revision == CONFIGURATION_SCHEMA: + if _fingerprint_table_contract( + source, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - PROJECT_TABLES + ) and _fingerprint_table_contract(restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - PROJECT_TABLES): + old = _audit_fingerprint_compatibility( + _without_configuration(source), + _without_configuration(restored), + source_schema_revision=AUDIT_CHECKPOINT_CURRENT_SCHEMA, + ) + if old.compatible: + return FingerprintCompatibility( + True, + "CONFIGURATION_STRICT", + False, + "schema 0025 retains configuration history exactly", + ) + return FingerprintCompatibility( + False, "INCOMPATIBLE", False, "schema 0025 requires complete configuration tables" + ) + if CONFIGURATION_TABLES & set(restored.get("tables", {})): + if _fingerprint_table_contract( + restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - PROJECT_TABLES + ) and _configuration_is_empty(restored): + old = _audit_fingerprint_compatibility( + source, + _without_configuration(restored), + source_schema_revision=source_schema_revision, + ) + if old.compatible: + return FingerprintCompatibility( + True, + "CONFIGURATION_UPGRADE_" + old.mode, + old.allow_audit_checkpoint_introduction, + "migration introduces exactly two empty configuration tables", + ) + return FingerprintCompatibility( + False, "INCOMPATIBLE", False, "unexpected configuration state during upgrade" + ) + return _audit_fingerprint_compatibility( + source, restored, source_schema_revision=source_schema_revision + ) - ``m15.2`` is strict and requires the singleton checkpoint on both sides. The one cross-version - exception is a genuine schema-0022 ``m15.1`` backup: 0022 had no checkpoint table, while the - 0024 migration must introduce exactly one verified head. No other missing/new table or version - pairing is interpreted as a migration side effect. - """ +def _configuration_fingerprint_differences_are_compatible( + source: dict[str, Any], + restored: dict[str, Any], + contract: FingerprintCompatibility, +) -> bool: + if not contract.compatible: + return False + if contract.mode == "CONFIGURATION_STRICT": + if ( + contract.allow_audit_checkpoint_introduction + or not _fingerprint_table_contract(source, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - PROJECT_TABLES) + or not _fingerprint_table_contract(restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - PROJECT_TABLES) + or any( + source["tables"][name] != restored["tables"][name] for name in CONFIGURATION_TABLES + ) + ): + return False + return _audit_fingerprint_differences_are_compatible( + _without_configuration(source), + _without_configuration(restored), + FingerprintCompatibility(True, "CURRENT_STRICT", False, contract.reason), + ) + if contract.mode.startswith("CONFIGURATION_UPGRADE_"): + if not _fingerprint_table_contract( + restored, expected_tables=((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - PROJECT_TABLES + ) or not _configuration_is_empty(restored): + return False + return _audit_fingerprint_differences_are_compatible( + source, + _without_configuration(restored), + FingerprintCompatibility( + True, + contract.mode.removeprefix("CONFIGURATION_UPGRADE_"), + contract.allow_audit_checkpoint_introduction, + contract.reason, + ), + ) + return _audit_fingerprint_differences_are_compatible(source, restored, contract) + + +def _audit_fingerprint_compatibility( + source: dict[str, Any], + restored: dict[str, Any], + *, + source_schema_revision: str | None, +) -> FingerprintCompatibility: source_version = source.get("version") restored_version = restored.get("version") - current_tables = classified_tables() + current_tables = ((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - CONFIGURATION_TABLES - PROJECT_TABLES legacy_tables = current_tables - {"audit_chain_heads"} if ( source_schema_revision == AUDIT_CHECKPOINT_CURRENT_SCHEMA @@ -329,9 +1867,7 @@ def fingerprint_compatibility( and _fingerprint_table_contract(restored, expected_tables=current_tables) and _has_single_complete_checkpoint(source) and _has_single_complete_checkpoint(restored) - and _physical_table_transition_is_exact( - source, restored, introduce_checkpoint=False - ) + and _physical_table_transition_is_exact(source, restored, introduce_checkpoint=False) ): return FingerprintCompatibility( compatible=True, @@ -350,9 +1886,7 @@ def fingerprint_compatibility( ) and _fingerprint_table_contract(restored, expected_tables=current_tables) and _has_single_complete_checkpoint(restored) - and _physical_table_transition_is_exact( - source, restored, introduce_checkpoint=True - ) + and _physical_table_transition_is_exact(source, restored, introduce_checkpoint=True) ): return FingerprintCompatibility( compatible=True, @@ -372,16 +1906,14 @@ def fingerprint_compatibility( ) -def audit_fingerprint_differences_are_compatible( +def _audit_fingerprint_differences_are_compatible( source: dict[str, Any], restored: dict[str, Any], contract: FingerprintCompatibility, ) -> bool: - """Require exact authoritative tables except the enumerated reconciliation mutations.""" - if not contract.compatible: return False - current_tables = classified_tables() + current_tables = ((_pre_updates_tables() - SECRET_TABLES) - MANAGED_IDENTITY_TABLES) - CONFIGURATION_TABLES - PROJECT_TABLES if contract.mode == "CURRENT_STRICT": if ( contract.allow_audit_checkpoint_introduction @@ -391,9 +1923,7 @@ def audit_fingerprint_differences_are_compatible( or not _fingerprint_table_contract(restored, expected_tables=current_tables) or not _has_single_complete_checkpoint(source) or not _has_single_complete_checkpoint(restored) - or not _physical_table_transition_is_exact( - source, restored, introduce_checkpoint=False - ) + or not _physical_table_transition_is_exact(source, restored, introduce_checkpoint=False) ): return False elif contract.mode == "SCHEMA_0022_TO_0024": @@ -408,9 +1938,7 @@ def audit_fingerprint_differences_are_compatible( ) or not _fingerprint_table_contract(restored, expected_tables=current_tables) or not _has_single_complete_checkpoint(restored) - or not _physical_table_transition_is_exact( - source, restored, introduce_checkpoint=True - ) + or not _physical_table_transition_is_exact(source, restored, introduce_checkpoint=True) ): return False else: @@ -422,14 +1950,8 @@ def audit_fingerprint_differences_are_compatible( def same_static_fields(left: dict[str, Any], right: dict[str, Any]) -> bool: return { - key: value - for key, value in left.items() - if key not in {"row_count", "digest"} - } == { - key: value - for key, value in right.items() - if key not in {"row_count", "digest"} - } + key: value for key, value in left.items() if key not in {"row_count", "digest"} + } == {key: value for key, value in right.items() if key not in {"row_count", "digest"}} changed: set[str] = set() for table in sorted(set(source_tables) | set(restored_tables)): @@ -481,9 +2003,11 @@ def classified_tables() -> frozenset[str]: return frozenset(name for tables in FINGERPRINT_GROUPS.values() for name in tables) -def unclassified_tables() -> frozenset[str]: - """Any persisted table must be either fingerprinted or explicitly current-truth.""" +def _pre_lab_execution_tables() -> frozenset[str]: + return classified_tables() - LAB_EXECUTION_TABLES + +def unclassified_tables() -> frozenset[str]: return frozenset(Base.metadata.tables) - classified_tables() - CURRENT_TRUTH_TABLES @@ -514,9 +2038,9 @@ def _canonical_cell_payload(value: Any) -> tuple[bytes, bytes]: if isinstance(value, bytes | bytearray | memoryview): return b"y", bytes(value) if isinstance(value, dict | list | tuple): - payload = json.dumps( - value, sort_keys=True, separators=(",", ":"), default=str - ).encode("utf-8") + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode( + "utf-8" + ) return b"j", payload if isinstance(value, str): return b"s", value.encode("utf-8") @@ -534,17 +2058,18 @@ def _canonical_row(values: Any) -> bytes: return b"r" + len(payload).to_bytes(8, byteorder="big") + payload -def _table_digest(connection: Connection, table: Table) -> dict[str, Any]: +def _table_digest(connection: Connection, table: Table, *, exclude: frozenset[str] = frozenset(), + include_comparison_projection: bool = False, include_creative_projection: bool = False, + include_watch_controls: bool = False, include_serving_generation: bool = False, + include_alert_notification_projection: bool = False) -> dict[str, Any]: columns = [ column for column in sorted(table.columns, key=lambda item: item.name) - if not _sensitive(column.name) + if not _sensitive(column.name) and column.name not in exclude ] redacted = sorted(column.name for column in table.columns if _sensitive(column.name)) if not columns: - row_count = int( - connection.scalar(select(func.count()).select_from(table)) or 0 - ) + row_count = int(connection.scalar(select(func.count()).select_from(table)) or 0) return { "row_count": row_count, "digest": hashlib.sha256(_canonical_row((row_count,))).hexdigest(), @@ -553,31 +2078,101 @@ def _table_digest(connection: Connection, table: Table) -> dict[str, Any]: } statement = select(*columns).select_from(table) hashes: list[bytes] = [] + projected_hashes: list[bytes] = [] + names = [column.name for column in columns] truncated = False for row in connection.execute(statement).yield_per(2_000): if len(hashes) >= MAX_ROWS_PER_TABLE: truncated = True break - hashes.append(hashlib.sha256(_canonical_row(row)).digest()) + values = list(row) + hashes.append(hashlib.sha256(_canonical_row(values)).digest()) + if include_comparison_projection: + status_index = names.index("status") + if values[status_index] in SELECTED_COMPARISON_ACTIVE_STATUSES: + values[status_index] = "interrupted" + values[names.index("version")] += 1 + values[names.index("error_code")] = SELECTED_COMPARISON_RESTORE_ERROR + projected_hashes.append(hashlib.sha256(_canonical_row(values)).digest()) + elif include_creative_projection: + if values[names.index("state")] in {"queued", "granted"}: + values[names.index("state")] = "uncertain" + values[names.index("decision_code")] = CREATIVE_RESTORE_ERROR + projected_hashes.append(hashlib.sha256(_canonical_row(values)).digest()) + elif include_serving_generation: + projected_values = dict(zip(names, values, strict=True)) + projected_values["serving_generation"] = 0 + projected_hashes.append(hashlib.sha256(_canonical_row( + [projected_values[name] for name in sorted(projected_values)])).digest()) + elif include_alert_notification_projection: + projected_values = dict(zip(names, values, strict=True)) + if projected_values.get("status") in ALERT_NOTIFICATION_RESTORABLE_STATES: + projected_values["status"] = "HELD_FOR_REVIEW" + projected_values["next_attempt_at"] = None + projected_values["lease_expires_at"] = None + projected_hashes.append(hashlib.sha256(_canonical_row( + [projected_values[name] for name in names])).digest()) + elif include_watch_controls: + projected_values = {name: value for name, value in zip(names, values, strict=True) + if name not in MODEL_UPDATE_TRANSIENT_COLUMNS} + projected_values.update(enabled=True, version=1) + projected_hashes.append(hashlib.sha256(_canonical_row( + [projected_values[name] for name in sorted(projected_values)])).digest()) hashes.sort() aggregate = hashlib.sha256() for item in hashes: aggregate.update(len(item).to_bytes(4, byteorder="big")) aggregate.update(item) - return { + entry = { "row_count": len(hashes), "digest": aggregate.hexdigest(), "status": "BOUNDED" if truncated else "COMPLETE", "redacted_columns": redacted, } + if (include_comparison_projection or include_creative_projection or include_watch_controls + or include_serving_generation or include_alert_notification_projection): + projected_hashes.sort() + projected = hashlib.sha256() + for item in projected_hashes: + projected.update(len(item).to_bytes(4, byteorder="big")) + projected.update(item) + projection_key = ("restore_projection" if include_alert_notification_projection else + "serving_generation_projection" if include_serving_generation else + "hub_controls_projection" if include_watch_controls else "restore_projection") + entry[projection_key] = {**entry, "digest": projected.hexdigest()} + return entry + + +def _actual_table(connection: Connection, name: str) -> Table: + declared = Base.metadata.tables[name] + return Table(name, MetaData(), *(Column(column["name"], + declared.columns[column["name"]].type if column["name"] in declared.columns else column["type"]) + for column in inspect(connection).get_columns(name))) def semantic_fingerprint(connection: Connection) -> dict[str, Any]: - """Compute the bounded semantic fingerprint of one control-plane database.""" - tables: dict[str, Any] = {} groups: dict[str, Any] = {} inspector_tables = set(connection.dialect.get_table_names(connection)) + benchmark_run_columns = ( + {column["name"] for column in inspect(connection).get_columns("benchmark_runs")} + if "benchmark_runs" in inspector_tables + else set() + ) + if "alembic_version" in inspector_tables: + schema = connection.scalar(text("SELECT version_num FROM alembic_version")) + else: + # Metadata-created databases can already carry the current benchmark shape without an + # Alembic marker. Infer that shape from physical columns so operator state cannot be + # silently treated as a legacy 0038 row. Partial additions are marked below and reject + # compatibility rather than being projected. + benchmark_columns_present = benchmark_run_columns & BENCHMARK_RUN_MIGRATION_COLUMNS + schema = ( + BENCHMARK_RUN_SCHEMA + if benchmark_columns_present + else LAB_EXECUTION_SCHEMA + ) + legacy_projects = isinstance(schema, str) and schema < PROJECT_SCHEMA unexpected_tables = sorted( inspector_tables - classified_tables() @@ -585,6 +2180,30 @@ def semantic_fingerprint(connection: Connection) -> dict[str, Any]: - NON_FINGERPRINT_INFRASTRUCTURE_TABLES ) for group, names in FINGERPRINT_GROUPS.items(): + if isinstance(schema, str) and schema < LAB_EXECUTION_SCHEMA: + names = tuple(name for name in names if name not in LAB_EXECUTION_TABLES) + if isinstance(schema, str) and schema < ALERT_NOTIFICATION_SCHEMA: + names = tuple(name for name in names if name not in ALERT_NOTIFICATION_TABLES) + if isinstance(schema, str) and schema < PORTFOLIO_SCHEMA: + names = tuple(name for name in names if name not in PORTFOLIO_TABLES) + if isinstance(schema, str) and schema < MODEL_CHANGE_SCHEMA: + names = tuple(name for name in names if name not in MODEL_CHANGE_TABLES) + if isinstance(schema, str) and schema < CREATIVE_SCHEMA: + names = tuple(name for name in names if name not in CREATIVE_TABLES) + elif isinstance(schema, str) and schema < CREATIVE_GOVERNANCE_SCHEMA: + names = tuple(name for name in names if name not in CREATIVE_TABLES - {CREATIVE_ALLOCATION_TABLE}) + if isinstance(schema, str) and schema < SELECTED_COMPARISON_SCHEMA: + names = tuple(name for name in names if name not in SELECTED_COMPARISON_TABLES) + if isinstance(schema, str) and schema < MODEL_UPDATE_SCHEMA: + names = tuple(name for name in names if name not in MODEL_UPDATE_TABLES) + if isinstance(schema, str) and schema < SECRET_SCHEMA: + names = tuple(name for name in names if name not in SECRET_TABLES) + if isinstance(schema, str) and schema < MANAGED_IDENTITY_SCHEMA: + names = tuple(name for name in names if name not in MANAGED_IDENTITY_TABLES) + if legacy_projects: + names = tuple(name for name in names if name not in PROJECT_TABLES) + if not names: + continue group_hash = hashlib.sha256() group_rows = 0 for name in sorted(names): @@ -593,7 +2212,118 @@ def semantic_fingerprint(connection: Connection) -> dict[str, Any]: tables[name] = {"row_count": None, "digest": None, "status": "ABSENT"} group_hash.update(_canonical_row(("table", name, "ABSENT", None, None))) continue - entry = _table_digest(connection, table) + old_watch = name == "model_update_watches" and isinstance(schema, str) and schema < SELECTED_COMPARISON_SCHEMA + old_lab = name == "serving_jobs" and isinstance(schema, str) and schema < LAB_EXECUTION_SCHEMA + old_benchmark_runs = ( + name == "benchmark_runs" + and isinstance(schema, str) + and schema < BENCHMARK_RUN_SCHEMA + ) + stale_benchmark_runs_schema = ( + old_benchmark_runs + and bool(benchmark_run_columns & BENCHMARK_RUN_MIGRATION_COLUMNS) + ) + incomplete_benchmark_runs = ( + name == "benchmark_runs" + and isinstance(schema, str) + and schema >= BENCHMARK_RUN_SCHEMA + and not benchmark_run_columns >= BENCHMARK_RUN_MIGRATION_COLUMNS + ) + if ( + old_watch + or old_lab + or old_benchmark_runs + or incomplete_benchmark_runs + or name == "capability_contracts" + ): + table = _actual_table(connection, name) + old_generation = name == "capability_contracts" and "serving_generation" not in table.c + exclude = (frozenset({"superseded_at"}) if legacy_projects and name == "service_credentials" + else MODEL_UPDATE_TRANSIENT_COLUMNS if name == "model_update_watches" and not old_watch + else SELECTED_COMPARISON_TRANSIENT_COLUMNS if name in SELECTED_COMPARISON_TABLES + else BENCHMARK_RUN_MIGRATION_COLUMNS + if old_benchmark_runs and not stale_benchmark_runs_schema + else frozenset()) + entry = _table_digest(connection, table, exclude=exclude, + include_comparison_projection=name in SELECTED_COMPARISON_TABLES, + include_creative_projection=name == CREATIVE_ALLOCATION_TABLE, + include_watch_controls=old_watch, + include_serving_generation=old_generation, + include_alert_notification_projection=name in ALERT_NOTIFICATION_TABLES) + if ( + name == "benchmark_runs" + and isinstance(schema, str) + and schema >= BENCHMARK_RUN_SCHEMA + and not incomplete_benchmark_runs + ): + non_legacy_columns = ( + table.c[column_name].is_not(None) + for column_name in ( + "capability_deployment_id", + "target_snapshot", + "actor_subject", + "idempotency_key", + "request_fingerprint", + "failure_code", + "cancellation_requested_at", + ) + ) + non_legacy_predicate = or_( + *non_legacy_columns, + table.c.created_at.is_(None), + table.c.generation.is_(None), + table.c.generation != 1, + ) + entry[BENCHMARK_RUN_SCHEMA_PROJECTION] = { + "base": _table_digest( + connection, + table, + exclude=BENCHMARK_RUN_MIGRATION_COLUMNS, + ), + "non_legacy_row_count": int( + connection.scalar( + select(func.count()).select_from(table).where(non_legacy_predicate) + ) + or 0 + ), + } + elif incomplete_benchmark_runs or stale_benchmark_runs_schema: + entry[BENCHMARK_RUN_SCHEMA_OBSERVED] = True + if name == "service_credentials" and not legacy_projects: + entry["pre_onboarding"] = _table_digest(connection, table, exclude=frozenset({"superseded_at"})) + entry["superseded_count"] = int(connection.scalar(select(func.count()).select_from(table).where(table.c.superseded_at.is_not(None))) or 0) + if ( + name == "serving_jobs" + and isinstance(schema, str) + and schema >= LAB_EXECUTION_SCHEMA + and "lab_execution_run_id" in table.c + ): + linked_jobs = list( + connection.execute( + select(table.c.id, table.c.lab_execution_run_id) + .where(table.c.lab_execution_run_id.is_not(None)) + .order_by(table.c.id) + ) + ) + links = hashlib.sha256() + for job_id, run_id in linked_jobs: + links.update(_canonical_row((job_id, run_id))) + entry["lab_execution_projection"] = { + "base": _table_digest( + connection, + table, + exclude=frozenset({"lab_execution_run_id"}), + ), + "run_link_count": len(linked_jobs), + "run_link_digest": links.hexdigest(), + } + elif ( + name == "serving_jobs" + and isinstance(schema, str) + and schema < LAB_EXECUTION_SCHEMA + and "lab_execution_run_id" in table.c + ): + entry["lab_execution_schema_observed"] = True tables[name] = entry group_rows += int(entry["row_count"] or 0) group_hash.update( @@ -608,6 +2338,17 @@ def semantic_fingerprint(connection: Connection) -> dict[str, Any]: ) ) ) + for projection_key in ( + "restore_projection", + "hub_controls_projection", + "serving_generation_projection", + "lab_execution_projection", + "lab_execution_schema_observed", + BENCHMARK_RUN_SCHEMA_PROJECTION, + BENCHMARK_RUN_SCHEMA_OBSERVED, + ): + if projection_key in entry: + group_hash.update(_canonical_row((projection_key, entry[projection_key]))) groups[group] = { "digest": group_hash.hexdigest(), "row_count": group_rows, @@ -647,8 +2388,6 @@ def fingerprint_session(session: Session) -> dict[str, Any]: def diff_fingerprints(source: dict[str, Any], restored: dict[str, Any]) -> dict[str, Any]: - """Explain exactly which subjects differ, so a restore is never declared good by hand-wave.""" - source_tables: dict[str, Any] = source.get("tables", {}) restored_tables: dict[str, Any] = restored.get("tables", {}) identical: list[str] = [] @@ -684,8 +2423,7 @@ def diff_fingerprints(source: dict[str, Any], restored: dict[str, Any]) -> dict[ "restored_digest": restored.get("digest"), "source_physical_tables": source.get("physical_tables"), "restored_physical_tables": restored.get("physical_tables"), - "physical_tables_match": source.get("physical_tables") - == restored.get("physical_tables"), + "physical_tables_match": source.get("physical_tables") == restored.get("physical_tables"), "identical_table_count": len(identical), "differing_groups": group_differences, "differences": differences, diff --git a/backend/src/modelforge_api/services/secret_slots.py b/backend/src/modelforge_api/services/secret_slots.py new file mode 100644 index 0000000..f4f2837 --- /dev/null +++ b/backend/src/modelforge_api/services/secret_slots.py @@ -0,0 +1,306 @@ +"""A single versioned, write-only HF control-plane secret; no generic vault.""" +from __future__ import annotations + +import base64 +import binascii +import json +import os +import time +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from typing import Any, Literal + +import httpx +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from sqlalchemy import delete, func, select, update +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Principal, PrincipalRole +from modelforge_api.db import rows_affected +from modelforge_api.domain.secret_slots import ( + SLOT, + DeploymentSecretMetadata, + SecretSlotCatalog, + SecretSlotMetadata, + SecretSlotRequest, + SecretSlotRevoke, + SecretSlotSet, + SecretSlotVersionRequest, + SecretVersionMetadata, +) +from modelforge_api.persistence.models import ( + ManagedOperatorCredential, + ManagedOperatorCredentialActivation, + ManagedOperatorIdentity, + ManagedOperatorState, + SecretSlotActivation, + SecretSlotState, + SecretSlotTest, + SecretSlotVersion, +) +from modelforge_api.providers.huggingface import OfficialHuggingFaceProvider, ProviderSnapshot +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.settings import Settings + + +class SecretSlotError(RuntimeError): + def __init__(self, status_code: int, code: str, message: str) -> None: + super().__init__(message) + self.status_code, self.code, self.message = status_code, code, message + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value + + +def _root_key(settings: Settings) -> bytes: + try: + key = base64.b64decode(settings.secret_slot_root_key.get_secret_value(), validate=True) if settings.secret_slot_root_key else b"" + if len(key) != 32: + raise ValueError + return key + except (ValueError, binascii.Error): + raise SecretSlotError(503, "SECRET_SLOT_KEY_UNAVAILABLE", "A valid external 32-byte slot root key is required") from None + + +def _key_available(settings: Settings) -> bool: + try: + _root_key(settings) + return True + except SecretSlotError: + return False + + +def _aad(version: int, key_id: str) -> bytes: + return f"modelforge:secret-slot:v1:{SLOT}:{version}:{key_id}".encode() + + +def _decrypt(row: SecretSlotVersion, settings: Settings) -> str: + key = _root_key(settings) + if row.key_id != settings.secret_slot_root_key_id: + raise SecretSlotError(503, "SECRET_SLOT_KEY_UNAVAILABLE", "The external key for this version is unavailable") + try: + return AESGCM(key).decrypt(base64.b64decode(row.secret_nonce, validate=True), + base64.b64decode(row.secret_ciphertext, validate=True), _aad(row.version, row.key_id)).decode() + except (InvalidTag, ValueError, UnicodeError, binascii.Error): + raise SecretSlotError(503, "SECRET_SLOT_DECRYPTION_FAILED", "The managed credential cannot be decrypted") from None + + +def test_huggingface_token(token: str) -> tuple[bool, str]: + """Only provider authentication; never follow a response URL or retain response details.""" + deadline = time.monotonic() + 10.0 + try: + with httpx.Client(timeout=httpx.Timeout(10.0), follow_redirects=False, trust_env=False) as client: # noqa: SIM117 + with client.stream("GET", "https://huggingface.co/api/whoami-v2", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json", + "Accept-Encoding": "identity"}) as response: + if response.status_code in (401, 403): + return False, "HF_AUTHENTICATION_REJECTED" + if response.status_code != 200: + return False, "HF_PROVIDER_UNAVAILABLE" + if response.headers.get("content-encoding", "identity").lower() != "identity": + return False, "HF_RESPONSE_INVALID" + body = bytearray() + for chunk in response.iter_raw(): + if time.monotonic() > deadline: + return False, "HF_PROVIDER_TIMEOUT" + if len(body) + len(chunk) > 65536: + return False, "HF_RESPONSE_INVALID" + body.extend(chunk) + if time.monotonic() > deadline: + return False, "HF_PROVIDER_TIMEOUT" + payload = json.loads(body) + if not isinstance(payload, dict) or not isinstance(payload.get("name"), str) or not payload["name"]: + return False, "HF_RESPONSE_INVALID" + return True, "HF_AUTHENTICATED" + except RecursionError: + return False, "HF_RESPONSE_INVALID" + except (httpx.HTTPError, ValueError): + return False, "HF_PROVIDER_UNAVAILABLE" + + +def resolve_huggingface_token(session: Session, settings: Settings) -> str | None: + state = session.get(SecretSlotState, 1) + if state is None: + raise SecretSlotError(503, "SECRET_SLOT_STATE_MISSING", "Secret slot state is unavailable") + if not state.initialized: + return settings.hf_token.get_secret_value() if settings.hf_token else None + activation = session.get(SecretSlotActivation, 1) + row = session.get(SecretSlotVersion, activation.version) if activation else None + if row is None or row.revoked_at is not None: + raise SecretSlotError(409, "SECRET_SLOT_INACTIVE", "Managed HF authentication requires a tested, explicitly activated version") + return _decrypt(row, settings) + + +class SecretAwareHuggingFaceProvider: + """Resolve once when upstream access starts, without blocking local job bookkeeping.""" + + def __init__(self, session: Session, settings: Settings) -> None: + self.session, self.settings = session, settings + self._resolved: OfficialHuggingFaceProvider | None = None + + def resolve(self) -> OfficialHuggingFaceProvider: + if self._resolved is None: + self._resolved = OfficialHuggingFaceProvider( + token=resolve_huggingface_token(self.session, self.settings), + timeout=self.settings.hf_timeout_seconds, + ) + return self._resolved + + def credential_configured(self) -> bool: + return bool(self.resolve().token) + + def search(self, query: str, *, limit: int, sort: str, + pipeline_tag: str | None) -> list[dict[str, Any]]: + return self.resolve().search(query, limit=limit, sort=sort, pipeline_tag=pipeline_tag) + + def snapshot(self, repository_id: str, revision: str) -> ProviderSnapshot: + return self.resolve().snapshot(repository_id, revision) + + +class SecretSlotService: + def __init__(self, session: Session, settings: Settings, principal: Principal, correlation_id: str) -> None: + self.session, self.settings, self.principal = session, settings, principal + self.audit = AuditWriter(session, context=AuditContext("operator", principal.subject, correlation_id)) + + def _state(self) -> SecretSlotState: + state = self.session.get(SecretSlotState, 1) + if state is None: + raise SecretSlotError(503, "SECRET_SLOT_STATE_MISSING", "Secret slot state is unavailable") + return state + + @contextmanager + def _mutation(self, request: SecretSlotRequest) -> Iterator[SecretSlotState]: + if self.principal.role != PrincipalRole.ADMIN: + raise SecretSlotError(403, "SECRET_SLOT_ADMIN_REQUIRED", "Administrator role required") + try: + # Match identity administration lock order; revocation cannot race authorization. + if self.principal.authentication_method == "managed_operator_key": + self.session.execute(update(ManagedOperatorState).where(ManagedOperatorState.singleton_id == 1).values(revision=ManagedOperatorState.revision)) + claimed = self.session.execute(update(SecretSlotState).where(SecretSlotState.singleton_id == 1).values(revision=SecretSlotState.revision)) + if rows_affected(claimed) != 1: + raise SecretSlotError(503, "SECRET_SLOT_STATE_MISSING", "Secret slot state is unavailable") + self.session.expire_all() + if self.principal.authentication_method == "managed_operator_key": + try: + identifier = uuid.UUID(self.principal.credential_id or "") + except ValueError: + raise SecretSlotError(403, "SECRET_SLOT_ADMIN_REQUIRED", "Administrator authorization changed") from None + credential = self.session.get(ManagedOperatorCredential, identifier) + identity = self.session.get(ManagedOperatorIdentity, credential.identity_slug) if credential else None + active = self.session.get(ManagedOperatorCredentialActivation, identifier) + if (credential is None or identity is None or active is None or not identity.enabled or identity.role != "admin" + or credential.revoked_at is not None or _aware(credential.expires_at) <= datetime.now(UTC) + or self.principal.subject != f"managed:{identity.slug}"): + raise SecretSlotError(403, "SECRET_SLOT_ADMIN_REQUIRED", "Administrator authorization changed") + state = self._state() + if state.revision != request.expected_revision: + raise SecretSlotError(409, "SECRET_SLOT_VERSION_CONFLICT", "Secret slot changed; refresh before retrying") + yield state + self.session.commit() + except Exception: + self.session.rollback() + raise + + def metadata(self) -> SecretSlotMetadata: + state = self._state() + rows = list(self.session.scalars(select(SecretSlotVersion).order_by(SecretSlotVersion.version.desc()).limit(10))) + tests = {item.version: item for item in self.session.scalars(select(SecretSlotTest).where(SecretSlotTest.version.in_([row.version for row in rows])))} + active = self.session.get(SecretSlotActivation, 1) + candidate = rows[0] if rows else None + status: Literal["deployment", "pending", "active", "revoked", "quarantined"] = "deployment" + if state.initialized: + status = "active" if active else "revoked" if candidate and candidate.revoked_at else "pending" if candidate and candidate.version in tests else "quarantined" + if candidate and not active and candidate.revoked_at is None and state.revision == 1: + status = "pending" + return SecretSlotMetadata(revision=state.revision, source="managed" if state.initialized else "deployment", + state=status, configured=bool(candidate and candidate.revoked_at is None) if state.initialized else bool(self.settings.hf_token), + root_key_available=_key_available(self.settings), candidate_version=candidate.version if candidate else None, + active_version=active.version if active else None, + versions=[SecretVersionMetadata(version=row.version, key_id=row.key_id, created_at=_aware(row.created_at), + revoked_at=_aware(row.revoked_at) if row.revoked_at else None, + test_status="passed" if row.version in tests and tests[row.version].passed else "failed" if row.version in tests and tests[row.version].code != "SECRET_SLOT_UNTESTED" else "untested", + tested_at=_aware(tests[row.version].tested_at) if row.version in tests and tests[row.version].code != "SECRET_SLOT_UNTESTED" else None, + test_code=tests[row.version].code if row.version in tests and tests[row.version].code != "SECRET_SLOT_UNTESTED" else None) for row in rows]) + + def catalog(self) -> SecretSlotCatalog: + consumers = {"database_url": "API database bootstrap", "migration_database_url": "one-shot schema migration", + "operator_api_key": "deployment recovery administrator", "operator_identities": "deployment operator identities", + "backup_encryption_key": "backup encryption and historical restore", "secret_slot_root_key": "HF slot decryption", + "hf_token": "deployment HF metadata and separately provisioned node downloads", "redis_url": "Redis deployment connection", + "alert_webhook_url": "deployment-managed operational alert notifications"} + return SecretSlotCatalog(slot=self.metadata(), deployment_secrets=[DeploymentSecretMetadata( + key=key, configured=bool(getattr(self.settings, key)), consumer=consumer) for key, consumer in consumers.items()]) + + def _finish(self, state: SecretSlotState, action: str, request: SecretSlotRequest, version: int | None) -> SecretSlotMetadata: + state.revision += 1 + self.audit.write(f"secret_slot.{action}", "platform_secret_slot", SLOT, + {"version": version, "revision": state.revision, "reason": request.reason, + "consumer": "huggingface_control_plane_metadata"}) + self.session.flush() + return self.metadata() + + def set(self, request: SecretSlotSet) -> SecretSlotMetadata: + key = _root_key(self.settings) + with self._mutation(request) as state: + version = (self.session.scalar(select(func.max(SecretSlotVersion.version))) or 0) + 1 + nonce = os.urandom(12) + encrypted = AESGCM(key).encrypt(nonce, request.value.get_secret_value().encode(), _aad(version, self.settings.secret_slot_root_key_id)) + self.session.add(SecretSlotVersion(version=version, key_id=self.settings.secret_slot_root_key_id, + secret_nonce=base64.b64encode(nonce).decode(), secret_ciphertext=base64.b64encode(encrypted).decode(), created_at=datetime.now(UTC))) + self.session.flush() + self.session.add(SecretSlotTest(version=version, passed=False, code="SECRET_SLOT_UNTESTED", tested_at=datetime.now(UTC))) + state.initialized = True + result = self._finish(state, "set", request, version) + return result + + def _candidate(self, version: int) -> SecretSlotVersion: + row = self.session.get(SecretSlotVersion, version) + latest = self.session.scalar(select(func.max(SecretSlotVersion.version))) + if row is None or row.revoked_at is not None or version != latest: + raise SecretSlotError(409, "SECRET_SLOT_VERSION_CONFLICT", "Select the latest unrevoked candidate") + return row + + def test(self, request: SecretSlotVersionRequest) -> SecretSlotMetadata: + # Check current managed-admin admission before sending a secret, then release all + # locks. Recheck both authorization and CAS before recording the external result. + with self._mutation(request): + token = _decrypt(self._candidate(request.version), self.settings) + passed, code = test_huggingface_token(token) + with self._mutation(request) as state: + self._candidate(request.version) + existing = self.session.get(SecretSlotTest, request.version) + if existing: + existing.passed, existing.code, existing.tested_at = passed, code, datetime.now(UTC) + else: + self.session.add(SecretSlotTest(version=request.version, passed=passed, code=code, tested_at=datetime.now(UTC))) + result = self._finish(state, "tested", request, request.version) + return result + + def activate(self, request: SecretSlotVersionRequest) -> SecretSlotMetadata: + with self._mutation(request) as state: + row = self._candidate(request.version) + test = self.session.get(SecretSlotTest, request.version) + if test is None or not test.passed or _aware(test.tested_at) < datetime.now(UTC) - timedelta(minutes=5): + raise SecretSlotError(409, "SECRET_SLOT_TEST_REQUIRED", "A successful test within five minutes is required") + _decrypt(row, self.settings) # Local consumer acceptance, not node acknowledgement. + active = self.session.get(SecretSlotActivation, 1) + if active: + active.version, active.activated_at = request.version, datetime.now(UTC) + else: + self.session.add(SecretSlotActivation(singleton_id=1, version=request.version, activated_at=datetime.now(UTC))) + result = self._finish(state, "activated", request, request.version) + return result + + def revoke(self, request: SecretSlotRevoke) -> SecretSlotMetadata: + with self._mutation(request) as state: + if not state.initialized: + raise SecretSlotError(409, "SECRET_SLOT_INACTIVE", "Deployment credentials require deployment revocation") + self.session.execute(update(SecretSlotVersion).where(SecretSlotVersion.revoked_at.is_(None)).values(revoked_at=datetime.now(UTC))) + self.session.execute(delete(SecretSlotActivation)) + self.session.execute(delete(SecretSlotTest)) + result = self._finish(state, "revoked", request, None) + return result diff --git a/backend/src/modelforge_api/services/selected_model_comparison.py b/backend/src/modelforge_api/services/selected_model_comparison.py new file mode 100644 index 0000000..ebb29b5 --- /dev/null +++ b/backend/src/modelforge_api/services/selected_model_comparison.py @@ -0,0 +1,737 @@ +"""Durable, single-pair consumer coordination; never runs retrieval or promotes models.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from modelforge_api.domain.evaluation import ( + EvaluationCaseDefinitionResponse, + EvaluationComparisonCreate, + EvaluationRunComplete, + EvaluationRunCreate, +) +from modelforge_api.domain.selected_model_comparison import ( + ComparisonCancel, + ComparisonClaim, + ComparisonCreate, + ComparisonEvaluationSummary, + ComparisonFinish, + ComparisonList, + ComparisonProgress, + ComparisonResponse, + ComparisonResult, +) +from modelforge_api.persistence.models import ( + ArtifactSet, + Capability, + CapabilityContract, + CapabilityDeployment, + CapabilityExperimentRoute, + DeploymentCandidate, + EmbeddingSpace, + EvaluationCase, + EvaluationComparison, + EvaluationSuite, + EvaluationSuiteRevision, + GatewayRequest, + ModelRevision, + Project, + ProjectBinding, + RuntimeProfile, + SelectedModelComparison, + ServiceClient, +) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.evaluation import EvaluationError, EvaluationService + +ACTIVE = ("pending", "running", "cancel_requested") + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value + + +def _digest(value: Any) -> str: + return hashlib.sha256( + json.dumps( + value, sort_keys=True, separators=(",", ":"), default=str, allow_nan=False + ).encode() + ).hexdigest() + + +def _error(code: str, status: int = 409) -> EvaluationError: + return EvaluationError(code, code.replace("_", " "), status_code=status) + + +class SelectedModelComparisonService: + def __init__(self, session: Session, audit_context: AuditContext) -> None: + self.session = session + self.audit_context = audit_context + + def _audit(self, row: SelectedModelComparison, action: str) -> None: + AuditWriter(self.session, context=self.audit_context).write( + action, + "selected_model_comparison", + str(row.id), + { + "status": row.status, + "version": row.version, + "project_binding_id": str(row.project_binding_id), + }, + ) + + def _response(self, row: SelectedModelComparison) -> ComparisonResponse: + values = { + name: getattr(row, name) + for name in ComparisonResponse.model_fields + if name not in {"pins_digest", "production_promotion_performed", "evaluation_summary"} + } + summary = None + if row.evaluation_comparison_id is not None: + comparison = self.session.get(EvaluationComparison, row.evaluation_comparison_id) + if ( + comparison is None + or comparison.baseline_run_id != row.baseline_run_id + or comparison.candidate_run_id != row.candidate_run_id + ): + raise _error("comparison_evidence_missing") + summary = ComparisonEvaluationSummary( + comparability=comparison.comparability, + eligibility=comparison.promotion_eligibility, + metric_deltas=comparison.metric_deltas, + critical_regressions=comparison.critical_regressions, + ) + elif row.status == "completed": + raise _error("comparison_evidence_missing") + return ComparisonResponse( + **values, pins_digest=_digest(row.pins), evaluation_summary=summary + ) + + def _row(self, identifier: UUID) -> SelectedModelComparison: + row = self.session.scalar( + select(SelectedModelComparison) + .where(SelectedModelComparison.id == identifier) + .with_for_update() + .execution_options(populate_existing=True) + ) + if row is None: + raise _error("comparison_not_found", 404) + return row + + def _consumer(self, row: SelectedModelComparison, client: ServiceClient) -> None: + fresh = self.session.get(ServiceClient, client.id, populate_existing=True) + binding = self.session.get(ProjectBinding, row.project_binding_id, populate_existing=True) + project = self.session.get(Project, binding.project_id) if binding else None + if ( + fresh is None + or fresh.id != row.consumer_client_id + or fresh.project_binding_id != row.project_binding_id + or fresh.status != "active" + or fresh.disabled_at is not None + or "rag.embedding@1" not in fresh.allowed_capabilities + or binding is None + or binding.deprecated_at is not None + or project is None + or not project.active + ): + raise _error("comparison_consumer_not_authorized", 403) + + def _target(self, identifier: UUID, contract_id: UUID) -> dict[str, Any]: + deployment = self.session.get(CapabilityDeployment, identifier, populate_existing=True) + if ( + deployment is None + or deployment.capability_contract_id != contract_id + or deployment.deprecated_at is not None + or deployment.draining_at is not None + or deployment.status not in {"stable", "candidate"} + ): + raise _error("comparison_target_unavailable") + candidate = self.session.get(DeploymentCandidate, deployment.deployment_candidate_id) + artifact = self.session.get(ArtifactSet, deployment.artifact_set_id) + profile = self.session.get(RuntimeProfile, deployment.runtime_profile_id) + space = ( + self.session.get(EmbeddingSpace, deployment.embedding_space_id) + if deployment.embedding_space_id + else None + ) + revision = self.session.get(ModelRevision, artifact.revision_id) if artifact else None + if ( + candidate is None + or artifact is None + or profile is None + or space is None + or revision is None + or artifact.immutable_at is None + or profile.immutable_at is None + or profile.artifact_set_id != artifact.id + or not revision.resolved_commit_sha + or (candidate.artifact_set_id, candidate.runtime_profile_id, candidate.compute_node_id) + != ( + deployment.artifact_set_id, + deployment.runtime_profile_id, + deployment.compute_node_id, + ) + or (space.capability_contract_id, space.artifact_set_id, space.runtime_profile_id) + != (contract_id, deployment.artifact_set_id, deployment.runtime_profile_id) + ): + raise _error("comparison_target_identity_mismatch") + return { + "deployment_id": str(identifier), + "config_fingerprint": deployment.config_fingerprint, + "artifact_set_id": str(artifact.id), + "revision_id": str(revision.id), + "upstream_commit_sha": revision.resolved_commit_sha, + "runtime_profile_id": str(profile.id), + "runtime_fingerprint": profile.fingerprint, + "artifact_sha256": profile.artifact_sha256, + "embedding_space_id": str(space.id), + "embedding_space_digest": space.identity_digest, + "dimension": space.dimension, + "compute_node_id": str(deployment.compute_node_id), + "accelerator_id": str(deployment.accelerator_id), + } + + def _pins(self, request: ComparisonCreate) -> tuple[dict[str, Any], int]: + binding = self.session.get(ProjectBinding, request.project_binding_id) + client = self.session.get(ServiceClient, request.consumer_client_id) + project = self.session.get(Project, binding.project_id) if binding else None + contract = ( + self.session.get(CapabilityContract, binding.capability_contract_id) + if binding + else None + ) + capability = self.session.get(Capability, contract.capability_id) if contract else None + revision = self.session.get(EvaluationSuiteRevision, request.suite_revision_id) + suite = self.session.get(EvaluationSuite, revision.suite_id) if revision else None + if ( + binding is None + or binding.deprecated_at is not None + or binding.channel != "stable" + or project is None + or not project.active + or contract is None + or contract.deprecated_at is not None + or capability is None + or capability.key != "rag.embedding" + or contract.version != 1 + or client is None + or client.project_binding_id != binding.id + or client.status != "active" + or client.disabled_at is not None + or "rag.embedding@1" not in client.allowed_capabilities + or revision is None + or suite is None + or suite.project_id != binding.project_id + ): + raise _error("comparison_binding_invalid", 422) + cases = list( + self.session.scalars( + select(EvaluationCase).where(EvaluationCase.suite_revision_id == revision.id) + ) + ) + if not 1 <= len(cases) <= 100 or any( + case.review_status not in {"reviewed", "approved"} for case in cases + ): + raise _error("comparison_suite_not_bounded_or_reviewed", 422) + if any( + case.label_provenance.get("corpus_revision") != request.corpus_sha256 for case in cases + ): + raise _error("comparison_corpus_provenance_mismatch", 422) + if request.retrieval_config_sha256 != _digest(revision.retrieval_settings): + raise _error("comparison_retrieval_configuration_mismatch", 422) + current = self._target(request.current_deployment_id, contract.id) + candidate = self._target(request.candidate_deployment_id, contract.id) + current_row = self.session.get(CapabilityDeployment, request.current_deployment_id) + candidate_row = self.session.get(CapabilityDeployment, request.candidate_deployment_id) + route = self.session.get( + CapabilityExperimentRoute, request.candidate_route_id, populate_existing=True + ) + if route is None: + raise _error("comparison_selection_invalid", 422) + common_invalid = ( + request.current_deployment_id == request.candidate_deployment_id + or candidate_row is None + or candidate_row.production + or candidate_row.channel != "experiment" + or candidate_row.status != "candidate" + or route.status != "active" + or route.capability_contract_id != contract.id + or route.capability_deployment_id != candidate_row.id + or request.current_index_ref == request.candidate_index_ref + ) + current_route: CapabilityExperimentRoute | None = None + if request.current_route_id is None: + # Keep the original stable-production comparison contract unchanged. + if ( + common_invalid + or current_row is None + or not current_row.production + or current_row.channel != "stable" + or current_row.status != "stable" + ): + raise _error("comparison_selection_invalid", 422) + else: + current_route = self.session.get( + CapabilityExperimentRoute, request.current_route_id, populate_existing=True + ) + if ( + common_invalid + or current_row is None + or current_row.production + or current_row.channel != "experiment" + or current_row.status != "candidate" + or current_route is None + or current_route.status != "active" + or current_route.capability_contract_id != contract.id + or current_route.capability_deployment_id != current_row.id + or current_route.id == route.id + ): + raise _error("comparison_selection_invalid", 422) + pins = { + "project_id": str(project.id), + "current": current, + "candidate": candidate, + "candidate_route_key": route.route_key, + "suite_definition_digest": revision.definition_digest, + "dataset_revision": revision.dataset_revision, + "corpus_sha256": request.corpus_sha256, + "retrieval_config_sha256": request.retrieval_config_sha256, + "current_index_ref": request.current_index_ref, + "candidate_index_ref": request.candidate_index_ref, + } + if current_route is not None: + pins["current_route_id"] = str(current_route.id) + pins["current_route_key"] = current_route.route_key + pins["candidate_route_id"] = str(route.id) + return pins, len(cases) + + def _revalidate(self, row: SelectedModelComparison) -> None: + request = ComparisonCreate( + **{ + name: getattr(row, name) + for name in ( + "project_binding_id", + "consumer_client_id", + "suite_revision_id", + "current_deployment_id", + "candidate_deployment_id", + "candidate_route_id", + "idempotency_key", + ) + }, + current_route_id=row.pins.get("current_route_id"), + **{ + name: row.pins[name] + for name in ( + "corpus_sha256", + "retrieval_config_sha256", + "current_index_ref", + "candidate_index_ref", + ) + }, + ) + pins, count = self._pins(request) + if pins != row.pins or count != row.expected_cases: + raise _error("comparison_pins_changed") + + def create(self, request: ComparisonCreate) -> ComparisonResponse: + # Excluding the optional null route preserves legacy stable-path retry digests. + digest = _digest(request.model_dump(mode="json", exclude_none=True)) + previous = self.session.scalar( + select(SelectedModelComparison).where( + SelectedModelComparison.project_binding_id == request.project_binding_id, + SelectedModelComparison.idempotency_key == request.idempotency_key, + ) + ) + if previous is not None: + if previous.request_digest != digest: + raise _error("comparison_idempotency_conflict") + return self._response(previous) + self.session.scalar( + select(ProjectBinding) + .where(ProjectBinding.id == request.project_binding_id) + .with_for_update() + ) + # The binding lock serializes both idempotency and the one-active-intent decision. + previous = self.session.scalar( + select(SelectedModelComparison).where( + SelectedModelComparison.project_binding_id == request.project_binding_id, + SelectedModelComparison.idempotency_key == request.idempotency_key, + ) + ) + if previous is not None: + if previous.request_digest != digest: + raise _error("comparison_idempotency_conflict") + return self._response(previous) + active = self.session.scalar( + select(SelectedModelComparison) + .where( + SelectedModelComparison.project_binding_id == request.project_binding_id, + SelectedModelComparison.status.in_(ACTIVE), + ) + .with_for_update() + ) + if active is not None and not self._expire(active): + raise _error("comparison_already_active") + self.session.flush() + pins, count = self._pins(request) + row = SelectedModelComparison( + **{ + name: getattr(request, name) + for name in ( + "project_binding_id", + "consumer_client_id", + "suite_revision_id", + "current_deployment_id", + "candidate_deployment_id", + "candidate_route_id", + "idempotency_key", + ) + }, + request_digest=digest, + actor_id=self.audit_context.actor_id, + pins=pins, + status="pending", + version=1, + expected_cases=count, + current_completed_cases=0, + candidate_completed_cases=0, + ) + self.session.add(row) + try: + self.session.flush() + self._audit(row, "SELECTED_MODEL_COMPARISON_CREATED") + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise _error("comparison_concurrent_create") from exc + self.session.refresh(row) + return self._response(row) + + def _expire(self, row: SelectedModelComparison) -> bool: + if row.status in {"running", "cancel_requested"} and ( + row.claim_expires_at is None + or _aware(row.claim_expires_at) <= _now() + or row.deadline_at is None + or _aware(row.deadline_at) <= _now() + ): + row.status, row.error_code, row.finished_at = "interrupted", "CLAIM_EXPIRED", _now() + row.claim_token_hash = row.claim_expires_at = None + row.version += 1 + self._audit(row, "SELECTED_MODEL_COMPARISON_INTERRUPTED") + return True + return False + + def get(self, identifier: UUID) -> ComparisonResponse: + row = self._row(identifier) + self._expire(row) + self.session.commit() + return self._response(row) + + def list( + self, project_binding_id: UUID | None = None, limit: int = 50, offset: int = 0 + ) -> ComparisonList: + if not 1 <= limit <= 100 or offset < 0: + raise _error("comparison_page_invalid", 422) + query = select(SelectedModelComparison) + if project_binding_id is not None: + query = query.where(SelectedModelComparison.project_binding_id == project_binding_id) + total = self.session.scalar(select(func.count()).select_from(query.subquery())) or 0 + rows = self.session.scalars( + query.order_by(SelectedModelComparison.created_at.desc(), SelectedModelComparison.id) + .limit(limit) + .offset(offset) + ).all() + locked = { + identifier: self._row(identifier) + for identifier in sorted((item.id for item in rows), key=str) + } + items = [] + for item in rows: + row = locked[item.id] + self._expire(row) + items.append(self._response(row)) + self.session.commit() + return ComparisonList(items=items, total=total) + + def cancel(self, identifier: UUID, request: ComparisonCancel) -> ComparisonResponse: + row = self._row(identifier) + if self._expire(row): + self.session.commit() + raise _error("comparison_claim_expired") + if row.version != request.expected_version: + raise _error("comparison_version_conflict") + if row.status == "pending": + row.status, row.finished_at = "cancelled", _now() + elif row.status == "running": + row.status = "cancel_requested" + elif row.status in {"cancelled", "cancel_requested"}: + return self._response(row) + else: + raise _error("comparison_not_cancellable") + row.cancel_requested_at = _now() + row.version += 1 + self._audit(row, "SELECTED_MODEL_COMPARISON_CANCEL_REQUESTED") + self.session.commit() + return self._response(row) + + def assigned(self, client: ServiceClient, limit: int = 50) -> ComparisonList: + if not 1 <= limit <= 100: + raise _error("comparison_page_invalid", 422) + query = select(SelectedModelComparison).where( + SelectedModelComparison.consumer_client_id == client.id, + SelectedModelComparison.project_binding_id == client.project_binding_id, + SelectedModelComparison.status.in_((*ACTIVE, "interrupted")), + ) + rows = self.session.scalars( + query.order_by(SelectedModelComparison.created_at.desc()).limit(limit) + ).all() + locked = { + identifier: self._row(identifier) + for identifier in sorted((item.id for item in rows), key=str) + } + items = [] + for item in rows: + row = locked[item.id] + self._consumer(row, client) + self._expire(row) + items.append(self._response(row)) + total = self.session.scalar(select(func.count()).select_from(query.subquery())) or 0 + self.session.commit() + return ComparisonList(items=items, total=total) + + def claim(self, identifier: UUID, client: ServiceClient) -> ComparisonClaim: + row = self._row(identifier) + self._consumer(row, client) + if self._expire(row): + self.session.commit() + raise _error("comparison_claim_expired") + if row.status != "pending": + raise _error("comparison_not_claimable") + self._revalidate(row) + token, now = secrets.token_urlsafe(32), _now() + result = self.session.execute( + update(SelectedModelComparison) + .where( + SelectedModelComparison.id == row.id, + SelectedModelComparison.status == "pending", + SelectedModelComparison.version == row.version, + ) + .values( + status="running", + version=row.version + 1, + claim_token_hash=hashlib.sha256(token.encode()).hexdigest(), + claimed_at=now, + claim_expires_at=now + timedelta(seconds=120), + deadline_at=now + timedelta(seconds=3600), + ) + .returning(SelectedModelComparison.id) + ) + if result.scalar_one_or_none() != row.id: + self.session.rollback() + raise _error("comparison_claim_conflict") + self.session.refresh(row) + cases = [ + EvaluationCaseDefinitionResponse.model_validate(case) + for case in self.session.scalars( + select(EvaluationCase) + .where(EvaluationCase.suite_revision_id == row.suite_revision_id) + .order_by(EvaluationCase.case_key) + ) + ] + self._audit(row, "SELECTED_MODEL_COMPARISON_CLAIMED") + self.session.commit() + if row.claim_expires_at is None: + raise _error("comparison_claim_invalid") + return ComparisonClaim( + comparison=self._response(row), + claim_token=token, + claim_expires_at=row.claim_expires_at, + cases=cases, + ) + + def _claim(self, row: SelectedModelComparison, token: str, client: ServiceClient) -> None: + self._consumer(row, client) + if self._expire(row): + self.session.commit() + raise _error("comparison_claim_expired") + if ( + row.status not in {"running", "cancel_requested"} + or row.claim_token_hash is None + or not hmac.compare_digest( + row.claim_token_hash, hashlib.sha256(token.encode()).hexdigest() + ) + ): + raise _error("comparison_claim_invalid") + + def progress( + self, identifier: UUID, request: ComparisonProgress, client: ServiceClient + ) -> ComparisonResponse: + row = self._row(identifier) + self._claim(row, request.claim_token.get_secret_value(), client) + if not ( + row.current_completed_cases <= request.current_completed_cases <= row.expected_cases + and row.candidate_completed_cases + <= request.candidate_completed_cases + <= row.expected_cases + ): + raise _error("comparison_progress_invalid", 422) + row.current_completed_cases, row.candidate_completed_cases = ( + request.current_completed_cases, + request.candidate_completed_cases, + ) + if row.deadline_at is None: + raise _error("comparison_claim_invalid") + row.claim_expires_at = min(_now() + timedelta(seconds=120), _aware(row.deadline_at)) + row.version += 1 + self.session.commit() + return self._response(row) + + def finish( + self, identifier: UUID, request: ComparisonFinish, client: ServiceClient + ) -> ComparisonResponse: + row = self._row(identifier) + self._claim(row, request.claim_token.get_secret_value(), client) + if request.outcome == "cancelled" and row.status != "cancel_requested": + raise _error("comparison_cancellation_not_requested") + row.status, row.error_code, row.finished_at = request.outcome, request.error_code, _now() + row.claim_token_hash = row.claim_expires_at = None + row.version += 1 + self._audit(row, "SELECTED_MODEL_COMPARISON_FINISHED") + self.session.commit() + return self._response(row) + + def _gateway_evidence( + self, + row: SelectedModelComparison, + leg: EvaluationRunComplete, + mapping: dict[UUID, UUID], + deployment_id: UUID, + seen: set[UUID], + ) -> None: + successful = {case.case_id for case in leg.results if case.error_code is None} + case_ids = {case.case_id for case in leg.results} + if not successful <= set(mapping) or not set(mapping) <= case_ids: + raise _error("comparison_gateway_evidence_missing", 422) + for case_id, identifier in mapping.items(): + case = self.session.get(EvaluationCase, case_id) + gateway = self.session.scalar( + select(GatewayRequest).where(GatewayRequest.request_id == identifier) + ) + if ( + identifier in seen + or case is None + or case.suite_revision_id != row.suite_revision_id + or gateway is None + or gateway.service_client_id != row.consumer_client_id + or gateway.capability_deployment_id != deployment_id + or gateway.capability_key != "rag.embedding" + or gateway.input_count != 1 + or gateway.input_sha256 != _digest([case.query]) + or gateway.capability_version != 1 + or gateway.status != "completed" + or gateway.finished_at is None + or row.claimed_at is None + or row.deadline_at is None + or not ( + _aware(row.claimed_at) + <= _aware(gateway.created_at) + <= _aware(gateway.finished_at) + <= min(_now(), _aware(row.deadline_at)) + ) + ): + raise _error("comparison_gateway_evidence_invalid", 422) + seen.add(identifier) + + def result( + self, identifier: UUID, request: ComparisonResult, client: ServiceClient + ) -> ComparisonResponse: + row = self._row(identifier) + self._consumer(row, client) + digest = _digest(request.model_dump(mode="json", exclude={"claim_token"})) + if row.status == "completed": + if row.result_digest != digest: + raise _error("comparison_result_conflict") + return self._response(row) + self._claim(row, request.claim_token.get_secret_value(), client) + if row.status != "running": + raise _error("comparison_cancel_pending") + self._revalidate(row) + if request.pins_digest != _digest(row.pins): + raise _error("comparison_pins_mismatch", 422) + seen: set[UUID] = set() + self._gateway_evidence( + row, request.current, request.current_gateway_requests, row.current_deployment_id, seen + ) + self._gateway_evidence( + row, + request.candidate, + request.candidate_gateway_requests, + row.candidate_deployment_id, + seen, + ) + evaluation = EvaluationService(self.session, commit=False) + try: + run_ids = [] + for name, leg, mapping, target in ( + ("current", request.current, request.current_gateway_requests, "current"), + ("candidate", request.candidate, request.candidate_gateway_requests, "shadow"), + ): + run = evaluation.create_run( + EvaluationRunCreate( + project_id=UUID(row.pins["project_id"]), + suite_revision_id=row.suite_revision_id, + target_kind="current" if target == "current" else "shadow", + target_index_ref=row.pins[f"{name}_index_ref"], + embedding_space_ref=row.pins[name]["embedding_space_id"], + capability_deployment_id=UUID(row.pins[name]["deployment_id"]), + corpus_revision=row.pins["corpus_sha256"], + retrieval_config_digest=row.pins["retrieval_config_sha256"], + environment_fingerprint={ + "source": "designated_consumer", + "comparison_id": str(row.id), + "consumer_revision_sha256": request.consumer_revision_sha256, + "pins_digest": request.pins_digest, + "gateway_request_ids": { + str(key): str(value) for key, value in mapping.items() + }, + }, + ) + ) + evaluation.complete_run(run.id, leg) + run_ids.append(run.id) + comparison = evaluation.compare( + EvaluationComparisonCreate(baseline_run_id=run_ids[0], candidate_run_id=run_ids[1]) + ) + if ( + row.claim_expires_at is None + or row.deadline_at is None + or min(_aware(row.claim_expires_at), _aware(row.deadline_at)) <= _now() + ): + raise _error("comparison_claim_expired") + row.baseline_run_id, row.candidate_run_id = run_ids + row.evaluation_comparison_id, row.result_digest = comparison.id, digest + row.status, row.finished_at = "completed", _now() + row.current_completed_cases = row.candidate_completed_cases = row.expected_cases + row.claim_token_hash = row.claim_expires_at = None + row.version += 1 + self._audit(row, "SELECTED_MODEL_COMPARISON_COMPLETED") + self.session.commit() + except Exception: + self.session.rollback() + raise + return self._response(row) diff --git a/backend/src/modelforge_api/services/selected_replacement.py b/backend/src/modelforge_api/services/selected_replacement.py new file mode 100644 index 0000000..39a1da8 --- /dev/null +++ b/backend/src/modelforge_api/services/selected_replacement.py @@ -0,0 +1,142 @@ +"""Advisory replacement preview; never approves, executes, reconciles or commits.""" +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from modelforge_api.domain.lifecycle_contracts import ServingTransitionSelection +from modelforge_api.domain.selected_replacement import ( + ReplacementApproval, + ReplacementBinding, + ReplacementBlocker, + ReplacementPreview, +) +from modelforge_api.persistence.models import ( + CapabilityContract, + CapabilityDeployment, + EvaluationComparison, + ProductionExecutionApproval, + Project, + ProjectBinding, + SelectedModelComparison, +) +from modelforge_api.services.evaluation import EvaluationError +from modelforge_api.services.lifecycle import LifecycleError +from modelforge_api.services.lifecycle_serving import LifecycleServing +from modelforge_api.services.serving import ServingError + + +class SelectedReplacementService: + def __init__(self, session: Session) -> None: + self.session = session + + def preview(self, comparison_id: UUID) -> ReplacementPreview: + with self.session.no_autoflush: + return self._preview(comparison_id) + + def _preview(self, comparison_id: UUID) -> ReplacementPreview: + chosen = self.session.get(SelectedModelComparison, comparison_id) + if chosen is None: + raise LifecycleError(404, "COMPARISON_NOT_FOUND", "Selected comparison was not found") + current = self.session.get(CapabilityDeployment, chosen.current_deployment_id) + candidate = self.session.get(CapabilityDeployment, chosen.candidate_deployment_id) + contract = self.session.get(CapabilityContract, current.capability_contract_id) if current else None + blockers = [ReplacementBlocker(code="PRODUCTION_VALIDATION_REVIEW_REQUIRED", + message="Review actual project production validation and explicitly approve the lifecycle plan; this preview grants no production authority.")] + + def block(code: str, message: str, binding_id: UUID | None = None) -> None: + if not any(item.code == code and item.project_binding_id == binding_id for item in blockers): + blockers.append(ReplacementBlocker(code=code, message=message, project_binding_id=binding_id)) + + if chosen.status != "completed": + block("COMPARISON_NOT_COMPLETED", "Complete the designated consumer comparison before preparing replacement.") + same_space = bool(current and candidate and current.embedding_space_id is not None + and current.embedding_space_id == candidate.embedding_space_id) + if not same_space: + block("SERVING_REINDEX_REQUIRED", "Changed embedding identity requires a separate validated consumer index and trusted cutover; equal dimensions do not suffice.") + if (current is None or candidate is None or contract is None or contract.deprecated_at is not None + or candidate.capability_contract_id != contract.id or current.id == candidate.id + or not current.production or current.channel != "stable" or current.status != "stable" + or candidate.production or candidate.channel != "experiment" or candidate.status != "candidate"): + block("SERVING_SELECTION_INVALID", "Refresh the comparison: its exact current stable or nonproduction candidate is no longer available.") + bindings = [] + comparison_ids = {} + if contract is not None: + rows = list(self.session.scalars(select(ProjectBinding).where( + ProjectBinding.capability_contract_id == contract.id, ProjectBinding.channel == "stable", + ProjectBinding.deprecated_at.is_(None)).order_by(ProjectBinding.id).limit(1001))) + if not rows or len(rows) > 1000: + block("SERVING_BINDINGS_UNBOUNDED", "One to 1000 affected stable bindings are required.") + for binding in rows[:1000]: + latest = self.session.scalar(select(SelectedModelComparison).where( + SelectedModelComparison.project_binding_id == binding.id + ).order_by(SelectedModelComparison.created_at.desc(), SelectedModelComparison.id.desc()).limit(1)) + exact = bool(latest and latest.current_deployment_id == chosen.current_deployment_id + and latest.candidate_deployment_id == chosen.candidate_deployment_id) + evaluated = self.session.get(EvaluationComparison, latest.evaluation_comparison_id) if latest and latest.evaluation_comparison_id else None + eligible = bool(exact and latest and latest.status == "completed" and evaluated + and evaluated.comparability == "comparable" and evaluated.promotion_eligibility == "eligible" + and not evaluated.critical_regressions) + project = self.session.get(Project, binding.project_id) + bindings.append(ReplacementBinding(project_binding_id=binding.id, project_id=binding.project_id, + project_name=project.name if project else "Unavailable project", + comparison_id=latest.id if exact and latest else None, + comparison_status=latest.status if exact and latest else None, eligible=eligible)) + if eligible and latest: + comparison_ids[binding.id] = latest.id + else: + block("BINDING_COMPARISON_REQUIRED", "This affected project needs its latest eligible comparison of the exact same pair.", binding.id) + if binding.id == chosen.project_binding_id and (latest is None or latest.id != chosen.id): + block("SELECTED_COMPARISON_SUPERSEDED", "A newer comparison supersedes this selection; review that evidence first.", binding.id) + approvals = [] + selection = None + if candidate is not None and contract is not None: + choices = list(self.session.scalars(select(ProductionExecutionApproval).where( + ProductionExecutionApproval.deployment_candidate_id == candidate.deployment_candidate_id, + ProductionExecutionApproval.capability_contract_id == contract.id, + ProductionExecutionApproval.status == "approved", ProductionExecutionApproval.revoked_at.is_(None) + ).order_by(ProductionExecutionApproval.approved_at.desc(), ProductionExecutionApproval.id.desc()).limit(101))) + if len(choices) > 100: + block("APPROVAL_CHOICES_UNBOUNDED", "Narrow the production approval history before continuing.") + adapter = LifecycleServing(self.session) + if choices: + # Qualification helpers lock their facts. Follow the writer's lock + # order rather than retaining an approval lock before a route lock. + adapter.repo.lock_contract_route(contract.id) + lock_selection = ServingTransitionSelection(current_deployment_id=chosen.current_deployment_id, + candidate_deployment_id=chosen.candidate_deployment_id, production_approval_id=choices[0].id, + expected_route_generation=contract.serving_generation, + comparison_ids={chosen.project_binding_id: chosen.id}) + adapter._lock_accelerators(lock_selection) + if current is not None: + adapter._deployment(current.id) + adapter._deployment(candidate.id) + rejected = [] + for approval in choices[:100]: + try: + # Reuse current production-fact validation, without invoking any write path. + LifecycleServing(self.session)._production_evidence(candidate, contract, approval.id) + except (LifecycleError, ServingError, EvaluationError) as error: + rejected.append(error) + continue + approvals.append(ReplacementApproval(id=approval.id, approved_by=approval.approved_by, + approved_at=approval.approved_at, evidence_fingerprint=approval.evidence_fingerprint)) + if not approvals: + for rejection in rejected: + block(rejection.code, str(rejection)) + block("PRODUCTION_APPROVAL_REQUIRED", "Create or refresh the exact candidate's production execution approval.") + if len(blockers) == 1 and approvals and comparison_ids: + proposed = ServingTransitionSelection(current_deployment_id=chosen.current_deployment_id, + candidate_deployment_id=chosen.candidate_deployment_id, production_approval_id=approvals[0].id, + expected_route_generation=contract.serving_generation, comparison_ids=comparison_ids) + try: + LifecycleServing(self.session).prepare(proposed) + except (LifecycleError, ServingError, EvaluationError) as error: + block(error.code, str(error)) + else: + selection = proposed + return ReplacementPreview(comparison_id=chosen.id, + capability_contract_id=contract.id if contract else None, + current_deployment_id=chosen.current_deployment_id, candidate_deployment_id=chosen.candidate_deployment_id, + route_generation=contract.serving_generation if contract else None, same_embedding_space=same_space, + affected_bindings=bindings, production_approvals=approvals, blockers=blockers, selection=selection) diff --git a/backend/src/modelforge_api/services/self_check.py b/backend/src/modelforge_api/services/self_check.py new file mode 100644 index 0000000..767acca --- /dev/null +++ b/backend/src/modelforge_api/services/self_check.py @@ -0,0 +1,903 @@ +from __future__ import annotations + +import shutil +import stat +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import redis +import yaml # type: ignore[import-untyped] +from pydantic import SecretStr +from sqlalchemy import func, select, text +from sqlalchemy.orm import Session + +from modelforge_api import __version__ +from modelforge_api.domain.configuration_reference import SETTING_DOCS +from modelforge_api.domain.operator_identity import parse_operator_identities +from modelforge_api.domain.recovery import BackupState +from modelforge_api.domain.release import Compatibility, build_identity, schema_compatibility +from modelforge_api.domain.self_check import ( + SelfCheckActionCode, + SelfCheckKey, + SelfCheckNextAction, + SelfCheckOverall, + SelfCheckReport, + SelfCheckResult, + SelfCheckStatus, +) +from modelforge_api.persistence.models import ( + Accelerator, + AcceleratorTelemetryLatest, + BackupSet, + CapabilityDeployment, + ComputeNode, + ManagedOperatorCredential, + ManagedOperatorCredentialActivation, + ManagedOperatorIdentity, + SchedulerPolicyRevision, +) +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.settings import Settings + +_NEXT_ACTIONS: dict[SelfCheckKey, tuple[SelfCheckActionCode, str]] = { + SelfCheckKey.API: ( + SelfCheckActionCode.REVIEW_API_IDENTITY, + "Confirm the API service and version against the intended deployment.", + ), + SelfCheckKey.DATABASE: ( + SelfCheckActionCode.CHECK_DATABASE, + "Review database reachability and schema compatibility.", + ), + SelfCheckKey.REDIS: ( + SelfCheckActionCode.CHECK_REDIS, + "Review Redis availability and its configured connection.", + ), + SelfCheckKey.MANIFESTS: ( + SelfCheckActionCode.REVIEW_MANIFESTS, + "Review the authoritative manifest files and their deployment mount.", + ), + SelfCheckKey.STORAGE: ( + SelfCheckActionCode.REVIEW_STORAGE, + "Review storage root availability and capacity evidence.", + ), + SelfCheckKey.NODES: ( + SelfCheckActionCode.REVIEW_NODE_FRESHNESS, + "Review node heartbeat freshness and enrollment state.", + ), + SelfCheckKey.SCHEDULER: ( + SelfCheckActionCode.REVIEW_SCHEDULER_INPUTS, + "Review scheduler policy and registered accelerator inputs.", + ), + SelfCheckKey.DEPLOYMENTS: ( + SelfCheckActionCode.REVIEW_DEPLOYMENTS, + "Review stable production deployment records.", + ), + SelfCheckKey.BACKUP: ( + SelfCheckActionCode.VERIFY_BACKUP_PAYLOAD, + "Review the latest verified backup and its retained payload.", + ), + SelfCheckKey.CREDENTIALS: ( + SelfCheckActionCode.REVIEW_CREDENTIALS, + "Review credential source and expiry metadata without exposing secrets.", + ), + SelfCheckKey.CONFIGURATION: ( + SelfCheckActionCode.COMPLETE_CONFIGURATION, + "Complete documented production-required settings.", + ), + SelfCheckKey.RELEASE: ( + SelfCheckActionCode.VERIFY_RELEASE_IDENTITY, + "Verify the source commit and image digest for this release.", + ), +} + + +def _result( + key: str, + label: str, + status: SelfCheckStatus, + summary: str, + *, + detail: str | None = None, + evidence: dict[str, Any] | None = None, +) -> SelfCheckResult: + check_key = SelfCheckKey(key) + action_code, action_summary = _NEXT_ACTIONS[check_key] + return SelfCheckResult( + key=check_key, + label=label, + status=status, + summary=summary, + detail=detail, + evidence=evidence or {}, + next_action=SelfCheckNextAction( + code=( + SelfCheckActionCode.NONE_REQUIRED + if status is SelfCheckStatus.PASS + else action_code + ), + summary=( + "No operator follow-up is indicated by this check." + if status is SelfCheckStatus.PASS + else action_summary + ), + ), + ) + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _rollback_failed_read(session: Session) -> None: + """Clear a failed read transaction so later independent checks can still report truth.""" + + with suppress(Exception): + session.rollback() + + +def _database_check(session: Session) -> SelfCheckResult: + try: + session.execute(text("SELECT 1")).scalar_one() + revision = session.execute(text("SELECT version_num FROM alembic_version")).scalar_one_or_none() + compatibility = schema_compatibility(revision) + except Exception as exc: # noqa: BLE001 - self-check turns dependency failures into typed state + _rollback_failed_read(session) + return _result( + "database", + "PostgreSQL and schema", + SelfCheckStatus.UNKNOWN, + "Database or schema identity could not be read", + detail=type(exc).__name__, + ) + if compatibility is not Compatibility.COMPATIBLE: + return _result( + "database", + "PostgreSQL and schema", + SelfCheckStatus.FAIL, + f"Schema {revision or '(none)'} is {compatibility.value}", + evidence={"schema_revision": revision, "compatibility": compatibility.value}, + ) + return _result( + "database", + "PostgreSQL and schema", + SelfCheckStatus.PASS, + f"Schema {revision} is compatible", + evidence={"schema_revision": revision, "compatibility": compatibility.value}, + ) + + +def _api_check(settings: Settings) -> SelfCheckResult: + service_name = settings.service_name.strip() + if not service_name or not __version__: + return _result( + "api", + "API identity", + SelfCheckStatus.UNKNOWN, + "API service identity could not be established", + ) + return _result( + "api", + "API identity", + SelfCheckStatus.PASS, + "API service identity is available", + evidence={"service_name": service_name, "version": __version__}, + ) + + +def _redis_check(settings: Settings) -> SelfCheckResult: + client: redis.Redis | None = None + try: + client = redis.Redis.from_url( + settings.redis_url, + decode_responses=True, + socket_connect_timeout=1.0, + socket_timeout=1.0, + ) + pong = bool(client.ping()) + except Exception as exc: # noqa: BLE001 - dependency health must be reported, not raised + return _result( + "redis", + "Redis", + SelfCheckStatus.FAIL, + "Redis did not answer the read-only health probe", + detail=type(exc).__name__, + ) + finally: + if client is not None: + with suppress(Exception): + client.close() + return _result( + "redis", + "Redis", + SelfCheckStatus.PASS if pong else SelfCheckStatus.FAIL, + "Redis answered PING" if pong else "Redis PING returned a non-success response", + ) + + +def _manifest_check(registry: ManifestRegistry) -> SelfCheckResult: + try: + candidates = registry.candidates() + policies = registry.policies() + counts = { + "capabilities": len(registry.capabilities()), + "projects": len(registry.projects()), + "candidates": len(candidates.candidates), + "benchmarks": len(registry.benchmarks()), + "policy_sections": len(policies.model_dump()), + } + except (FileNotFoundError, TypeError, ValueError, yaml.YAMLError) as exc: + return _result( + "manifests", + "Authoritative manifests", + SelfCheckStatus.FAIL, + "One or more authoritative manifests could not be loaded", + detail=type(exc).__name__, + ) + except Exception as exc: # noqa: BLE001 - an unreadable manifest state is not evidence of health + return _result( + "manifests", + "Authoritative manifests", + SelfCheckStatus.UNKNOWN, + "Authoritative manifest state could not be inspected", + detail=type(exc).__name__, + ) + return _result( + "manifests", + "Authoritative manifests", + SelfCheckStatus.PASS, + "All manifest classes loaded successfully", + evidence=counts, + ) + + +def _storage_check(settings: Settings) -> SelfCheckResult: + roots = { + "artifact_root": Path(settings.artifact_root), + "quarantine_root": Path(settings.quarantine_root), + "backup_root": Path(settings.backup_root), + } + evidence: dict[str, Any] = {} + invalid: list[str] = [] + path_unknown: list[str] = [] + capacity_unknown: list[str] = [] + for key, path in roots.items(): + entry: dict[str, Any] = { + "path": str(path), + "writability": "not_probed_read_only", + } + try: + path_stat = path.stat() + except (FileNotFoundError, NotADirectoryError): + entry.update({"exists": False, "is_directory": False}) + invalid.append(key) + except OSError as exc: + entry.update( + {"exists": None, "is_directory": None, "stat_error": type(exc).__name__} + ) + path_unknown.append(key) + else: + is_directory = stat.S_ISDIR(path_stat.st_mode) + entry.update({"exists": True, "is_directory": is_directory}) + if not is_directory: + invalid.append(key) + else: + try: + usage = shutil.disk_usage(path) + entry.update( + { + "capacity_bytes": usage.total, + "free_bytes": usage.free, + "used_bytes": usage.used, + } + ) + except OSError as exc: + entry["capacity_error"] = type(exc).__name__ + capacity_unknown.append(key) + evidence[key] = entry + if invalid: + return _result( + "storage", + "Storage roots", + SelfCheckStatus.FAIL, + "One or more configured storage roots are unavailable", + detail=", ".join(invalid), + evidence=evidence, + ) + if path_unknown or capacity_unknown: + return _result( + "storage", + "Storage roots", + SelfCheckStatus.UNKNOWN, + "Storage root status or capacity could not be read for every root", + detail=( + "Writability is intentionally not probed by this read-only workflow; " + "metadata unavailable for " + f"{', '.join(path_unknown + capacity_unknown)}" + ), + evidence=evidence, + ) + return _result( + "storage", + "Storage roots", + SelfCheckStatus.PASS, + "Configured storage roots exist and capacity was inspected without writes", + detail="Writability is intentionally not probed by this read-only workflow", + evidence=evidence, + ) + + +def _node_check(session: Session, settings: Settings, now: datetime) -> SelfCheckResult: + try: + rows = list( + session.execute( + select(ComputeNode.id, ComputeNode.display_name, ComputeNode.last_heartbeat_at).where( + ComputeNode.decommissioned_at.is_(None) + ) + ) + ) + except Exception as exc: # noqa: BLE001 + _rollback_failed_read(session) + return _result( + "nodes", + "Compute node freshness", + SelfCheckStatus.UNKNOWN, + "Compute node freshness could not be evaluated", + detail=type(exc).__name__, + ) + if not rows: + return _result( + "nodes", + "Compute node freshness", + SelfCheckStatus.WARN, + "No active compute nodes are enrolled", + evidence={"active_nodes": 0}, + ) + + stale: list[str] = [] + offline: list[str] = [] + unknown: list[str] = [] + ages: dict[str, float | None] = {} + for row in rows: + name = str(row.display_name) + heartbeat = row.last_heartbeat_at + if heartbeat is None: + unknown.append(name) + ages[name] = None + continue + age = max(0.0, (now - _aware(heartbeat)).total_seconds()) + ages[name] = round(age, 3) + if age > settings.node_offline_after_seconds: + offline.append(name) + elif age > settings.node_stale_after_seconds: + stale.append(name) + + evidence = { + "active_nodes": len(rows), + "stale_nodes": stale, + "offline_nodes": offline, + "heartbeat_unknown": unknown, + "heartbeat_age_seconds": ages, + "stale_after_seconds": settings.node_stale_after_seconds, + "offline_after_seconds": settings.node_offline_after_seconds, + } + if len(offline) == len(rows): + return _result( + "nodes", + "Compute node freshness", + SelfCheckStatus.FAIL, + "All active compute nodes are offline", + evidence=evidence, + ) + if unknown: + return _result( + "nodes", + "Compute node freshness", + SelfCheckStatus.UNKNOWN, + "At least one active compute node has no heartbeat evidence", + evidence=evidence, + ) + if stale or offline: + return _result( + "nodes", + "Compute node freshness", + SelfCheckStatus.WARN, + "At least one compute node is stale, offline, or has no heartbeat", + evidence=evidence, + ) + return _result( + "nodes", + "Compute node freshness", + SelfCheckStatus.PASS, + "All active compute nodes have fresh heartbeats", + evidence=evidence, + ) + + +def _scheduler_check(session: Session, settings: Settings, now: datetime) -> SelfCheckResult: + try: + active_accelerators = int( + session.scalar( + select(func.count(Accelerator.id)).where(Accelerator.status == "active") + ) + or 0 + ) + telemetry_missing = int( + session.scalar( + select(func.count(Accelerator.id)) + .select_from(Accelerator) + .outerjoin(AcceleratorTelemetryLatest) + .where( + Accelerator.status == "active", + AcceleratorTelemetryLatest.accelerator_id.is_(None), + ) + ) + or 0 + ) + telemetry_stale = int( + session.scalar( + select(func.count(Accelerator.id)) + .join(AcceleratorTelemetryLatest) + .where( + Accelerator.status == "active", + AcceleratorTelemetryLatest.observed_at + < now - timedelta(seconds=settings.scheduler_telemetry_stale_seconds), + ) + ) + or 0 + ) + capacity_unknown = int( + session.scalar( + select(func.count(Accelerator.id)).where( + Accelerator.status == "active", + Accelerator.total_vram_bytes.is_(None), + ) + ) + or 0 + ) + active_policies = list( + session.execute( + select(SchedulerPolicyRevision.revision).where( + SchedulerPolicyRevision.active.is_(True) + ).limit(2) + ) + ) + except Exception as exc: # noqa: BLE001 - scheduler readiness is unknown when inputs cannot be read + _rollback_failed_read(session) + return _result( + "scheduler", + "Scheduler inputs", + SelfCheckStatus.UNKNOWN, + "Scheduler policy or accelerator inputs could not be read", + detail=type(exc).__name__, + ) + + evidence: dict[str, Any] = { + "active_accelerators": active_accelerators, + "telemetry_missing": telemetry_missing, + "telemetry_stale": telemetry_stale, + "capacity_unknown": capacity_unknown, + "active_policy_revisions": len(active_policies), + "deployment_queue_limit": settings.scheduler_global_queue_limit, + "telemetry_stale_after_seconds": settings.scheduler_telemetry_stale_seconds, + } + if active_accelerators == 0: + return _result( + "scheduler", + "Scheduler inputs", + SelfCheckStatus.UNKNOWN, + "Scheduler readiness cannot be inferred without active accelerator inputs", + evidence=evidence, + ) + if telemetry_missing or telemetry_stale or capacity_unknown: + return _result( + "scheduler", + "Scheduler inputs", + SelfCheckStatus.UNKNOWN, + "Scheduler inputs lack fresh telemetry or known accelerator capacity", + evidence=evidence, + ) + if len(active_policies) > 1: + return _result( + "scheduler", + "Scheduler inputs", + SelfCheckStatus.UNKNOWN, + "Multiple active scheduler policies make the effective policy ambiguous", + evidence=evidence, + ) + if not active_policies: + evidence["policy_source"] = "validated_deployment_settings" + return _result( + "scheduler", + "Scheduler inputs", + SelfCheckStatus.WARN, + "Scheduler has accelerator inputs and is using deployment policy defaults", + evidence=evidence, + ) + evidence["policy_revision"] = str(active_policies[0][0]) + evidence["policy_source"] = "persisted" + return _result( + "scheduler", + "Scheduler inputs", + SelfCheckStatus.PASS, + "Scheduler has active accelerator inputs and one persisted policy", + evidence=evidence, + ) + + +def _deployment_check(session: Session) -> SelfCheckResult: + try: + production_count = int( + session.scalar( + select(func.count(CapabilityDeployment.id)).where( + CapabilityDeployment.status == "stable", + CapabilityDeployment.production.is_(True), + ) + ) + or 0 + ) + except Exception as exc: # noqa: BLE001 + _rollback_failed_read(session) + return _result( + "deployments", + "Production capability deployments", + SelfCheckStatus.UNKNOWN, + "Production deployment state could not be read", + detail=type(exc).__name__, + ) + if production_count == 0: + return _result( + "deployments", + "Production capability deployments", + SelfCheckStatus.WARN, + "No stable production capability deployment is active", + evidence={"stable_production_deployments": 0}, + ) + return _result( + "deployments", + "Production capability deployments", + SelfCheckStatus.PASS, + f"{production_count} stable production deployment(s) are recorded", + evidence={"stable_production_deployments": production_count}, + ) + + +def _backup_check(session: Session, settings: Settings, now: datetime) -> SelfCheckResult: + try: + latest = session.execute( + select(BackupSet.backup_id, BackupSet.verified_at) + .where( + BackupSet.state == BackupState.VERIFIED.value, + BackupSet.verified_at.is_not(None), + ) + .order_by(BackupSet.verified_at.desc()) + .limit(1) + ).one_or_none() + except Exception as exc: # noqa: BLE001 + _rollback_failed_read(session) + return _result( + "backup", + "Verified backup freshness", + SelfCheckStatus.UNKNOWN, + "Backup freshness could not be read", + detail=type(exc).__name__, + ) + if latest is None or latest.verified_at is None: + return _result( + "backup", + "Verified backup freshness", + SelfCheckStatus.WARN, + "No verified backup is recorded", + evidence={"verified_backup": False}, + ) + age = max(0.0, (now - _aware(latest.verified_at)).total_seconds()) + stale = age > settings.backup_stale_after_seconds + return _result( + "backup", + "Verified backup freshness", + SelfCheckStatus.WARN if stale else SelfCheckStatus.PASS, + "Newest verified backup is stale" if stale else "Newest verified backup is within policy age", + evidence={ + "backup_id": latest.backup_id, + "age_seconds": round(age, 3), + "stale_after_seconds": settings.backup_stale_after_seconds, + }, + ) + + +def _credentials_check( + session: Session, settings: Settings, now: datetime +) -> SelfCheckResult: + document = settings.operator_identities + try: + deployment_identities = ( + parse_operator_identities(document.get_secret_value()) + if document is not None and document.get_secret_value() + else () + ) + except ValueError as exc: + return _result( + "credentials", + "Operator credential metadata", + SelfCheckStatus.UNKNOWN, + "Deployment operator credential metadata could not be parsed", + detail=type(exc).__name__, + ) + + deployment_roles = {role: 0 for role in ("viewer", "operator", "admin")} + deployment_expired = 0 + deployment_expiring = 0 + expiring_before = now + timedelta(days=7) + deployment_admins = 0 + for identity in deployment_identities: + deployment_roles[identity.role] += 1 + expires_at = _aware(identity.expires_at) + if expires_at <= now: + deployment_expired += 1 + elif expires_at <= expiring_before: + deployment_expiring += 1 + if identity.role == "admin" and expires_at > now: + deployment_admins += 1 + + try: + enabled_rows = list( + session.execute( + select(ManagedOperatorIdentity.role, func.count(ManagedOperatorIdentity.slug)) + .where(ManagedOperatorIdentity.enabled.is_(True)) + .group_by(ManagedOperatorIdentity.role) + ) + ) + usable_managed = int( + session.scalar( + select(func.count(ManagedOperatorCredential.id)) + .join(ManagedOperatorIdentity) + .join(ManagedOperatorCredentialActivation) + .where( + ManagedOperatorIdentity.enabled.is_(True), + ManagedOperatorCredential.revoked_at.is_(None), + ManagedOperatorCredential.superseded_at.is_(None), + ManagedOperatorCredential.expires_at > now, + ) + ) + or 0 + ) + usable_managed_admins = int( + session.scalar( + select(func.count(ManagedOperatorCredential.id)) + .join(ManagedOperatorIdentity) + .join(ManagedOperatorCredentialActivation) + .where( + ManagedOperatorIdentity.enabled.is_(True), + ManagedOperatorIdentity.role == "admin", + ManagedOperatorCredential.revoked_at.is_(None), + ManagedOperatorCredential.superseded_at.is_(None), + ManagedOperatorCredential.expires_at > now, + ) + ) + or 0 + ) + managed_expired = int( + session.scalar( + select(func.count(ManagedOperatorCredential.id)) + .join(ManagedOperatorIdentity) + .where( + ManagedOperatorIdentity.enabled.is_(True), + ManagedOperatorCredential.revoked_at.is_(None), + ManagedOperatorCredential.superseded_at.is_(None), + ManagedOperatorCredential.expires_at <= now, + ) + ) + or 0 + ) + managed_expiring = int( + session.scalar( + select(func.count(ManagedOperatorCredential.id)) + .join(ManagedOperatorIdentity) + .where( + ManagedOperatorIdentity.enabled.is_(True), + ManagedOperatorCredential.revoked_at.is_(None), + ManagedOperatorCredential.superseded_at.is_(None), + ManagedOperatorCredential.expires_at > now, + ManagedOperatorCredential.expires_at <= expiring_before, + ) + ) + or 0 + ) + except Exception as exc: # noqa: BLE001 - credential store state is unknown when unreadable + _rollback_failed_read(session) + return _result( + "credentials", + "Operator credential metadata", + SelfCheckStatus.UNKNOWN, + "Managed operator credential metadata could not be read", + detail=type(exc).__name__, + evidence={ + "deployment_identity_count": len(deployment_identities), + "deployment_roles": deployment_roles, + "deployment_expired_identities": deployment_expired, + "deployment_expiring_within_7_days": deployment_expiring, + }, + ) + + managed_roles = {str(role): int(count) for role, count in enabled_rows} + legacy_admin_configured = _legacy_admin_token_configured(settings.operator_api_key) + password_session_configured = ( + legacy_admin_configured and _configured(settings.operator_password_hash) + ) + sources: list[str] = [] + if legacy_admin_configured: + sources.append("legacy_operator_api_key") + if deployment_identities: + sources.append("deployment_operator_identities") + if managed_roles: + sources.append("managed_operator_identities") + if password_session_configured: + sources.append("operator_password_session") + administrator_available = bool( + legacy_admin_configured or deployment_admins or usable_managed_admins + ) + evidence = { + "configured_sources": sources, + "deployment_identity_count": len(deployment_identities), + "deployment_roles": deployment_roles, + "deployment_expired_identities": deployment_expired, + "deployment_expiring_within_7_days": deployment_expiring, + "managed_enabled_identities_by_role": managed_roles, + "managed_usable_credentials": usable_managed, + "managed_usable_admin_credentials": usable_managed_admins, + "managed_expired_credentials": managed_expired, + "managed_expiring_within_7_days": managed_expiring, + "operator_password_session_configured": password_session_configured, + "administrator_metadata_present": administrator_available, + } + if not sources: + return _result( + "credentials", + "Operator credential metadata", + SelfCheckStatus.FAIL, + "No operator credential source is configured", + evidence=evidence, + ) + if not administrator_available: + return _result( + "credentials", + "Operator credential metadata", + SelfCheckStatus.FAIL, + "No usable administrator credential is represented in available metadata", + evidence=evidence, + ) + if deployment_expired or deployment_expiring or managed_expired or managed_expiring: + return _result( + "credentials", + "Operator credential metadata", + SelfCheckStatus.WARN, + "One or more deployment operator credentials are expired or nearing expiry", + evidence=evidence, + ) + return _result( + "credentials", + "Operator credential metadata", + SelfCheckStatus.PASS, + "Credential source and administrator metadata are available", + evidence=evidence, + ) + + +def _configured(value: object) -> bool: + if value is None: + return False + if isinstance(value, SecretStr): + return bool(value.get_secret_value()) + if isinstance(value, str): + return bool(value) + return True + + +def _legacy_admin_token_configured(value: object) -> bool: + if not isinstance(value, SecretStr): + return False + token = value.get_secret_value() + return bool( + token + and len(token) <= 512 + and not token.startswith(("mfopm_", "mfop_", "mfsvc_", "mfnode_", "mfenroll_")) + ) + + +def _configuration_check(settings: Settings) -> SelfCheckResult: + if settings.env != "production": + return _result( + "configuration", + "Required production configuration", + SelfCheckStatus.PASS, + "Production-only required-setting gate is not applicable in this environment", + evidence={"environment": settings.env}, + ) + missing = [ + key + for key, documentation in SETTING_DOCS.items() + if documentation.required_in_production and not _configured(getattr(settings, key)) + ] + if missing: + return _result( + "configuration", + "Required production configuration", + SelfCheckStatus.FAIL, + "Required production configuration is incomplete", + evidence={"missing_settings": sorted(missing), "environment": settings.env}, + ) + return _result( + "configuration", + "Required production configuration", + SelfCheckStatus.PASS, + "All documented production-required settings are configured", + evidence={"environment": settings.env, "missing_settings": []}, + ) + + +def _release_check(settings: Settings) -> SelfCheckResult: + identity = build_identity( + source_commit=settings.build_commit, + built_at=settings.build_timestamp, + image_digest=settings.build_image_digest, + ) + missing = [ + name + for name, value in { + "source_commit": identity.source_commit, + "image_digest": identity.image_digest, + }.items() + if not value + ] + status = ( + SelfCheckStatus.FAIL + if settings.env == "production" and missing + else SelfCheckStatus.WARN + if missing + else SelfCheckStatus.PASS + ) + return _result( + "release", + "Release identity", + status, + "Release provenance is complete" if not missing else "Release provenance is incomplete", + evidence={ + "version": identity.version, + "channel": identity.channel, + "source_commit": identity.source_commit, + "image_digest": identity.image_digest, + "missing_identity_fields": missing, + }, + ) + + +def run_self_check( + session: Session, + settings: Settings, + registry: ManifestRegistry, + *, + now: datetime | None = None, +) -> SelfCheckReport: + observed_at = _aware(now or datetime.now(UTC)) + checks = [ + _api_check(settings), + _database_check(session), + _redis_check(settings), + _manifest_check(registry), + _storage_check(settings), + _node_check(session, settings, observed_at), + _scheduler_check(session, settings, observed_at), + _deployment_check(session), + _backup_check(session, settings, observed_at), + _credentials_check(session, settings, observed_at), + _configuration_check(settings), + _release_check(settings), + ] + if any(check.status is SelfCheckStatus.FAIL for check in checks): + overall = SelfCheckOverall.UNHEALTHY + elif any(check.status in {SelfCheckStatus.WARN, SelfCheckStatus.UNKNOWN} for check in checks): + overall = SelfCheckOverall.DEGRADED + else: + overall = SelfCheckOverall.HEALTHY + return SelfCheckReport(observed_at=observed_at, overall=overall, checks=checks) diff --git a/backend/src/modelforge_api/services/serving.py b/backend/src/modelforge_api/services/serving.py index edfa8be..ab44cf1 100644 --- a/backend/src/modelforge_api/services/serving.py +++ b/backend/src/modelforge_api/services/serving.py @@ -6,6 +6,7 @@ import itertools import json import math import posixpath +import re import secrets import time import uuid @@ -14,11 +15,18 @@ from datetime import UTC, datetime, timedelta from pathlib import PurePosixPath from typing import Any, cast -from sqlalchemy import select +from redis.exceptions import RedisError +from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from modelforge_api.db import rows_affected +from modelforge_api.domain.dispatch_context import DispatchContext +from modelforge_api.domain.lab import LabEmbeddingInput, LabInput, LabPreflightRequest, LabTarget from modelforge_api.domain.serving import ( + AgentServingJobCancellationAck, + AgentServingJobCancellationCheck, + AgentServingJobCancellationStatus, AgentServingJobComplete, AgentServingJobFailure, AgentServingJobLease, @@ -60,6 +68,9 @@ from modelforge_api.domain.serving import ( ServiceClientCreate, ServiceClientCreated, ServiceClientResponse, + ServiceCredentialResponse, + ServiceCredentialRevoke, + ServiceCredentialRotate, SupplyChainReview, ) from modelforge_api.persistence.models import ( @@ -70,8 +81,11 @@ from modelforge_api.persistence.models import ( CapabilityResourceEnvelope, ComputeNode, CoResidencyEvidence, + DeploymentCandidate, EmbeddingSpace, + ExternalCreativeAllocation, GatewayRequest, + LabExecutionRun, PlacementPlanRecord, ProductionExecutionApproval, Project, @@ -88,6 +102,7 @@ from modelforge_api.persistence.models import ( ) from modelforge_api.persistence.serving_repository import ServingRepository from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.configuration_changes import resolve_serving_settings from modelforge_api.services.gpu_scheduler import ( AccountingSnapshot, AttributionConfidence, @@ -101,6 +116,7 @@ from modelforge_api.services.gpu_scheduler import ( plan_placement, required_envelope, ) +from modelforge_api.services.lab import LabPreflightService from modelforge_api.services.manifest_registry import ManifestRegistry from modelforge_api.services.runtime import RuntimeService, _selected_manifest_rows from modelforge_api.settings import Settings @@ -127,6 +143,71 @@ def _canonical_hash(payload: Any) -> str: return hashlib.sha256(raw.encode()).hexdigest() +def _bounded_worker_metrics(raw_metrics: Any) -> dict[str, int | float | str]: + """Keep only finite, bounded serving-worker observations; never persist arbitrary payloads.""" + if not isinstance(raw_metrics, dict): + return {} + metrics: dict[str, int | float | str] = {} + gpu_uuid = raw_metrics.get("gpu_uuid") + if isinstance(gpu_uuid, str) and 0 < len(gpu_uuid) <= 255: + metrics["gpu_uuid"] = gpu_uuid + for key in ("used_vram_bytes", "process_used_vram_bytes", "free_vram_bytes"): + value = raw_metrics.get(key) + if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 2**63 - 1: + metrics[key] = value + utilization = raw_metrics.get("gpu_utilization_percent") + if isinstance(utilization, int) and not isinstance(utilization, bool) and 0 <= utilization <= 100: + metrics["gpu_utilization_percent"] = utilization + temperature = raw_metrics.get("temperature_c") + if ( + isinstance(temperature, int | float) + and not isinstance(temperature, bool) + and -50 <= temperature <= 200 + and math.isfinite(float(temperature)) + ): + metrics["temperature_c"] = temperature + power_draw = raw_metrics.get("power_draw_w") + if ( + isinstance(power_draw, int | float) + and not isinstance(power_draw, bool) + and 0 <= power_draw <= 100_000 + and math.isfinite(float(power_draw)) + ): + metrics["power_draw_w"] = power_draw + return metrics + + +def _worker_resource_observation( + raw_metrics: Any, *, expected_gpu_uuid: str | None +) -> dict[str, Any]: + metrics = _bounded_worker_metrics(raw_metrics) + expected = { + "gpu_uuid", + "used_vram_bytes", + "process_used_vram_bytes", + "free_vram_bytes", + "gpu_utilization_percent", + "temperature_c", + "power_draw_w", + } + observed = set(metrics) + gpu_uuid = metrics.get("gpu_uuid") + device_matches_target = ( + gpu_uuid == expected_gpu_uuid if isinstance(gpu_uuid, str) and expected_gpu_uuid else None + ) + return { + "source": "runtime-worker", + "phase": "after_inference", + "availability": ( + "complete" + if expected.issubset(observed) and device_matches_target is True + else "partial" if observed else "unavailable" + ), + "device_matches_target": device_matches_target, + "metrics": metrics, + } + + def _percentile(values: list[float], fraction: float) -> float | None: if not values: return None @@ -188,12 +269,22 @@ class ServingService: actor_id: str = "local-operator", ) -> None: self.session = session - self.settings = settings + self._deployment_settings = settings + self._effective_settings: Settings | None = None + self.authenticated_credential_id: uuid.UUID | None = None self.manifests = manifests self.payload_store = payload_store self.repo = ServingRepository(session) self.audit = AuditWriter(session, actor_type, actor_id) + @property + def settings(self) -> Settings: + # Authentication-only service instances must reject absent/invalid credentials before + # touching configuration storage. The first policy consumer pins the request snapshot. + if self._effective_settings is None: + self._effective_settings = resolve_serving_settings(self.session, self._deployment_settings) + return self._effective_settings + def _commit(self) -> None: try: self.session.commit() @@ -435,7 +526,7 @@ class ServingService: raise ServingError( 409, "PRODUCTION_APPROVAL_STALE", "production approval is not current" ) - contract = self.session.get(CapabilityContract, approval.capability_contract_id) + contract = self.repo.lock_contract_route(approval.capability_contract_id) if not contract: raise ServingError( 409, "CAPABILITY_NOT_FOUND", "approved capability contract disappeared" @@ -511,6 +602,7 @@ class ServingService: raise ServingError( 409, "STABLE_DEPLOYMENT_EXISTS", "drain the current stable deployment first" ) + self._advance_serving_generation(contract) deployment = self.repo.add( CapabilityDeployment( capability_contract_id=contract.id, @@ -625,6 +717,7 @@ class ServingService: ) return CapabilityDeploymentResponse( id=item.id, + route_generation=contract.serving_generation, capability=capability.key, contract_version=contract.version, deployment_candidate_id=item.deployment_candidate_id, @@ -940,6 +1033,15 @@ class ServingService: return "unavailable" def create_client(self, request: ServiceClientCreate) -> ServiceClientCreated: + try: + return self._create_client(request) + except IntegrityError as exc: + self.session.rollback() + raise ServingError(409, "CLIENT_EXISTS", "service client name already exists") from exc + + def _create_client(self, request: ServiceClientCreate) -> ServiceClientCreated: + if request.credential_expires_at is not None and request.credential_expires_at <= _now(): + raise ServingError(422, "CREDENTIAL_EXPIRY_INVALID", "credential expiry must be in the future") if self.repo.client_by_name(request.name): raise ServingError(409, "CLIENT_EXISTS", "service client name already exists") project: Project | None = None @@ -976,6 +1078,10 @@ class ServingService: (item for item in bindings if item.channel in preferred_channels), bindings[0] if bindings else None, ) + if request.project_binding_id is not None: + project_binding = next( + (item for item in bindings if item.id == request.project_binding_id), None + ) if project_binding is None: raise ServingError( 409, @@ -1043,6 +1149,7 @@ class ServingService: integration_environment=client.integration_environment, purpose=client.purpose, credential_prefix=credential.secret_prefix if credential else None, + credential_id=credential.id if credential else None, credential_created_at=credential.created_at if credential else None, credential_expires_at=credential.expires_at if credential else None, credential_revoked_at=credential.revoked_at if credential else None, @@ -1053,27 +1160,79 @@ class ServingService: def clients(self) -> list[ServiceClientResponse]: return [self.client_response(item) for item in self.repo.clients()] - def revoke_client_credential(self, client_id: uuid.UUID) -> ServiceClientResponse: + def credential_history(self, client_id: uuid.UUID) -> list[ServiceCredentialResponse]: + if self.repo.client(client_id) is None: + raise ServingError(404, "CLIENT_NOT_FOUND", "service client was not found") + return [ServiceCredentialResponse.model_validate(item) for item in self.session.scalars( + select(ServiceCredential).where(ServiceCredential.service_client_id == client_id) + .order_by(ServiceCredential.created_at.desc()).limit(100) + )] + + def _claim_credential(self, credential: ServiceCredential) -> None: + # Conditional UPDATE serializes rotation/revocation on the same current credential. + # An expired SQLAlchemy identity-map entry can never make a stale browser action win. + claimed = self.session.execute(update(ServiceCredential).where( + ServiceCredential.id == credential.id, + ServiceCredential.revoked_at.is_(None), + ServiceCredential.superseded_at.is_(None), + ).values(superseded_at=_now()).execution_options(synchronize_session=False)) + if rows_affected(claimed) != 1: + self.session.rollback() + raise ServingError(409, "CREDENTIAL_CHANGED", "credential changed; refresh before trying again") + self.session.refresh(credential) + + def revoke_client_credential( + self, client_id: uuid.UUID, request: ServiceCredentialRevoke | None = None + ) -> ServiceClientResponse: client = self.repo.client(client_id) credential = self.repo.active_credential(client_id) if client else None if not client: raise ServingError(404, "CLIENT_NOT_FOUND", "service client was not found") - if credential and not credential.revoked_at: - credential.revoked_at = _now() + if request and (credential is None or credential.id != request.expected_credential_id): + raise ServingError(409, "CREDENTIAL_CHANGED", "credential changed; refresh before trying again") + if request and request.confirmation != client.name: + raise ServingError(422, "CONFIRMATION_REQUIRED", "type the exact service client name") + if credential: + self._claim_credential(credential) + self.session.execute(update(ServiceCredential).where( + ServiceCredential.service_client_id == client_id, + ServiceCredential.revoked_at.is_(None), + ).values(revoked_at=_now())) self.audit.write( "CLIENT_CREDENTIAL_REVOKED", "service_client", str(client.id), {"name": client.name} ) self._commit() return self.client_response(client, credential) - def rotate_client_credential(self, client_id: uuid.UUID) -> ServiceClientCreated: + def rotate_client_credential( + self, client_id: uuid.UUID, request: ServiceCredentialRotate | None = None + ) -> ServiceClientCreated: client = self.repo.client(client_id) if client is None: raise ServingError(404, "CLIENT_NOT_FOUND", "service client was not found") current = self.repo.active_credential(client_id) - if current is not None: - current.revoked_at = _now() - self.session.flush() + if current is None or (request and current.id != request.expected_credential_id): + raise ServingError(409, "CREDENTIAL_CHANGED", "credential changed; refresh before trying again") + now = _now() + expiry = request.expires_at if request else current.expires_at + if expiry is not None and _aware(expiry) <= now: + raise ServingError(422, "CREDENTIAL_EXPIRY_INVALID", "credential expiry must be in the future") + if client.status != "active" or client.disabled_at: + raise ServingError(409, "CLIENT_DISABLED", "disabled clients cannot rotate credentials") + self._claim_credential(current) + # A second rotation retires any older overlap: at most current + one previous key. + self.session.execute(update(ServiceCredential).where( + ServiceCredential.service_client_id == client_id, + ServiceCredential.id != current.id, + ServiceCredential.revoked_at.is_(None), + ).values(revoked_at=now)) + overlap = request.overlap_seconds if request else 0 + if overlap: + deadline = now + timedelta(seconds=overlap) + current.expires_at = min(_aware(current.expires_at), deadline) if current.expires_at else deadline + else: + current.revoked_at = now + self.session.flush() secret = "mf_" + secrets.token_urlsafe(36) credential = self.repo.add( ServiceCredential( @@ -1081,10 +1240,14 @@ class ServingService: secret_hash=hashlib.sha256(secret.encode()).hexdigest(), secret_prefix=secret[:12], created_at=_now(), + expires_at=expiry, ) ) self.audit.write( - "CLIENT_CREDENTIAL_ROTATED", "service_client", str(client.id), {"name": client.name} + "CLIENT_CREDENTIAL_ROTATED", "service_client", str(client.id), + {"name": client.name, "previous_credential_id": str(current.id), + "credential_id": str(credential.id), "overlap_seconds": overlap, + "expires_at": _aware(expiry).isoformat() if expiry else None} ) self._commit() return ServiceClientCreated( @@ -1337,7 +1500,9 @@ class ServingService: if bound_capability and contract else None ) - if binding is None or binding.deprecated_at is not None or bound_key != capability: + project = self.session.get(Project, binding.project_id) if binding else None + if (binding is None or binding.deprecated_at is not None or bound_key != capability + or project is None or not project.active or contract is None or contract.deprecated_at is not None): raise ServingError( 403, "PROJECT_CAPABILITY_NOT_BOUND", "project binding is inactive" ) @@ -1352,6 +1517,7 @@ class ServingService: def authenticate_with_evidence( self, authorization: str | None, capability: str ) -> tuple[ServiceClient, CapabilityAuthenticationEvidence]: + self.authenticated_credential_id = None secret = self._credential_secret(authorization) credential_sha256 = hashlib.sha256(secret.encode()).hexdigest() match = self.repo.credential_by_hash(credential_sha256) @@ -1368,6 +1534,7 @@ class ServingService: credential.last_used_at = now client.last_used_at = now self._commit_authentication() + self.authenticated_credential_id = credential.id return client, CapabilityAuthenticationEvidence( credential_id=credential.id, client_id=client.id, @@ -1387,6 +1554,7 @@ class ServingService: ) -> ServiceClient: """Revalidate current scope/client state while reusing the pre-body credential proof.""" + self.authenticated_credential_id = None secret = self._credential_secret(authorization) credential_sha256 = hashlib.sha256(secret.encode()).hexdigest() if capability != evidence.capability or not hmac.compare_digest( @@ -1412,9 +1580,15 @@ class ServingService: capability, enforce_request_limits=False, ) + self.authenticated_credential_id = credential.id return client - def _budget(self, deployment: CapabilityDeployment) -> SchedulerBudgetResponse: + def _budget( + self, + deployment: CapabilityDeployment, + *, + update_pressure: bool = True, + ) -> SchedulerBudgetResponse: node = self.repo.node(deployment.compute_node_id) accelerator = self.repo.accelerator(deployment.accelerator_id) telemetry = self.repo.telemetry(deployment.accelerator_id) @@ -1437,6 +1611,12 @@ class ServingService: and item.state != "cold" ) leases = self.repo.active_leases(accelerator.id, _now()) + external_reservations = list(self.session.scalars( + select(ExternalCreativeAllocation).where( + ExternalCreativeAllocation.accelerator_id == accelerator.id, + ExternalCreativeAllocation.state.in_(("granted", "uncertain")), + ) + )) policy = self._scheduler_policy() pressure_row = self.repo.scheduler_accelerator_state(accelerator.id) previous_pressure = ( @@ -1449,6 +1629,12 @@ class ServingService: leases=[ LeaseAllocation(item.reserved_vram_bytes, item.materialized_vram_bytes) for item in leases + ] + [ + # Unattributed NVML usage is not proof that a specific external + # grant materialized. Retain its full reservation until release, + # including expired/uncertain grants, in both admission paths. + LeaseAllocation(item.required_vram_bytes, 0) + for item in external_reservations ], policy=policy, telemetry_fresh=( @@ -1458,10 +1644,12 @@ class ServingService: ), previous_pressure=previous_pressure, ) - snapshot = replace( - snapshot, - pressure=self._stabilize_pressure(accelerator.id, snapshot.pressure, pressure_row), + effective_pressure = ( + self._stabilize_pressure(accelerator.id, snapshot.pressure, pressure_row) + if update_pressure + else self._project_pressure(snapshot.pressure, pressure_row) ) + snapshot = replace(snapshot, pressure=effective_pressure) return SchedulerBudgetResponse( compute_node_id=node.id, node_name=node.display_name or node.hostname, @@ -1489,17 +1677,36 @@ class ServingService: candidate: PressureState, row: SchedulerAcceleratorState | None, ) -> PressureState: - now = _now() + effective, changes = self._pressure_transition(candidate, row, _now()) if row is None: - self.repo.add( - SchedulerAcceleratorState( - accelerator_id=accelerator_id, - pressure_state=candidate.value, - pressure_changed_at=now, - last_observed_at=now, - ) - ) - return candidate + self.repo.add(SchedulerAcceleratorState(accelerator_id=accelerator_id, **changes)) + else: + for field, value in changes.items(): + setattr(row, field, value) + return effective + + def _project_pressure( + self, + candidate: PressureState, + row: SchedulerAcceleratorState | None, + ) -> PressureState: + effective, _changes = self._pressure_transition(candidate, row, _now()) + return effective + + def _pressure_transition( + self, + candidate: PressureState, + row: SchedulerAcceleratorState | None, + now: datetime, + ) -> tuple[PressureState, dict[str, Any]]: + if row is None: + return candidate, { + "pressure_state": candidate.value, + "pressure_changed_at": now, + "last_observed_at": now, + "recovery_candidate": None, + "recovery_candidate_since": None, + } current = PressureState(row.pressure_state) severity = { PressureState.NORMAL: 0, @@ -1507,33 +1714,41 @@ class ServingService: PressureState.HIGH: 2, PressureState.CRITICAL: 3, } - row.last_observed_at = now + changes: dict[str, Any] = {"last_observed_at": now} if severity[candidate] > severity[current]: - row.pressure_state = candidate.value - row.pressure_changed_at = now - row.recovery_candidate = None - row.recovery_candidate_since = None - return candidate + changes.update( + { + "pressure_state": candidate.value, + "pressure_changed_at": now, + "recovery_candidate": None, + "recovery_candidate_since": None, + } + ) + return candidate, changes if candidate == current: - row.recovery_candidate = None - row.recovery_candidate_since = None - return current + changes.update({"recovery_candidate": None, "recovery_candidate_since": None}) + return current, changes if row.recovery_candidate != candidate.value: - row.recovery_candidate = candidate.value - row.recovery_candidate_since = now - return current + changes.update( + {"recovery_candidate": candidate.value, "recovery_candidate_since": now} + ) + return current, changes if ( row.recovery_candidate_since and _aware(row.recovery_candidate_since) + timedelta(seconds=self.settings.scheduler_pressure_stable_seconds) <= now ): - row.pressure_state = candidate.value - row.pressure_changed_at = now - row.recovery_candidate = None - row.recovery_candidate_since = None - return candidate - return current + changes.update( + { + "pressure_state": candidate.value, + "pressure_changed_at": now, + "recovery_candidate": None, + "recovery_candidate_since": None, + } + ) + return candidate, changes + return current, changes def _scheduler_policy(self) -> SchedulerPolicy: configured = SchedulerPolicy( @@ -1807,7 +2022,12 @@ class ServingService: ) return responses - def scheduler_overview(self) -> list[SchedulerBudgetResponse]: + def scheduler_overview( + self, + *, + commit: bool = True, + update_pressure: bool = True, + ) -> list[SchedulerBudgetResponse]: representatives: dict[uuid.UUID, CapabilityDeployment] = {} for deployment in self.repo.deployments(): if deployment.status not in {"stable", "draining"}: @@ -1815,8 +2035,12 @@ class ServingService: current = representatives.get(deployment.accelerator_id) if current is None or (current.status != "stable" and deployment.status == "stable"): representatives[deployment.accelerator_id] = deployment - budgets = [self._budget(item) for item in representatives.values()] - self._commit() + budgets = [ + self._budget(item, update_pressure=update_pressure) + for item in representatives.values() + ] + if commit: + self._commit() return budgets def co_residency_matrix(self) -> list[CoResidencyEvidenceResponse]: @@ -2100,6 +2324,16 @@ class ServingService: raise ServingError(503, "NO_ELIGIBLE_NODE", "capability node is not online") return deployment, route_key + def _store_invocation_payload(self, payload: dict[str, Any]) -> str: + try: + return cast( + str, self.payload_store.put(payload, self.settings.serving_payload_ttl_seconds) + ) + except RedisError: + raise ServingError( + 503, "PAYLOAD_STORE_UNAVAILABLE", "transient payload store is unavailable" + ) from None + def _validate_inputs(self, request: EmbeddingInvokeRequest) -> list[str]: inputs = request.inputs() if len(inputs) > self.settings.gateway_max_batch_size: @@ -2170,8 +2404,12 @@ class ServingService: self, deployment: CapabilityDeployment, gateway_request: GatewayRequest, - client: ServiceClient, + context: DispatchContext, ) -> ServingGpuLease: + if (gateway_request.service_client_id != context.service_client_id + or gateway_request.priority != context.workload_priority + or gateway_request.capability_deployment_id != deployment.id): + raise ServingError(409, "DISPATCH_CONTEXT_MISMATCH", "request does not match trusted dispatch context") deadline = time.monotonic() + self.settings.gateway_queue_timeout_seconds while time.monotonic() < deadline: self.session.expire_all() @@ -2182,6 +2420,10 @@ class ServingService: envelope = self.repo.envelope(deployment.id) if not current or not residency or not envelope: raise ServingError(503, "CAPABILITY_UNAVAILABLE", "scheduler state is incomplete") + if (current.service_client_id != context.service_client_id + or current.priority != context.workload_priority + or current.capability_deployment_id != deployment.id): + raise ServingError(409, "DISPATCH_CONTEXT_MISMATCH", "queued dispatch context changed") if envelope.stale: raise ServingError( 503, @@ -2203,7 +2445,7 @@ class ServingService: resident = residency.measured_resident_vram_bytes if residency.state != "cold" else 0 plan = self._placement_plan( deployment, - priority=client.workload_priority, + priority=context.workload_priority, deadline_remaining_ms=float(self.settings.gateway_request_timeout_seconds * 1000), request_id=current.request_id, dry_run=False, @@ -2235,7 +2477,7 @@ class ServingService: key_suffix=( f"scheduler-{plan.decision_fingerprint[:16]}-{candidate.load_count}" ), - priority=client.workload_priority, + priority=context.workload_priority, ) self._wait_job(unload.id, deadline) continue @@ -2268,9 +2510,9 @@ class ServingService: capability_deployment_id=deployment.id, request_id=current.request_id, reserved_vram_bytes=required, - priority=client.workload_priority, + priority=context.workload_priority, state="active", - owner=client.name, + owner=context.lease_owner, created_at=now, acquired_at=now, heartbeat_at=now, @@ -2284,6 +2526,7 @@ class ServingService: current.status = "running" current.decision_evidence = { **current.decision_evidence, + "dispatch_context": context.provenance(), **self._placement_evidence(deployment), "placement_plan_id": str(plan.id), "decision": plan.verdict, @@ -2291,6 +2534,12 @@ class ServingService: "decision_fingerprint": plan.decision_fingerprint, "required_vram_bytes": required, } + lab_run_evidence = current.decision_evidence.get("lab_run_evidence") + if context.actor_kind == "operator" and isinstance(lab_run_evidence, dict): + current.decision_evidence = { + **current.decision_evidence, + "lab_run_evidence": {**lab_run_evidence, "status": "running"}, + } residency.active_requests += 1 if residency.state in {"warm", "idle"}: residency.state = "busy" @@ -2469,6 +2718,105 @@ class ServingService: deployment: CapabilityDeployment | None = None, experiment_route: str | None = None, ) -> EmbeddingInvokeResponse: + context = DispatchContext.for_service_client(client.id, client.name, client.workload_priority) + return self._invoke_embedding(request, context, + deployment=deployment, + experiment_route=experiment_route) + + def _operator_lab_target_snapshot( + self, deployment: CapabilityDeployment, experiment_route_id: uuid.UUID + ) -> dict[str, Any]: + candidate = self.session.get(DeploymentCandidate, deployment.deployment_candidate_id) + profile = self.repo.profile(deployment.runtime_profile_id) + accelerator = self.repo.accelerator(deployment.accelerator_id) + contract = self.session.get(CapabilityContract, deployment.capability_contract_id) + if ( + candidate is None + or profile is None + or accelerator is None + or contract is None + or deployment.execution_approval_id is None + or deployment.embedding_space_id is None + or profile.artifact_set_id != deployment.artifact_set_id + ): + raise ServingError( + 409, "LAB_TARGET_EVIDENCE_MISSING", "selected embedding target evidence is incomplete" + ) + return { + "deployment_id": str(deployment.id), + "candidate_id": str(candidate.id), + "experiment_route_id": str(experiment_route_id), + "capability_contract_id": str(contract.id), + "contract_version": contract.version, + "artifact_set_id": str(deployment.artifact_set_id), + "artifact_sha256": profile.artifact_sha256, + "runtime_profile_id": str(profile.id), + "runtime_profile_fingerprint": profile.fingerprint, + "compatibility_assessment_id": str(candidate.compatibility_assessment_id), + "runtime_probe_id": str(candidate.runtime_probe_id), + "compute_node_id": str(deployment.compute_node_id), + "accelerator_id": str(deployment.accelerator_id), + "accelerator_uuid": accelerator.device_uuid, + "execution_approval_id": str(deployment.execution_approval_id), + "embedding_space_id": str(deployment.embedding_space_id), + "deployment_fingerprint": deployment.config_fingerprint, + } + + def invoke_operator_lab( + self, input_value: LabInput, context: DispatchContext, *, + deployment_id: uuid.UUID, experiment_route_id: uuid.UUID, + ) -> EmbeddingInvokeResponse: + """Internal synchronous mechanism only; no operator HTTP route or durable run is created.""" + if context.actor_kind != "operator": + raise ServingError(403, "OPERATOR_CONTEXT_REQUIRED", "operator dispatch context is required") + if not isinstance(input_value, LabEmbeddingInput): + raise ServingError(422, "LAB_INPUT_NOT_SUPPORTED", "operator execution currently supports text embeddings only") + deployment, route_key = self._admit_operator_embedding(input_value, deployment_id, experiment_route_id) + return self._invoke_embedding(input_value.payload, context, deployment=deployment, + experiment_route=route_key, operator_route_id=experiment_route_id) + + def _admit_operator_embedding( + self, input_value: LabEmbeddingInput, deployment_id: uuid.UUID, route_id: uuid.UUID, + ) -> tuple[CapabilityDeployment, str]: + self.session.expire_all() + deployment = self.repo.deployment(deployment_id) + route = self.repo.experiment_route_by_id(route_id) + if (not deployment or not route or route.status != "active" + or route.capability_deployment_id != deployment.id + or route.capability_contract_id != deployment.capability_contract_id + or deployment.production or deployment.channel != "experiment" + or deployment.status != "candidate"): + raise ServingError(409, "LAB_TARGET_NOT_ROUTABLE", "exact active nonproduction experiment is required") + contract = self.session.get(CapabilityContract, deployment.capability_contract_id) + capability = self.session.get(Capability, contract.capability_id) if contract else None + if not contract or contract.version != 1 or not capability or capability.key != "rag.embedding": + raise ServingError(409, "LAB_CONTRACT_MISMATCH", "selected deployment must implement rag.embedding@1") + if deployment.execution_approval_id is None: + raise ServingError(409, "LAB_APPROVAL_STALE", "exact LAB approval is required") + preflight = LabPreflightService(self.session, self.settings).preflight(LabPreflightRequest( + target=LabTarget(deployment_candidate_id=deployment.deployment_candidate_id, + artifact_set_id=deployment.artifact_set_id, runtime_profile_id=deployment.runtime_profile_id, + compute_node_id=deployment.compute_node_id, execution_approval_id=deployment.execution_approval_id), + input=input_value)) + if not preflight.checks_passed: + raise ServingError(409, "LAB_ADMISSION_BLOCKED", "selected LAB evidence or input is no longer admissible", + {"blockers": preflight.blockers}) + if preflight.binding is None or preflight.binding.accelerator_id != deployment.accelerator_id: + raise ServingError(409, "LAB_TARGET_MISMATCH", "deployment accelerator differs from the proven target") + return deployment, route.route_key + + def _invoke_embedding( + self, request: EmbeddingInvokeRequest, context: DispatchContext, *, + deployment: CapabilityDeployment | None, experiment_route: str | None = None, + operator_route_id: uuid.UUID | None = None, + ) -> EmbeddingInvokeResponse: + if context.actor_kind == "operator" and (operator_route_id is None or deployment is None): + raise ServingError(403, "LAB_TARGET_REQUIRED", "operator execution requires an explicitly admitted LAB route") + if context.actor_kind == "operator" and operator_route_id is not None and deployment is not None: + deployment, selected_route = self._admit_operator_embedding( + LabEmbeddingInput(capability="rag.embedding", payload=request), deployment.id, operator_route_id) + if experiment_route != selected_route: + raise ServingError(409, "LAB_TARGET_MISMATCH", "operator route does not match selected deployment") started = time.perf_counter() validation_started = time.perf_counter() inputs = self._validate_inputs(request) @@ -2488,20 +2836,40 @@ class ServingService: raise ServingError(429, "QUEUE_FULL", "global capability queue is full") request_id = uuid.uuid4() input_hash = _canonical_hash(inputs) + lab_run_evidence = None + if context.actor_kind == "operator": + if operator_route_id is None or context.operation_id is None: + raise ServingError(403, "LAB_TARGET_REQUIRED", "operator execution requires a persisted run identity") + lab_run_evidence = { + "schema_version": 1, + "status": "queued", + "capability": "rag.embedding@1", + "operation_id": str(context.operation_id), + "target": self._operator_lab_target_snapshot(deployment, operator_route_id), + "input": { + "sha256": input_hash, + "item_count": len(inputs), + "content_logged": False, + }, + } record = self.repo.add( GatewayRequest( request_id=request_id, - service_client_id=client.id, + service_client_id=context.service_client_id, capability_key="rag.embedding", capability_version=1, capability_deployment_id=deployment.id, compute_node_id=deployment.compute_node_id, status="queued", - priority=client.workload_priority, + priority=context.workload_priority, input_sha256=input_hash, input_count=len(inputs), decision_evidence={ + "dispatch_context": context.provenance(), + "operator_route_id": str(operator_route_id) if operator_route_id else None, "input_persisted": False, + "service_credential_id": str(self.authenticated_credential_id) + if context.actor_kind == "service_client" and self.authenticated_credential_id else None, "input_type": request.input_type, "experiment_route": experiment_route, "shape": { @@ -2509,6 +2877,7 @@ class ServingService: "item_count": len(inputs), "maximum_characters": max(map(len, inputs), default=0), }, + **({"lab_run_evidence": lab_run_evidence} if lab_run_evidence else {}), }, ) ) @@ -2516,26 +2885,34 @@ class ServingService: deadline = time.monotonic() + self.settings.gateway_request_timeout_seconds lease: ServingGpuLease | None = None result_reference: str | None = None + payload_reference: str | None = None + job_enqueued = False try: scheduling_started = time.perf_counter() - lease = self._acquire_request_lease(deployment, record, client) + lease = self._acquire_request_lease(deployment, record, context) scheduling_ms = (time.perf_counter() - scheduling_started) * 1000 + if operator_route_id is not None: + self._admit_operator_embedding(LabEmbeddingInput(capability="rag.embedding", payload=request), + deployment.id, operator_route_id) cold, load_ms = self._ensure_loaded( - deployment, deadline, priority=client.workload_priority + deployment, deadline, priority=context.workload_priority ) payload_started = time.perf_counter() - payload_reference = self.payload_store.put( - {"input": inputs, "input_type": request.input_type}, - self.settings.serving_payload_ttl_seconds, + payload_reference = self._store_invocation_payload( + {"input": inputs, "input_type": request.input_type} ) + if operator_route_id is not None: + self._admit_operator_embedding(LabEmbeddingInput(capability="rag.embedding", payload=request), + deployment.id, operator_route_id) job = self._enqueue_job( deployment, "invoke", request=record, payload_reference=payload_reference, key_suffix=f"request-{request_id}", - priority=client.workload_priority, + priority=context.workload_priority, ) + job_enqueued = True payload_ms = (time.perf_counter() - payload_started) * 1000 wait_started = time.perf_counter() completed, result = self._wait_job(job.id, deadline) @@ -2622,6 +2999,40 @@ class ServingService: "latency_clock": "process-local-monotonic; control-plane timestamps for dispatch", "latency_ms": latency_ms, } + lab_run_evidence = current.decision_evidence.get("lab_run_evidence") + if context.actor_kind == "operator" and isinstance(lab_run_evidence, dict): + target = lab_run_evidence.get("target", {}) + expected_gpu_uuid = ( + target.get("accelerator_uuid") if isinstance(target, dict) else None + ) + resource_observation = completed.result_summary.get("resource_observation", {}) + raw_worker_metrics = ( + resource_observation.get("metrics", {}) + if isinstance(resource_observation, dict) + else {} + ) + current.decision_evidence = { + **current.decision_evidence, + "lab_run_evidence": { + **lab_run_evidence, + "status": "completed", + "output": { + "type": "float_vector", + "dimension": space.dimension, + "item_count": len(vectors), + "embedding_space_id": str(space.id), + "sha256": _canonical_hash(vectors), + "content_logged": False, + }, + "worker_observations": _worker_resource_observation( + raw_worker_metrics, + expected_gpu_uuid=( + expected_gpu_uuid if isinstance(expected_gpu_uuid, str) else None + ), + ), + "timing_ms": latency_ms, + }, + } self._commit() selected_node = self.repo.node(deployment.compute_node_id) if not selected_node: @@ -2670,6 +3081,17 @@ class ServingService: current.failure_code = exc.code current.total_latency_ms = (time.perf_counter() - started) * 1000 current.finished_at = _now() + lab_run_evidence = current.decision_evidence.get("lab_run_evidence") + if context.actor_kind == "operator" and isinstance(lab_run_evidence, dict): + current.decision_evidence = { + **current.decision_evidence, + "lab_run_evidence": { + **lab_run_evidence, + "status": "failed", + "failure_code": exc.code, + "timing_ms": {"total": current.total_latency_ms}, + }, + } if residency and residency.active_requests: residency.active_requests -= 1 if residency.state == "busy": @@ -2681,12 +3103,15 @@ class ServingService: self._commit() raise finally: + if payload_reference and not job_enqueued: + self.payload_store.delete(payload_reference) if result_reference: self.payload_store.delete(result_reference) def invoke_reranking( self, request: RerankingInvokeRequest, client: ServiceClient ) -> RerankingInvokeResponse: + context = DispatchContext.for_service_client(client.id, client.name, client.workload_priority) started = time.perf_counter() validation_started = time.perf_counter() documents = self._validate_reranking_input(request) @@ -2713,19 +3138,20 @@ class ServingService: record = self.repo.add( GatewayRequest( request_id=request_id, - service_client_id=client.id, + service_client_id=context.service_client_id, capability_key="rag.reranking", capability_version=1, capability_deployment_id=deployment.id, compute_node_id=deployment.compute_node_id, status="queued", - priority=client.workload_priority, + priority=context.workload_priority, input_sha256=_canonical_hash(content_facts), input_count=len(documents), decision_evidence={ "input_persisted": False, "content_logged": False, "document_count": len(documents), + "service_credential_id": str(self.authenticated_credential_id) if self.authenticated_credential_id else None, "top_n": request.top_n, "experiment_route": experiment_route, "shape": { @@ -2746,15 +3172,14 @@ class ServingService: result_reference: str | None = None try: scheduling_started = time.perf_counter() - lease = self._acquire_request_lease(deployment, record, client) + lease = self._acquire_request_lease(deployment, record, context) scheduling_ms = (time.perf_counter() - scheduling_started) * 1000 cold, load_ms = self._ensure_loaded( - deployment, deadline, priority=client.workload_priority + deployment, deadline, priority=context.workload_priority ) payload_started = time.perf_counter() - input_reference = self.payload_store.put( - {"query": request.query, "documents": documents, "top_n": request.top_n}, - self.settings.serving_payload_ttl_seconds, + input_reference = self._store_invocation_payload( + {"query": request.query, "documents": documents, "top_n": request.top_n} ) job = self._enqueue_job( deployment, @@ -2762,7 +3187,7 @@ class ServingService: request=record, payload_reference=input_reference, key_suffix=f"request-{request_id}", - priority=client.workload_priority, + priority=context.workload_priority, ) payload_ms = (time.perf_counter() - payload_started) * 1000 wait_started = time.perf_counter() @@ -2967,6 +3392,7 @@ class ServingService: payload: dict[str, Any], client: ServiceClient, ) -> tuple[dict[str, Any], uuid.UUID, GatewayExecution]: + context = DispatchContext.for_service_client(client.id, client.name, client.workload_priority) started = time.perf_counter() resolution_started = time.perf_counter() deployment, experiment_route = self._experimental_deployment(capability_key) @@ -2993,13 +3419,13 @@ class ServingService: record = self.repo.add( GatewayRequest( request_id=request_id, - service_client_id=client.id, + service_client_id=context.service_client_id, capability_key=capability_key, capability_version=1, capability_deployment_id=deployment.id, compute_node_id=deployment.compute_node_id, status="queued", - priority=client.workload_priority, + priority=context.workload_priority, input_sha256=_canonical_hash(payload), input_count=input_count, decision_evidence={ @@ -3007,6 +3433,7 @@ class ServingService: "content_logged": False, "experiment_route": experiment_route, "modality": capability_key.split(".", 1)[0], + "service_credential_id": str(self.authenticated_credential_id) if self.authenticated_credential_id else None, "shape": { "bucket": _shape_bucket(approximate_bytes), "encoded_payload_bytes_approximate": approximate_bytes, @@ -3022,22 +3449,20 @@ class ServingService: result_reference: str | None = None try: scheduling_started = time.perf_counter() - lease = self._acquire_request_lease(deployment, record, client) + lease = self._acquire_request_lease(deployment, record, context) scheduling_ms = (time.perf_counter() - scheduling_started) * 1000 cold, load_ms = self._ensure_loaded( - deployment, deadline, priority=client.workload_priority + deployment, deadline, priority=context.workload_priority ) payload_started = time.perf_counter() - input_reference = self.payload_store.put( - payload, self.settings.serving_payload_ttl_seconds - ) + input_reference = self._store_invocation_payload(payload) job = self._enqueue_job( deployment, "invoke", request=record, payload_reference=input_reference, key_suffix=f"request-{request_id}", - priority=client.workload_priority, + priority=context.workload_priority, ) payload_ms = (time.perf_counter() - payload_started) * 1000 wait_started = time.perf_counter() @@ -3234,7 +3659,7 @@ class ServingService: rerank_documents: list[dict[str, str]] | None = None top_n: int | None = None modality_payload: dict[str, Any] | None = None - if job.operation == "invoke": + if job.operation == "invoke" and job.lab_execution_run_id is None: transient = ( self.payload_store.get(job.payload_reference) if job.payload_reference else None ) @@ -3279,6 +3704,80 @@ class ServingService: top_n = int(transient["top_n"]) else: modality_payload = transient + lab_run: LabExecutionRun | None = None + if job.lab_execution_run_id is not None: + lab_run = self.session.scalar( + select(LabExecutionRun) + .where(LabExecutionRun.id == job.lab_execution_run_id) + .with_for_update() + ) + expected_status = "queued" if job.operation == "invoke" else "preparing" + if lab_run is None or lab_run.status != expected_status: + job.status = ( + "cancelled" + if lab_run and lab_run.status in {"cancel_requested", "cancelled"} + else "failed" + ) + job.failure_code = ( + "CANCELLED" if job.status == "cancelled" else "LAB_RUN_STATE_INVALID" + ) + job.finished_at = _now() + if lab_run and lab_run.status == "cancel_requested": + from modelforge_api.services.lab_runs import LabRunService + + run_service = LabRunService(self) + terminal = "failed" if lab_run.failure_code else "cancelled" + run_service._finish(lab_run, terminal, failure_code=lab_run.failure_code) + run_service._release_reservation(lab_run) + self._commit() + return None + from modelforge_api.services.lab_runs import LabRunService + + run_service = LabRunService(self) + selected_input = run_service._input(lab_run) + if selected_input is None: + job.status = "failed" + job.failure_code = "REQUEST_PAYLOAD_EXPIRED" + job.finished_at = _now() + run_service._finish(lab_run, "failed", failure_code="REQUEST_PAYLOAD_EXPIRED") + run_service._release_reservation(lab_run, failed=True) + self._commit() + return None + if job.operation == "invoke": + if job.payload_reference != lab_run.payload_reference: + job.status = "failed" + job.failure_code = "LAB_PAYLOAD_REFERENCE_MISMATCH" + job.finished_at = _now() + run_service._finish( + lab_run, "failed", failure_code="LAB_PAYLOAD_REFERENCE_MISMATCH" + ) + run_service._release_reservation(lab_run, failed=True) + self._commit() + return None + input_value = selected_input.payload.inputs() + input_type = selected_input.payload.input_type + try: + admitted, _ = self._admit_operator_embedding( + selected_input, + deployment.id, + uuid.UUID(str(lab_run.target_snapshot["experiment_route_id"])), + ) + selected_snapshot = self._operator_lab_target_snapshot( + admitted, + uuid.UUID(str(lab_run.target_snapshot["experiment_route_id"])), + ) + if selected_snapshot != lab_run.target_snapshot: + raise ServingError( + 409, "LAB_TARGET_CHANGED", "selected LAB target is no longer admissible" + ) + except (ServingError, KeyError, ValueError): + job.status = "failed" + job.failure_code = "LAB_TARGET_CHANGED" + job.finished_at = _now() + run_service._finish(lab_run, "failed", failure_code="LAB_TARGET_CHANGED") + run_service._release_reservation(lab_run, failed=True) + self._commit() + return None token = secrets.token_urlsafe(32) now = _now() job.status = "running" @@ -3286,6 +3785,18 @@ class ServingService: job.lease_token_hash = hashlib.sha256(token.encode()).hexdigest() job.lease_expires_at = now + timedelta(seconds=self.settings.serving_job_lease_seconds) job.started_at = job.started_at or now + if lab_run is not None and job.operation == "invoke": + lab_run.status = "running" + lab_run.started_at = lab_run.started_at or now + lab_run.heartbeat_at = now + gateway_request = ( + self.session.get(GatewayRequest, job.gateway_request_id) + if job.gateway_request_id + else None + ) + if gateway_request: + gateway_request.status = "running" + run_service._set_gateway_state(lab_run, "running") self._commit() runtime = RuntimeService(self.session, self.settings) gateway_request = ( @@ -3329,6 +3840,7 @@ class ServingService: top_n=top_n, modality_payload=modality_payload, request_id=gateway_request.request_id if gateway_request else None, + lab_run_id=lab_run.id if lab_run else None, ) @staticmethod @@ -3341,16 +3853,136 @@ class ServingService: if not job.lease_expires_at or _aware(job.lease_expires_at) <= _now(): raise ServingError(409, "LEASE_TIMEOUT", "serving job lease expired") + def check_lab_cancellation( + self, + job_id: uuid.UUID, + node: ComputeNode, + request: AgentServingJobCancellationCheck, + ) -> AgentServingJobCancellationStatus: + job = self.session.scalar( + select(ServingJob).where(ServingJob.id == job_id).with_for_update() + ) + if not job or job.lab_execution_run_id is None or job.status != "running": + raise ServingError(404, "SERVING_JOB_NOT_FOUND", "LAB serving job was not found") + self._assert_job_lease(job, node, request.lease_token) + run = self.session.scalar( + select(LabExecutionRun) + .where(LabExecutionRun.id == job.lab_execution_run_id) + .with_for_update() + ) + if run is None: + raise ServingError(404, "LAB_RUN_NOT_FOUND", "LAB run was not found") + now = _now() + run.heartbeat_at = now + deadline = _aware(run.created_at) + timedelta(seconds=run.timeout_seconds) + job.lease_expires_at = min( + deadline, + now + timedelta(seconds=self.settings.serving_job_lease_seconds), + ) + gateway = self.session.get(GatewayRequest, run.gateway_request_id) + if gateway: + lease = self.session.scalar( + select(ServingGpuLease) + .where(ServingGpuLease.request_id == gateway.request_id) + .order_by(ServingGpuLease.created_at.desc()) + .limit(1) + ) + if lease and lease.state in {"pending", "granted", "active", "releasing"}: + lease.heartbeat_at = now + lease.expires_at = job.lease_expires_at + self._commit() + return AgentServingJobCancellationStatus(cancel_requested=run.status == "cancel_requested") + + def acknowledge_lab_cancellation( + self, + job_id: uuid.UUID, + node: ComputeNode, + request: AgentServingJobCancellationAck, + ) -> ServingJob: + job = self.session.scalar( + select(ServingJob).where(ServingJob.id == job_id).with_for_update() + ) + if not job or job.lab_execution_run_id is None: + raise ServingError(404, "SERVING_JOB_NOT_FOUND", "LAB serving job was not found") + self._assert_job_lease(job, node, request.lease_token) + run = self.session.scalar( + select(LabExecutionRun) + .where(LabExecutionRun.id == job.lab_execution_run_id) + .with_for_update() + ) + if run is None or run.status != "cancel_requested" or job.operation != "invoke": + raise ServingError( + 409, "LAB_CANCEL_NOT_REQUESTED", "worker cancellation was not requested" + ) + now = _now() + run.cancellation_acknowledged_at = now + run.worker_observations = { + "source": "runtime-worker", + "phase": "after_inference", + "availability": "cancelled", + "safe_boundary_reached": True, + "worker_instance_id": request.worker_instance_id, + "worker_state": request.state, + } + job.status = "cancelled" + job.failure_code = "CANCELLED" + job.finished_at = now + job.lease_token_hash = None + job.lease_expires_at = None + job.result_reference = None + job.result_summary = {"cancelled": True, "safe_boundary_reached": True} + if job.payload_reference: + self.payload_store.delete(job.payload_reference) + job.payload_reference = None + from modelforge_api.services.lab_runs import LabRunService + + run_service = LabRunService(self) + terminal = "failed" if run.failure_code else "cancelled" + run_service._finish(run, terminal, failure_code=run.failure_code) + run_service._release_reservation(run, failed=terminal == "failed") + deployment = self.repo.deployment(job.capability_deployment_id) + residency = self.repo.residency(job.capability_deployment_id, lock=True) + if residency: + if request.state in {"warm", "cold"}: + residency.state = request.state + elif request.state == "failed": + residency.state = "failed" + if deployment: + deployment.health_status = "unavailable" + residency.failure_code = ( + None if residency.state in {"warm", "cold"} else residency.failure_code + ) + self._commit() + return job + def complete_job( self, job_id: uuid.UUID, node: ComputeNode, request: AgentServingJobComplete, ) -> ServingJob: - job = self.repo.job(job_id) + job = self.session.scalar( + select(ServingJob).where(ServingJob.id == job_id).with_for_update() + ) if not job: raise ServingError(404, "SERVING_JOB_NOT_FOUND", "serving job was not found") self._assert_job_lease(job, node, request.lease_token) + lab_run = ( + self.session.scalar( + select(LabExecutionRun) + .where(LabExecutionRun.id == job.lab_execution_run_id) + .with_for_update() + ) + if job.lab_execution_run_id + else None + ) + if job.lab_execution_run_id and ( + lab_run is None or lab_run.status not in {"running", "cancel_requested", "preparing"} + ): + raise ServingError( + 409, "LAB_RUN_STATE_INVALID", "LAB run is no longer awaiting this job" + ) + lab_cancel_requested = bool(lab_run and lab_run.status == "cancel_requested") deployment = self.repo.deployment(job.capability_deployment_id) residency = self.repo.residency(job.capability_deployment_id, lock=True) if not deployment or not residency: @@ -3366,7 +3998,13 @@ class ServingService: summary: dict[str, Any] if capability.key == "rag.embedding": vectors = result.get("vectors") - expected_count = int(result.get("count", 1)) + expected_count = lab_run.input_count if lab_run else int(result.get("count", 1)) + if lab_run and ( + result.get("count") != expected_count or result.get("normalized") is not True + ): + raise ServingError( + 502, "INVALID_OUTPUT", "runtime returned an invalid LAB embedding result" + ) space = self.repo.embedding_space(deployment.embedding_space_id) if not space: raise ServingError(409, "CAPABILITY_UNAVAILABLE", "embedding space is missing") @@ -3442,10 +4080,15 @@ class ServingService: } else: raise ServingError(409, "CAPABILITY_UNAVAILABLE", "unsupported capability") - result_reference = self.payload_store.put( - transient_result, self.settings.serving_payload_ttl_seconds - ) - job.result_reference = result_reference + if lab_run is None: + result_reference = self.payload_store.put( + transient_result, self.settings.serving_payload_ttl_seconds + ) + job.result_reference = result_reference + else: + # LAB run results are reduced to a digest and bounded metadata; vectors are + # never written to Redis or PostgreSQL for this operator path. + job.result_reference = None job.result_summary = { **summary, "inference_time_ms": float(metrics.get("inference_time_ms", 0.0)), @@ -3454,6 +4097,34 @@ class ServingService: "worker_total_time_ms": float(metrics.get("worker_total_time_ms", 0.0)), "prompt_tokens": int(result.get("prompt_tokens", 0)), } + gateway_request = ( + self.session.get(GatewayRequest, job.gateway_request_id) + if job.gateway_request_id + else None + ) + dispatch_context = ( + gateway_request.decision_evidence.get("dispatch_context", {}) + if gateway_request + else {} + ) + if ( + job.operation == "invoke" + and capability.key == "rag.embedding" + and gateway_request is not None + and gateway_request.service_client_id is None + and gateway_request.priority == "lab" + and isinstance(dispatch_context, dict) + and dispatch_context.get("actor_kind") == "operator" + and dispatch_context.get("workload_priority") == "lab" + ): + accelerator = self.repo.accelerator(deployment.accelerator_id) + job.result_summary = { + **job.result_summary, + "resource_observation": _worker_resource_observation( + metrics, + expected_gpu_uuid=accelerator.device_uuid if accelerator else None, + ), + } residency.state = "warm" residency.last_used_at = _now() residency.health = request.health @@ -3540,6 +4211,72 @@ class ServingService: if job.payload_reference: self.payload_store.delete(job.payload_reference) job.payload_reference = None + if lab_run is not None: + from modelforge_api.services.lab_runs import LabRunService + + run_service = LabRunService(self) + now = _now() + if job.operation == "invoke": + accelerator = self.repo.accelerator(deployment.accelerator_id) + safe_observation = _worker_resource_observation( + metrics, + expected_gpu_uuid=accelerator.device_uuid if accelerator else None, + ) + if lab_cancel_requested: + lab_run.cancellation_acknowledged_at = now + lab_run.worker_observations = { + "source": "runtime-worker", + "phase": "after_inference", + "availability": "cancelled", + "safe_boundary_reached": True, + "worker_instance_id": request.worker_instance_id, + "worker_state": request.state, + } + job.status = "cancelled" + job.failure_code = "CANCELLED" + job.result_reference = None + job.result_summary = {"cancelled": True, "safe_boundary_reached": True} + terminal = "failed" if lab_run.failure_code else "cancelled" + run_service._finish(lab_run, terminal, failure_code=lab_run.failure_code) + run_service._release_reservation(lab_run, failed=terminal == "failed") + else: + space = self.repo.embedding_space(deployment.embedding_space_id) + if not space: + raise ServingError( + 409, "CAPABILITY_UNAVAILABLE", "embedding space is missing" + ) + lab_run.status = "completed" + lab_run.output_metadata = { + "type": "float_vector", + "dimension": space.dimension, + "item_count": lab_run.input_count, + "embedding_space_id": str(space.id), + "sha256": _canonical_hash(validated), + "content_logged": False, + } + lab_run.worker_observations = safe_observation + lab_run.failure_code = None + lab_run.finished_at = now + run_service._set_gateway_state(lab_run, "completed") + run_service._delete_payload(lab_run) + run_service._release_reservation(lab_run) + elif job.operation == "load": + if lab_cancel_requested: + lab_run.cancellation_acknowledged_at = now + lab_run.worker_observations = { + "source": "runtime-worker", + "phase": "after_load", + "availability": "cancelled", + "safe_boundary_reached": True, + "worker_instance_id": request.worker_instance_id, + "worker_state": request.state, + } + terminal = "failed" if lab_run.failure_code else "cancelled" + run_service._finish(lab_run, terminal, failure_code=lab_run.failure_code) + run_service._release_reservation(lab_run, failed=terminal == "failed") + else: + lab_run.heartbeat_at = now + run_service._set_gateway_state(lab_run, "preparing") self._commit() return job @@ -3549,15 +4286,35 @@ class ServingService: node: ComputeNode, request: AgentServingJobFailure, ) -> ServingJob: - job = self.repo.job(job_id) + job = self.session.scalar( + select(ServingJob).where(ServingJob.id == job_id).with_for_update() + ) if not job: raise ServingError(404, "SERVING_JOB_NOT_FOUND", "serving job was not found") self._assert_job_lease(job, node, request.lease_token) + lab_run = ( + self.session.scalar( + select(LabExecutionRun) + .where(LabExecutionRun.id == job.lab_execution_run_id) + .with_for_update() + ) + if job.lab_execution_run_id + else None + ) residency = self.repo.residency(job.capability_deployment_id, lock=True) job.status = "failed" - job.failure_code = request.failure_code - job.failure_message = request.failure_message - job.result_summary = {"details": request.details} + safe_failure_code = ( + request.failure_code + if re.fullmatch(r"[A-Z0-9_]{1,64}", request.failure_code) + else "WORKER_FAILURE" + ) + job.failure_code = safe_failure_code + job.failure_message = None if lab_run else request.failure_message + job.result_summary = ( + {"operation": job.operation, "failure_code": safe_failure_code} + if lab_run + else {"details": request.details} + ) job.finished_at = _now() job.lease_token_hash = None job.lease_expires_at = None @@ -3566,12 +4323,30 @@ class ServingService: job.payload_reference = None if residency: residency.state = "cold" if job.operation in {"load", "invoke"} else "failed" - residency.failure_code = request.failure_code - residency.failure_message = request.failure_message + residency.failure_code = safe_failure_code + residency.failure_message = None if lab_run else request.failure_message residency.measured_resident_vram_bytes = ( 0 if residency.state == "cold" else residency.measured_resident_vram_bytes ) residency.active_requests = max(0, residency.active_requests - 1) + if lab_run: + from modelforge_api.services.lab_runs import LabRunService + + deployment = self.repo.deployment(job.capability_deployment_id) + if deployment: + deployment.health_status = "unavailable" + lab_run.worker_observations = { + "source": "runtime-worker", + "phase": "after_inference" if job.operation == "invoke" else "after_load", + "availability": "failed", + "safe_boundary_reached": True, + "worker_instance_id": request.worker_instance_id, + "worker_state": request.state, + } + lab_run.finished_at = _now() + run_service = LabRunService(self) + run_service._finish(lab_run, "failed", failure_code=safe_failure_code) + run_service._release_reservation(lab_run, failed=True) self._commit() return job @@ -3685,16 +4460,39 @@ class ServingService: self._commit() return AgentServingStateAck() + def _advance_serving_generation(self, contract: CapabilityContract) -> None: + """Fence every stable route mutation, including the legacy first-promotion path.""" + expected = contract.serving_generation + result = self.session.execute( + update(CapabilityContract).where( + CapabilityContract.id == contract.id, + CapabilityContract.serving_generation == expected, + ).values(serving_generation=expected + 1) + ) + if rows_affected(result) != 1: + self.session.rollback() + raise ServingError(409, "SERVING_ROUTE_CHANGED", "stable route changed concurrently") + def request_unload( self, deployment_id: uuid.UUID, *, drain: bool = False ) -> CapabilityDeploymentResponse: deployment = self.repo.deployment(deployment_id) + route_contract = None + if deployment is not None and drain: + route_contract = self.repo.lock_contract_route(deployment.capability_contract_id) + if route_contract is None: + raise ServingError(409, "CAPABILITY_NOT_FOUND", "capability contract disappeared") + # Read status after the route lock: another switch may have drained this row. + self.session.refresh(deployment) residency = self.repo.residency(deployment_id, lock=True) if deployment else None if not deployment or not residency: raise ServingError(404, "CAPABILITY_NOT_FOUND", "capability deployment was not found") if residency.active_requests: raise ServingError(409, "DEPLOYMENT_BUSY", "active requests must finish before unload") if drain: + if (route_contract is not None and deployment.production + and deployment.channel == "stable" and deployment.status == "stable"): + self._advance_serving_generation(route_contract) deployment.status = "draining" deployment.draining_at = _now() deployment.health_status = "degraded" @@ -3774,5 +4572,15 @@ class ServingService: for lease in self.repo.expired_leases(now): if lease.state == "expired": expired += 1 + from modelforge_api.services.benchmark_runs import BenchmarkRunService + from modelforge_api.services.lab_runs import LabRunService + + lab_runs = LabRunService(self).reconcile() + benchmark_runs = BenchmarkRunService(self).reconcile() self._commit() - return {"expired_leases": expired, "unloads_queued": unloads} + return { + "expired_leases": expired, + "unloads_queued": unloads, + **lab_runs, + **benchmark_runs, + } diff --git a/backend/src/modelforge_api/services/serving_reconciliation.py b/backend/src/modelforge_api/services/serving_reconciliation.py index 2f6db83..3916331 100644 --- a/backend/src/modelforge_api/services/serving_reconciliation.py +++ b/backend/src/modelforge_api/services/serving_reconciliation.py @@ -1,9 +1,12 @@ from __future__ import annotations import asyncio +import uuid import redis +import structlog from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from modelforge_api.services.manifest_registry import ManifestRegistry @@ -11,6 +14,8 @@ from modelforge_api.services.serving import ServingError, ServingService from modelforge_api.services.transient_payloads import RedisPayloadStore from modelforge_api.settings import Settings +logger = structlog.get_logger() + class ServingReconciliationService: def __init__(self, settings: Settings, engine: Engine) -> None: @@ -30,9 +35,15 @@ class ServingReconciliationService: actor_type="system", actor_id="serving-reconciler", ).reconcile() - except (OSError, RuntimeError, ValueError, ServingError, redis.RedisError): - # Readiness and structured state expose persistent failures; the loop stays bounded. - pass + except (OSError, RuntimeError, ValueError, ServingError, redis.RedisError, + SQLAlchemyError) as error: + # Database/Redis failures must not silently terminate reconciliation. Exception + # messages can contain SQL parameters or credentials, so retain only their type. + logger.error( + "serving_reconciliation_failed", + error_type=type(error).__name__, + correlation_id=str(uuid.uuid4()), + ) try: await asyncio.wait_for( self._stop.wait(), self.settings.serving_reconciliation_interval_seconds diff --git a/backend/src/modelforge_api/services/startup_validation.py b/backend/src/modelforge_api/services/startup_validation.py index 78d365f..7a63381 100644 --- a/backend/src/modelforge_api/services/startup_validation.py +++ b/backend/src/modelforge_api/services/startup_validation.py @@ -14,6 +14,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass, field +from datetime import UTC, datetime from enum import StrEnum from pathlib import Path from typing import Any @@ -21,6 +22,7 @@ from urllib.parse import unquote, urlparse from sqlalchemy import Engine, text +from modelforge_api.domain.operator_identity import parse_operator_identities from modelforge_api.domain.release import ( MINIMUM_POSTGRES_MAJOR, Compatibility, @@ -323,15 +325,45 @@ def validate_settings(settings: Settings) -> ValidationReport: _validate_url(report, "MODELFORGE_DATABASE_URL", settings.database_url, {"postgresql"}) _validate_url(report, "MODELFORGE_REDIS_URL", settings.redis_url, {"redis", "rediss"}) - if settings.operator_api_key is None: - if production: + if settings.operator_password_hash is not None: + if (settings.operator_api_key is None + or len(settings.operator_api_key.get_secret_value()) < MINIMUM_OPERATOR_KEY_LENGTH): report.fail( StartupFailureCode.MISSING_REQUIRED_SETTING, "MODELFORGE_OPERATOR_API_KEY", - "production requires an operator API key; every admin route depends on it", + "password login requires a separate bootstrap key of at least 32 characters for session signing", + ) + from modelforge_api.services.operator_password import parse_password_hash + + try: + parse_password_hash(settings.operator_password_hash.get_secret_value()) + except ValueError: + report.fail( + StartupFailureCode.INSECURE_PRODUCTION_SETTING, + "MODELFORGE_OPERATOR_PASSWORD_HASH", + "must be a canonical salted scrypt password verifier from operator_password.py", + ) + + if settings.operator_api_key is None or not settings.operator_api_key.get_secret_value(): + document = settings.operator_identities + named_admin = document is not None and bool(document.get_secret_value()) and any( + identity.role == "admin" and identity.expires_at > datetime.now(UTC) + for identity in parse_operator_identities(document.get_secret_value()) + ) + if production and not named_admin: + report.fail( + StartupFailureCode.MISSING_REQUIRED_SETTING, + "MODELFORGE_OPERATOR_API_KEY", + "production requires an operator API key or an unexpired named administrator", ) else: secret = settings.operator_api_key.get_secret_value() + if secret.startswith(("mfop_", "mfopm_", "mfsvc_", "mfnode_", "mfenroll_")): + report.fail( + StartupFailureCode.INSECURE_PRODUCTION_SETTING, + "MODELFORGE_OPERATOR_API_KEY", + "reserved personal/machine key namespaces cannot be used as bootstrap credentials", + ) if production and len(secret) < MINIMUM_OPERATOR_KEY_LENGTH: report.fail( StartupFailureCode.INSECURE_PRODUCTION_SETTING, diff --git a/backend/src/modelforge_api/settings.py b/backend/src/modelforge_api/settings.py index 8119457..d707e1c 100644 --- a/backend/src/modelforge_api/settings.py +++ b/backend/src/modelforge_api/settings.py @@ -1,9 +1,55 @@ +from __future__ import annotations + +import ipaddress +import re +from collections.abc import Mapping +from contextvars import ContextVar from functools import lru_cache from pathlib import Path -from typing import Literal +from typing import Any, Literal +from urllib.parse import urlsplit -from pydantic import Field, SecretStr -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field, PrivateAttr, SecretStr, field_validator, model_validator +from pydantic.fields import FieldInfo +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict + +from modelforge_api.domain.operator_identity import parse_operator_identities + +_active_settings_source_capture: ContextVar[dict[str, str] | None] = ContextVar( + "modelforge_settings_source_capture", default=None +) + + +class _CapturedSettingsSource(PydanticBaseSettingsSource): + """Delegate a Pydantic source unchanged while capturing only its field names.""" + + def __init__(self, source: PydanticBaseSettingsSource, origin: str) -> None: + super().__init__(source.settings_cls, _init_state=source._init_state) + self._source = source + self._origin = origin + + @property + def __name__(self) -> str: + return type(self._source).__name__ + + def get_field_value( + self, field: FieldInfo, field_name: str + ) -> tuple[Any, str, bool]: + raise NotImplementedError("captured sources delegate their complete call") + + def __call__(self) -> dict[str, Any]: + self._source._set_current_state(self.current_state) + self._source._set_settings_sources_data(self.settings_sources_data) + values = self._source() + + # Capture names and source labels only. Never retain source values, which may include + # SecretStr credentials or URLs containing userinfo. + captured = _active_settings_source_capture.get() + if captured is not None: + for field_name in values: + if field_name in self.settings_cls.model_fields: + captured.setdefault(field_name, self._origin) + return values def default_config_root() -> Path: @@ -13,12 +59,75 @@ def default_config_root() -> Path: class Settings(BaseSettings): + _setting_sources: dict[str, str] = PrivateAttr(default_factory=dict) + model_config = SettingsConfigDict( env_prefix="MODELFORGE_", env_file=".env", extra="ignore", + hide_input_in_errors=True, ) + def __init__(self, *args: Any, **values: Any) -> None: + captured: dict[str, str] = {} + token = _active_settings_source_capture.set(captured) + try: + super().__init__(*args, **values) + object.__setattr__(self, "_setting_sources", captured) + finally: + _active_settings_source_capture.reset(token) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Track source provenance without changing Pydantic's built-in source priority.""" + + return ( + _CapturedSettingsSource(init_settings, "initialization"), + _CapturedSettingsSource(env_settings, "process_environment"), + _CapturedSettingsSource(dotenv_settings, "dotenv"), + _CapturedSettingsSource(file_secret_settings, "secrets_file"), + ) + + def setting_source(self, field_name: str) -> str: + """Return the source that supplied a resolved field, or its Pydantic default.""" + + return self._setting_sources.get(field_name, "pydantic_default") + + def _inherit_setting_sources(self, source_settings: Settings) -> None: + """Retain resolved origins after validated reconstruction from this settings object.""" + + object.__setattr__(self, "_setting_sources", dict(source_settings._setting_sources)) + + def __setattr__(self, name: str, value: Any) -> None: + super().__setattr__(name, value) + if ( + name in type(self).model_fields + and _active_settings_source_capture.get() is None + and getattr(self, "__pydantic_private__", None) is not None + ): + sources = dict(self._setting_sources) + sources[name] = "runtime_update" + object.__setattr__(self, "_setting_sources", sources) + + def model_copy( + self, *, update: Mapping[str, Any] | None = None, deep: bool = False + ) -> Settings: + copied = super().model_copy(update=update, deep=deep) + if update: + sources = dict(self._setting_sources) + for field_name in update: + if field_name in type(self).model_fields: + sources[field_name] = "runtime_update" + object.__setattr__(copied, "_setting_sources", sources) + return copied + env: Literal["development", "test", "production"] = "development" api_host: str = "0.0.0.0" api_port: int = 8000 @@ -29,6 +138,8 @@ class Settings(BaseSettings): log_level: str = "INFO" hf_home: str = "/data/hf-cache" hf_token: SecretStr | None = None + secret_slot_root_key: SecretStr | None = None + secret_slot_root_key_id: str = Field(default="hf-control-plane-1", pattern=r"^[a-zA-Z0-9_-]{1,64}$") hf_timeout_seconds: float = Field(default=30, gt=0, le=300) hf_snapshot_ttl_seconds: int = Field(default=3600, ge=60, le=86400) artifact_root: str = "/data/artifacts" @@ -42,6 +153,25 @@ class Settings(BaseSettings): node_identity_mode: Literal["auto", "persisted"] = "auto" node_identity_file: Path = Path("/data/state/node-id") operator_api_key: SecretStr | None = None + operator_identities: SecretStr | None = None + + @field_validator("operator_identities") + @classmethod + def validate_operator_identities(cls, value: SecretStr | None) -> SecretStr | None: + if value is not None and value.get_secret_value(): + parse_operator_identities(value.get_secret_value()) + return value + + @field_validator("alert_webhook_url", mode="before") + @classmethod + def unset_empty_alert_webhook_url(cls, value: object) -> object: + if value == "" or ( + isinstance(value, SecretStr) and value.get_secret_value() == "" + ): + return None + return value + + operator_password_hash: SecretStr | None = None node_stale_after_seconds: int = Field(default=30, ge=10) node_offline_after_seconds: int = Field(default=90, ge=20) liveness_poll_interval_seconds: int = Field(default=5, ge=1) @@ -84,6 +214,17 @@ class Settings(BaseSettings): migration_reconciliation_enabled: bool = False observability_monitor_enabled: bool = False observability_poll_interval_seconds: int = Field(default=60, ge=30, le=3600) + alert_notifications_enabled: bool = False + alert_webhook_url: SecretStr | None = None + alert_webhook_allowed_hosts: str = "" + alert_webhook_min_severity: Literal["INFO", "WARNING", "CRITICAL"] = "WARNING" + alert_notification_timeout_seconds: float = Field(default=5.0, gt=0, le=30) + alert_notification_max_attempts: int = Field(default=8, ge=1, le=20) + alert_notification_batch_size: int = Field(default=20, ge=1, le=100) + alert_notification_lease_seconds: int = Field(default=60, ge=15, le=600) + alert_notification_retry_base_seconds: int = Field(default=15, ge=1, le=3600) + model_update_monitor_enabled: bool = True + model_update_check_interval_seconds: int = Field(default=86400, ge=3600, le=604800) backup_root: Path = Path("/data/backups") backup_restore_root: Path = Path("/data/restore") backup_encryption_key: SecretStr | None = None @@ -99,6 +240,51 @@ class Settings(BaseSettings): recovery_reconciliation_enabled: bool = False restore_allow_production_target: bool = False + @field_validator("alert_webhook_allowed_hosts") + @classmethod + def normalize_alert_webhook_allowed_hosts(cls, value: str) -> str: + hosts = [item.strip().casefold().rstrip(".") for item in value.split(",") if item.strip()] + if len(hosts) > 20 or len(hosts) != len(set(hosts)): + raise ValueError("alert webhook allowlist must contain up to 20 unique exact hosts") + for host in hosts: + try: + ipaddress.ip_address(host) + continue + except ValueError: + pass + if len(host) > 253 or not re.fullmatch( + r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", + host, + ): + raise ValueError("alert webhook allowlist requires exact DNS names or IP addresses") + return ",".join(hosts) + + @model_validator(mode="after") + def validate_alert_webhook_settings(self) -> Settings: + if not self.alert_notifications_enabled: + return self + if self.alert_webhook_url is None or not self.alert_webhook_allowed_hosts: + raise ValueError("enabled alert notifications require a webhook URL and exact host allowlist") + raw = self.alert_webhook_url.get_secret_value() + try: + parsed = urlsplit(raw) + host = (parsed.hostname or "").casefold().rstrip(".") + port = parsed.port + except ValueError: + raise ValueError("alert webhook URL must be a valid HTTPS URL on an allowed host") from None + allowed = set(self.alert_webhook_allowed_hosts.split(",")) + if ( + parsed.scheme.casefold() != "https" + or not host + or host not in allowed + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or port not in (None, 443) + ): + raise ValueError("alert webhook URL must be HTTPS on an exact allowlisted host") + return self + # Build identity. These are stamped into the image at build time and are never inferred at # runtime: a build identity a running process can talk itself into is worth nothing during an # incident. Absent values are reported as null rather than guessed. @@ -106,6 +292,13 @@ class Settings(BaseSettings): build_timestamp: str | None = None build_image_digest: str | None = None + @field_validator("operator_password_hash", mode="before") + @classmethod + def unset_empty_password_hash(cls, value: object) -> object: + if value == "" or (isinstance(value, SecretStr) and value.get_secret_value() == ""): + return None + return value + @property def cors_origin_list(self) -> list[str]: return [item.strip() for item in self.cors_origins.split(",") if item.strip()] diff --git a/backend/tests/http_contract_fakes.py b/backend/tests/http_contract_fakes.py new file mode 100644 index 0000000..a74177b --- /dev/null +++ b/backend/tests/http_contract_fakes.py @@ -0,0 +1,32 @@ +"""Opt-in HTTP fixture: real middleware/routes, no ambient startup or database.""" +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager + +import pytest +from fastapi import FastAPI + +from modelforge_api.db import engine as ambient_engine +from modelforge_api.main import app + + +@pytest.fixture +def isolated_http_contract(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + # These exercise the real HTTP stack with fake collectors, not application startup. + # Do not run reconciliation, schema validation or background polling against .env. + @asynccontextmanager + async def contract_lifespan(_app: FastAPI) -> AsyncIterator[None]: + yield + + ambient_connections: list[str] = [] + + def reject_ambient_connection(*args: object, **kwargs: object) -> None: + ambient_connections.append("connection attempted") + raise AssertionError("HTTP contract tests must not use the ambient database") + + monkeypatch.setattr(ambient_engine, "connect", reject_ambient_connection) + monkeypatch.setattr(ambient_engine, "raw_connection", reject_ambient_connection) + monkeypatch.setattr(app.router, "lifespan_context", contract_lifespan) + # Restore the original mapping (including other tests' overrides) on teardown. + monkeypatch.setattr(app, "dependency_overrides", {}) + yield + assert not ambient_connections diff --git a/backend/tests/recovery_fixtures.py b/backend/tests/recovery_fixtures.py new file mode 100644 index 0000000..d73d60b --- /dev/null +++ b/backend/tests/recovery_fixtures.py @@ -0,0 +1,35 @@ +"""Physical legacy schema helpers for recovery fingerprint tests.""" + +from sqlalchemy.engine import Connection + + +def recreate_0038_benchmark_runs(connection: Connection) -> None: + """Replace metadata's current table with the physical pre-0039 benchmark shape.""" + connection.exec_driver_sql("DROP TABLE benchmark_runs") + connection.exec_driver_sql( + """ + CREATE TABLE benchmark_runs ( + id CHAR(32) NOT NULL, + deployment_id CHAR(32) NOT NULL, + suite_id CHAR(32) NOT NULL, + model_revision_sha VARCHAR(64) NOT NULL, + artifact_sha256 VARCHAR(64) NOT NULL, + runtime_type VARCHAR(32) NOT NULL, + runtime_version VARCHAR(128) NOT NULL, + runtime_image_digest VARCHAR(71), + launch_arguments JSON NOT NULL, + suite_revision VARCHAR(128) NOT NULL, + dataset_revision VARCHAR(255) NOT NULL, + environment_fingerprint JSON NOT NULL, + environment_digest VARCHAR(64) NOT NULL, + seed INTEGER, + results JSON NOT NULL, + status VARCHAR(32) NOT NULL, + comparable BOOLEAN NOT NULL, + stale_reason TEXT, + started_at DATETIME NOT NULL, + completed_at DATETIME, + PRIMARY KEY (id) + ) + """ + ) diff --git a/backend/tests/test_alert_notifications.py b/backend/tests/test_alert_notifications.py new file mode 100644 index 0000000..390524d --- /dev/null +++ b/backend/tests/test_alert_notifications.py @@ -0,0 +1,764 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import threading +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations +from fastapi.testclient import TestClient +from pydantic import SecretStr, ValidationError +from sqlalchemy import create_engine, func, inspect, select, text +from sqlalchemy import event as sqlalchemy_event +from sqlalchemy.engine import make_url +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.db import get_session +from modelforge_api.domain.alert_notifications import NotificationStatus +from modelforge_api.domain.observability import AlertState +from modelforge_api.main import app +from modelforge_api.persistence.models import ( + AlertHistoryEvent, + AlertNotificationDelivery, + AlertRuleRevision, + Base, + OperationalAlert, +) +from modelforge_api.services.alert_notifications import ( + _claim, + dispatch_pending, + enqueue_alert_transition, +) +from modelforge_api.services.observability import ObservabilityService +from modelforge_api.services.recovery_fingerprint import ( + ALERT_NOTIFICATION_SCHEMA, + PORTFOLIO_SCHEMA, + FingerprintCompatibility, + _without_alert_notifications, + _without_lab_execution, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + fingerprint_session, +) +from modelforge_api.settings import Settings, get_settings + +ADMIN_TOKEN = "alert-notification-test-admin" # noqa: S105 - synthetic test credential +HOOK_URL = "https://hooks.example.test/secret-route-value" + + +@pytest.fixture +def session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + engine.dispose() + + +def settings(**overrides: object) -> Settings: + values: dict[str, object] = { + "_env_file": None, + "env": "test", + "alert_notifications_enabled": True, + "alert_webhook_url": SecretStr(HOOK_URL), + "alert_webhook_allowed_hosts": "hooks.example.test", + "alert_notification_retry_base_seconds": 1, + "operator_api_key": SecretStr(ADMIN_TOKEN), + } + values.update(overrides) + return Settings(**values) # type: ignore[arg-type] + + +def make_alert_event( + session: Session, *, severity: str = "CRITICAL", state: str = "FIRING" +) -> tuple[OperationalAlert, AlertHistoryEvent]: + rule = AlertRuleRevision( + key=f"notification-test-{uuid.uuid4()}", + revision=1, + alert_type="BACKUP_STALE", + signal="backup_stale", + slo_policy_id=None, + condition={}, + pending_seconds=0, + severity=severity, + labels={}, + cooldown_seconds=0, + recovery_condition={}, + active=True, + fingerprint=uuid.uuid4().hex, + created_by="notification-test", + ) + session.add(rule) + session.flush() + alert = OperationalAlert( + rule_id=rule.id, + fingerprint=hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest(), + alert_type="BACKUP_STALE", + severity=severity, + state=state, + source="backup_sets", + subject_type="backup", + subject_ref="latest", + summary="Latest verified backup is stale", + details={"secret": "must-not-be-in-webhook"}, + first_seen_at=datetime.now(UTC), + last_seen_at=datetime.now(UTC), + ) + session.add(alert) + session.flush() + event = AlertHistoryEvent( + alert_id=alert.id, + from_state=None, + to_state=state, + actor="observability-engine", + reason="Signal condition detected", + evidence={"secret": "must-not-be-in-webhook"}, + occurred_at=datetime.now(UTC), + ) + session.add(event) + session.flush() + return alert, event + + +def test_settings_require_https_exact_host_allowlist_and_standard_port() -> None: + with pytest.raises(ValidationError): + Settings( + _env_file=None, + alert_notifications_enabled=True, + alert_webhook_url=SecretStr("http://hooks.example.test/private"), + alert_webhook_allowed_hosts="hooks.example.test", + ) + with pytest.raises(ValidationError): + Settings( + _env_file=None, + alert_notifications_enabled=True, + alert_webhook_url=SecretStr("https://evil.example.test/private"), + alert_webhook_allowed_hosts="hooks.example.test", + ) + with pytest.raises(ValidationError): + Settings( + _env_file=None, + alert_notifications_enabled=True, + alert_webhook_url=SecretStr("https://hooks.example.test:8443/private"), + alert_webhook_allowed_hosts="hooks.example.test", + ) + with pytest.raises(ValidationError): + Settings( + _env_file=None, + alert_notifications_enabled=True, + alert_webhook_url=SecretStr("https://hooks.example.test/private"), + alert_webhook_allowed_hosts="*.example.test", + ) + + +def test_enqueue_is_transactional_filtered_and_unique_per_history_event(session: Session) -> None: + config = settings(alert_webhook_min_severity="WARNING") + alert, event = make_alert_event(session) + enqueue_alert_transition(session, config, alert, event) + session.flush() + row = session.scalar(select(AlertNotificationDelivery)) + assert row is not None + assert row.idempotency_key == f"alert:{event.id}:default" + assert row.event_kind == "FIRING" + assert row.payload["summary"] == alert.summary + assert "details" not in row.payload + assert "must-not-be-in-webhook" not in json.dumps(row.payload) + + enqueue_alert_transition(session, config, alert, event) + with pytest.raises(IntegrityError): + session.flush() + session.rollback() + assert session.scalar(select(func.count()).select_from(AlertNotificationDelivery)) == 0 + + +def test_enqueue_only_notifies_firing_or_resolved_at_configured_severity(session: Session) -> None: + config = settings(alert_webhook_min_severity="WARNING") + alert, event = make_alert_event(session, severity="INFO", state="PENDING") + enqueue_alert_transition(session, config, alert, event) + assert session.scalar(select(func.count()).select_from(AlertNotificationDelivery)) == 0 + + event.to_state = "RESOLVED" + alert.severity = "CRITICAL" + enqueue_alert_transition(session, config, alert, event) + assert session.scalar(select(AlertNotificationDelivery)).event_kind == "RESOLVED" # type: ignore[union-attr] + + +def test_firing_transition_and_outbox_commit_in_one_transaction(session: Session) -> None: + config = settings() + alert, _initial_event = make_alert_event(session, state="PENDING") + now = datetime.now(UTC) + ObservabilityService(session, config)._transition( + alert, AlertState.FIRING, "observability-engine", "pending duration satisfied", now, {} + ) + session.commit() + delivery = session.scalar(select(AlertNotificationDelivery)) + assert delivery is not None + assert delivery.status == NotificationStatus.PENDING.value + assert delivery.event_kind == "FIRING" + assert delivery.alert_history_event_id is not None + + session.delete(delivery) + session.commit() + second, _event = make_alert_event(session, state="PENDING") + ObservabilityService(session, config)._transition( + second, AlertState.FIRING, "observability-engine", "pending duration satisfied", now, {} + ) + session.flush() + session.rollback() + assert session.scalar(select(func.count()).select_from(AlertNotificationDelivery)) == 0 + + +def test_dispatch_retries_then_delivers_without_following_redirects_or_exposing_body( + session: Session, +) -> None: + config = settings() + alert, event = make_alert_event(session) + enqueue_alert_transition(session, config, alert, event) + session.commit() + requests: list[httpx.Request] = [] + + def retry(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(503, text="remote response contains sensitive diagnostic") + + assert dispatch_pending(session, config, transport=httpx.MockTransport(retry)) == 1 + row = session.scalar(select(AlertNotificationDelivery)) + assert row is not None + assert row.status == NotificationStatus.RETRY_WAIT.value + assert row.attempt_count == 1 + assert row.last_error_code == "REMOTE_RETRYABLE" + assert row.last_http_status == 503 + assert HOOK_URL not in repr(row) + assert row.last_error_code not in "remote response contains sensitive diagnostic" + assert requests[0].url == httpx.URL(HOOK_URL) + assert requests[0].headers["idempotency-key"] == row.idempotency_key + assert requests[0].extensions.get("timeout") is not None + + row.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + assert dispatch_pending( + session, + config, + transport=httpx.MockTransport(lambda request: httpx.Response(200, text="ignored")), + ) == 1 + session.refresh(row) + assert row.status == NotificationStatus.DELIVERED.value + assert row.delivered_at is not None + assert row.last_error_code is None + + +def test_dispatch_rejects_redirect_and_permanent_client_errors(session: Session) -> None: + config = settings() + alert, event = make_alert_event(session) + enqueue_alert_transition(session, config, alert, event) + session.commit() + requests: list[httpx.Request] = [] + + def redirect(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(302, headers={"Location": "https://evil.example.test/"}) + + assert dispatch_pending(session, config, transport=httpx.MockTransport(redirect)) == 1 + row = session.scalar(select(AlertNotificationDelivery)) + assert row is not None + assert row.status == NotificationStatus.FAILED.value + assert row.last_error_code == "REDIRECT_REJECTED" + assert len(requests) == 1 + + +def test_dispatch_permanent_failure_after_attempt_limit(session: Session) -> None: + config = settings(alert_notification_max_attempts=1) + alert, event = make_alert_event(session) + enqueue_alert_transition(session, config, alert, event) + session.commit() + assert dispatch_pending( + session, + config, + transport=httpx.MockTransport(lambda request: httpx.Response(400, text="private body")), + ) == 1 + row = session.scalar(select(AlertNotificationDelivery)) + assert row is not None + assert row.status == NotificationStatus.FAILED.value + assert row.last_error_code == "REMOTE_REJECTED" + assert row.last_http_status == 400 + + +def test_schema_0037_recovery_holds_pending_and_rejects_unrelated_delivery_changes( + session: Session, +) -> None: + config = settings() + alert, event = make_alert_event(session) + enqueue_alert_transition(session, config, alert, event) + pending = session.scalar(select(AlertNotificationDelivery)) + assert pending is not None + pending.next_attempt_at = datetime.now(UTC) + timedelta(minutes=1) + pending.lease_expires_at = datetime.now(UTC) + timedelta(minutes=1) + pending.claim_token_hash = "a" * 64 + delivered = AlertNotificationDelivery( + alert_history_event_id=None, + route_key="default", + idempotency_key="test:already-delivered:default", + event_kind="TEST", + severity=None, + payload={"event": "TEST"}, + status="DELIVERED", + attempt_count=1, + last_attempt_at=datetime.now(UTC), + delivered_at=datetime.now(UTC), + ) + session.add(delivered) + session.commit() + source = _without_lab_execution(fingerprint_session(session)) + + pending.status = NotificationStatus.HELD_FOR_REVIEW.value + pending.next_attempt_at = None + pending.lease_expires_at = None + pending.claim_token_hash = None + session.commit() + restored = fingerprint_session(session) + contract = fingerprint_compatibility( + source, restored, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + assert contract.compatible + assert contract.mode.startswith( + "BENCHMARK_RUNS_UPGRADE|20260923_0037|LAB_EXECUTION_UPGRADE|20260923_0037|" + "ALERT_NOTIFICATION_STRICT|" + ) + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + + delivered.status = NotificationStatus.HELD_FOR_REVIEW.value + session.commit() + corrupted = fingerprint_session(session) + rejected = fingerprint_compatibility( + source, corrupted, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + assert not rejected.compatible + + +def test_restore_reconciliation_holds_pending_deliveries_and_requires_review( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + from modelforge_api.services.recovery import RecoveryService + + statements: list[str] = [] + + class RecoveryEngine: + def scalar(self, _target: object, sql: str) -> str: + statements.append(sql) + if "from alert_notification_deliveries" in sql and "select count(*)" in sql: + return "2" + if "from audit_events where action" in sql: + return "0" + if "string_agg" in sql: + return "" + return "0" + + service = RecoveryService(session, Settings(_env_file=None, env="test")) + service.engine = RecoveryEngine() # type: ignore[assignment] + monkeypatch.setattr(service, "_verify_restored_audit_chain", lambda _target: {}) + monkeypatch.setattr(service, "_append_restored_audit_marker", lambda *_args, **_kwargs: "a" * 64) + monkeypatch.setattr(service, "_reconcile_selected_comparisons", lambda _target: {"interrupted": 0, "claims_cleared": 0}) + monkeypatch.setattr(service, "_reconcile_creative_allocations", lambda _target: 0) + monkeypatch.setattr(service.audit, "write", lambda *_args, **_kwargs: None) + record = SimpleNamespace(id=uuid.uuid4()) + plan = SimpleNamespace( + database_destination="postgresql+psycopg://modelforge@restore.example.test/restored", + backup_set=SimpleNamespace(backup_id="notification-recovery"), + ) + + result = service._reconcile_restored(record, plan) # type: ignore[arg-type] + + assert any( + "update alert_notification_deliveries set status='HELD_FOR_REVIEW'" in sql + and "next_attempt_at=null" in sql + and "lease_expires_at=null" in sql + and "claim_token_hash=null" in sql + and "where status in ('PENDING','DISPATCHING','RETRY_WAIT')" in sql + for sql in statements + ) + assert result["notification_deliveries_held_for_review"] == 2 + assert result["outcome"] == "RECOVERY_RECONCILIATION_REQUIRED" + + +def test_schema_0037_upgrade_allows_only_empty_outbox_and_exact_marker() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as session: + restored = fingerprint_session(session) + source = _without_alert_notifications(_without_lab_execution(restored)) + contract = fingerprint_compatibility( + source, restored, source_schema_revision=PORTFOLIO_SCHEMA + ) + assert contract.compatible + assert contract.mode.startswith( + "BENCHMARK_RUNS_UPGRADE|20260923_0036|LAB_EXECUTION_UPGRADE|20260923_0036|" + ) + assert "ALERT_NOTIFICATION_UPGRADE|PORTFOLIO_STRICT|" in contract.mode + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + + populated = json.loads(json.dumps(restored)) + populated["tables"]["alert_notification_deliveries"]["row_count"] = 1 + populated["tables"]["alert_notification_deliveries"]["digest"] = "f" * 64 + rejected = fingerprint_compatibility( + source, populated, source_schema_revision=PORTFOLIO_SCHEMA + ) + assert not rejected.compatible + + unrelated = FingerprintCompatibility( + True, + "UNRELATED|" + contract.mode, + contract.allow_audit_checkpoint_introduction, + contract.reason, + ) + assert not audit_fingerprint_differences_are_compatible(source, restored, unrelated) + engine.dispose() + + +def test_pre_0037_fingerprint_rejects_a_future_outbox_physical_table() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as session: + current = fingerprint_session(session) + source = _without_lab_execution(_without_alert_notifications(current)) + source["physical_tables"] = sorted( + [*source["physical_tables"], "alert_notification_deliveries"] + ) + rejected = fingerprint_compatibility( + source, source, source_schema_revision=PORTFOLIO_SCHEMA + ) + assert not rejected.compatible + assert "table contract" in rejected.reason + engine.dispose() + + +def test_schema_0037_migration_creates_indexes_and_refuses_history_downgrade() -> None: + migration_path = ( + Path(__file__).resolve().parents[1] + / "alembic/versions/20260923_0037_alert_notification_outbox.py" + ) + spec = importlib.util.spec_from_file_location("alert_notification_migration", migration_path) + assert spec is not None and spec.loader is not None + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + + engine = create_engine("sqlite+pysqlite:///:memory:") + with engine.begin() as connection, Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + assert migration.down_revision == "20260923_0036" + assert "ix_alert_notification_dispatch_due" in { + item["name"] for item in inspect(connection).get_indexes("alert_notification_deliveries") + } + migration.downgrade() + migration.upgrade() + connection.execute( + text( + "insert into alert_notification_deliveries " + "(id, route_key, idempotency_key, event_kind, payload, status, attempt_count, created_at) " + "values (:id, 'default', 'test:migration:default', 'TEST', '{}', 'PENDING', 0, :created_at)" + ), + {"id": str(uuid.uuid4()), "created_at": datetime.now(UTC)}, + ) + with pytest.raises(RuntimeError, match="history exists"): + migration.downgrade() + engine.dispose() + + +def test_admin_test_action_queues_safe_delivery_and_viewer_is_denied(session: Session) -> None: + config = settings() + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_settings] = lambda: config + client = TestClient(app) + try: + assert client.post("/api/v1/admin/operations/notifications/test").status_code == 401 + response = client.post( + "/api/v1/admin/operations/notifications/test", + headers={"X-ModelForge-Admin-Token": ADMIN_TOKEN}, + ) + assert response.status_code == 202 + assert HOOK_URL not in response.text + assert "secret-route-value" not in response.text + delivery_id = response.json()["delivery"]["id"] + listed = client.get( + "/api/v1/admin/operations/notification-deliveries", + headers={"X-ModelForge-Admin-Token": ADMIN_TOKEN}, + ) + assert listed.status_code == 200 + assert listed.json()[0]["id"] == delivery_id + assert "payload" not in listed.json()[0] + + app.dependency_overrides[get_settings] = lambda: settings( + alert_notifications_enabled=False + ) + unavailable = client.post( + "/api/v1/admin/operations/notifications/test", + headers={"X-ModelForge-Admin-Token": ADMIN_TOKEN}, + ) + assert unavailable.status_code == 503 + assert unavailable.json()["error"]["code"] == "NOTIFICATION_ROUTE_UNAVAILABLE" + assert unavailable.json()["error"]["message"] == ( + "The deployment-managed alert notification route is unavailable" + ) + assert HOOK_URL not in unavailable.text + + viewer_token = "mfop_" + "v" * 43 + viewer_settings = settings( + operator_api_key=None, + operator_identities=SecretStr( + json.dumps( + [ + { + "credential_id": "viewer-test", + "subject": "alert-viewer", + "role": "viewer", + "token_sha256": hashlib.sha256(viewer_token.encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + } + ] + ) + ), + ) + app.dependency_overrides[get_settings] = lambda: viewer_settings + denied = client.post( + "/api/v1/admin/operations/notifications/test", + headers={"X-ModelForge-Admin-Token": viewer_token}, + ) + assert denied.status_code == 403 + finally: + app.dependency_overrides.clear() + client.close() + + +def test_postgres_concurrent_claim_skips_the_row_held_by_another_worker() -> None: + admin_url = os.getenv("MODELFORGE_TEST_POSTGRES_URL") + if not admin_url: + pytest.skip("requires managed MODELFORGE_TEST_POSTGRES_URL") + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + if not owner_url or not runtime_url: + pytest.fail("managed notification claim test requires owner and runtime URLs") + if admin_url.startswith("postgresql://"): + admin_url = admin_url.replace("postgresql://", "postgresql+psycopg://", 1) + + database = f"test_alert_claim_{uuid.uuid4().hex}" + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + with admin_engine.connect() as connection: + connection.execute(text(f'CREATE DATABASE "{database}" OWNER modelforge')) + + owner_database_url = make_url(owner_url).set(database=database) + runtime_engine = create_engine(make_url(runtime_url).set(database=database), pool_pre_ping=True) + migration_config = Config() + migration_config.set_main_option( + "script_location", str(Path(__file__).resolve().parents[1] / "alembic") + ) + migration_config.set_main_option( + "sqlalchemy.url", + owner_database_url.render_as_string(hide_password=False).replace("%", "%%"), + ) + first_selected = threading.Event() + release_first = threading.Event() + results: dict[str, int] = {} + failures: list[SQLAlchemyError | TimeoutError] = [] + + @sqlalchemy_event.listens_for(runtime_engine, "after_cursor_execute") + def pause_first_claim( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + normalized = statement.lower() + if ( + threading.current_thread().name == "notification-claim-a" + and "from alert_notification_deliveries" in normalized + and "for update" in normalized + ): + first_selected.set() + if not release_first.wait(timeout=10): + raise TimeoutError("first notification claim did not receive release signal") + + try: + import importlib + + importlib.import_module("alembic.command").upgrade(migration_config, "head") + with Session(runtime_engine) as session: + session.add( + AlertNotificationDelivery( + idempotency_key=f"test:{uuid.uuid4()}:default", + route_key="default", + event_kind="TEST", + payload={"schema_version": 1, "event": "TEST"}, + status="PENDING", + attempt_count=0, + ) + ) + session.commit() + + config = settings(alert_notification_batch_size=1) + now = datetime.now(UTC) + + def claim(name: str) -> None: + try: + with Session(runtime_engine) as session: + results[name] = len(_claim(session, config, now)) + except (SQLAlchemyError, TimeoutError) as error: # retained until both workers unblock + failures.append(error) + + first = threading.Thread(target=claim, args=("first",), name="notification-claim-a") + second = threading.Thread(target=claim, args=("second",), name="notification-claim-b") + first.start() + assert first_selected.wait(timeout=10), "first worker did not lock the due row" + second.start() + second.join(timeout=10) + release_first.set() + first.join(timeout=10) + assert not first.is_alive() and not second.is_alive() + assert failures == [] + assert results == {"first": 1, "second": 0} + finally: + release_first.set() + runtime_engine.dispose() + with admin_engine.connect() as connection: + connection.execute(text(f'DROP DATABASE IF EXISTS "{database}" WITH (FORCE)')) + admin_engine.dispose() + + +def test_postgres_expired_lease_fences_a_stale_dispatch_result() -> None: + admin_url = os.getenv("MODELFORGE_TEST_POSTGRES_URL") + if not admin_url: + pytest.skip("requires managed MODELFORGE_TEST_POSTGRES_URL") + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + if not owner_url or not runtime_url: + pytest.fail("managed notification lease test requires owner and runtime URLs") + if admin_url.startswith("postgresql://"): + admin_url = admin_url.replace("postgresql://", "postgresql+psycopg://", 1) + + database = f"test_alert_lease_{uuid.uuid4().hex}" + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + with admin_engine.connect() as connection: + connection.execute(text(f'CREATE DATABASE "{database}" OWNER modelforge')) + + owner_database_url = make_url(owner_url).set(database=database) + runtime_engine = create_engine(make_url(runtime_url).set(database=database), pool_pre_ping=True) + migration_config = Config() + migration_config.set_main_option( + "script_location", str(Path(__file__).resolve().parents[1] / "alembic") + ) + migration_config.set_main_option( + "sqlalchemy.url", + owner_database_url.render_as_string(hide_password=False).replace("%", "%%"), + ) + first_delivery_started = threading.Event() + release_first_delivery = threading.Event() + results: dict[str, int] = {} + failures: list[Exception] = [] + workers: list[threading.Thread] = [] + delivery_id = uuid.uuid4() + + class DelayedFailureTransport(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + first_delivery_started.set() + if not release_first_delivery.wait(timeout=10): + raise AssertionError("first delivery did not receive release signal") + return httpx.Response(503, request=request) + + class SuccessfulTransport(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response(200, request=request) + + def dispatch( + name: str, + claim_time: datetime, + transport: httpx.BaseTransport, + ) -> None: + try: + with Session(runtime_engine) as session: + results[name] = dispatch_pending( + session, config, now=claim_time, transport=transport + ) + except (AssertionError, RuntimeError, SQLAlchemyError, TimeoutError, ValueError) as error: + failures.append(error) + + try: + import importlib + + importlib.import_module("alembic.command").upgrade(migration_config, "head") + with Session(runtime_engine) as session: + session.add( + AlertNotificationDelivery( + id=delivery_id, + idempotency_key=f"test:{uuid.uuid4()}:default", + route_key="default", + event_kind="TEST", + payload={"schema_version": 1, "event": "TEST"}, + status="PENDING", + attempt_count=0, + ) + ) + session.commit() + + config = settings(alert_notification_batch_size=1, alert_notification_lease_seconds=15) + claim_time = datetime.now(UTC) + first = threading.Thread( + target=dispatch, + args=("first", claim_time, DelayedFailureTransport()), + name="notification-dispatch-stale", + ) + second = threading.Thread( + target=dispatch, + args=( + "second", + claim_time + timedelta(seconds=config.alert_notification_lease_seconds + 1), + SuccessfulTransport(), + ), + name="notification-dispatch-reclaimed", + ) + first.start() + workers.append(first) + assert first_delivery_started.wait(timeout=10), "first worker did not begin delivery" + second.start() + workers.append(second) + second.join(timeout=10) + assert not second.is_alive(), "replacement worker did not finish" + assert results.get("second") == 1 + release_first_delivery.set() + first.join(timeout=10) + assert not first.is_alive(), "stale worker did not finish" + assert failures == [] + assert results == {"second": 1, "first": 1} + + with Session(runtime_engine) as session: + delivery = session.get(AlertNotificationDelivery, delivery_id) + assert delivery is not None + assert delivery.status == "DELIVERED" + assert delivery.attempt_count == 2 + assert delivery.last_http_status == 200 + assert delivery.last_error_code is None + assert delivery.delivered_at is not None + assert delivery.lease_expires_at is None + assert delivery.claim_token_hash is None + finally: + release_first_delivery.set() + for worker in workers: + worker.join(timeout=10) + runtime_engine.dispose() + with admin_engine.connect() as connection: + connection.execute(text(f'DROP DATABASE IF EXISTS "{database}" WITH (FORCE)')) + admin_engine.dispose() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4ebffcc..31c435d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,15 +1,19 @@ +from typing import Any + from fastapi.testclient import TestClient from pydantic import SecretStr -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool +from modelforge_api.api.routes.catalog import list_capabilities, list_projects from modelforge_api.api.routes.registry import get_registry_service from modelforge_api.db import get_session -from modelforge_api.domain.release import PRODUCT_VERSION +from modelforge_api.domain.release import PRODUCT_VERSION, RELEASE_CHANNEL from modelforge_api.main import app -from modelforge_api.persistence.models import Base +from modelforge_api.persistence.models import Base, Capability, CapabilityContract from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.project_registry import sync_project_registry from modelforge_api.services.registry import RegistryService, seed_candidate_registry from modelforge_api.settings import Settings, get_settings @@ -44,6 +48,32 @@ def test_readiness_validates_all_manifests() -> None: assert response.json()["checks"] == {"manifests": "healthy"} +def test_public_version_contract_supplies_console_release_identity_fields() -> None: + commit = "e472d444dd007f22db9f2eac0b97e4e97d0a2993" + built_at = "2026-09-24T12:00:00Z" + image_digest = f"sha256:{'f' * 64}" + app.dependency_overrides[get_settings] = lambda: Settings( + _env_file=None, + build_commit=commit, + build_timestamp=built_at, + build_image_digest=image_digest, + ) + try: + response = client.get("/api/v1/version") + assert response.status_code == 200 + payload = response.json() + + assert payload["version"] == "1.3.0-rc.1" + assert payload["release_channel"] == "rc" + assert payload["source_commit"] == commit + assert payload["built_at"] == built_at + assert payload["image_digest"] == image_digest + assert payload["compatibility"]["schema_revision"] == "20260924_0039" + assert payload["compatibility"]["agent_protocol_version"] == 1 + finally: + app.dependency_overrides.clear() + + def test_system_metadata_reports_the_release_without_unpromoted_inference() -> None: """A released product reports its version and channel, not the milestone that built it.""" @@ -62,7 +92,7 @@ def test_system_metadata_reports_the_release_without_unpromoted_inference() -> N headers=CONTROL_PLANE_HEADERS, ).json() assert payload["version"] == PRODUCT_VERSION - assert payload["release_channel"] == "stable" + assert payload["release_channel"] == RELEASE_CHANNEL assert "milestone" not in payload assert payload["production_inference_available"] is False finally: @@ -124,3 +154,88 @@ def test_capability_and_project_contract_endpoints() -> None: assert {item["key"] for item in capabilities} >= {"rag.embedding", "vision.embedding"} assert {item["id"] for item in projects} == {"examplerag", "examplevision", "example-ops"} assert all("model_id" not in binding for project in projects for binding in project["bindings"]) + + +def test_capability_catalog_select_count_does_not_grow_with_contracts() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + manifests = ManifestRegistry() + expected = manifests.capabilities() + assert len(expected) > 1 + selects = 0 + + def count_selects( + _connection: Any, + _cursor: Any, + statement: str, + _parameters: Any, + _context: Any, + _executemany: bool, + ) -> None: + nonlocal selects + if statement.lstrip().upper().startswith("SELECT"): + selects += 1 + + event.listen(engine, "before_cursor_execute", count_selects) + try: + with Session(engine) as session: + result = list_capabilities(registry=manifests, session=session) + finally: + event.remove(engine, "before_cursor_execute", count_selects) + engine.dispose() + + assert [(item.key, item.version) for item in result] == [ + (item.capability, item.version) for item in expected + ] + assert selects == 1 + + +def test_project_catalog_select_count_does_not_grow_with_bindings() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + manifests = ManifestRegistry() + with Session(engine) as session: + for manifest in manifests.capabilities(): + if manifest.capability not in {"rag.embedding", "rag.reranking", "vision.embedding"}: + continue + capability = Capability(key=manifest.capability, description=manifest.description) + session.add(capability) + session.flush() + session.add( + CapabilityContract( + capability_id=capability.id, + version=manifest.version, + input_schema=manifest.input_schema, + output_schema=manifest.output_schema, + contract=manifest.model_dump(mode="json"), + upgrade_class=manifest.upgrade_class.value, + ) + ) + session.commit() + assert sync_project_registry(session, manifests).bindings_created == 3 + + selects = 0 + + def count_selects( + _connection: Any, + _cursor: Any, + statement: str, + _parameters: Any, + _context: Any, + _executemany: bool, + ) -> None: + nonlocal selects + if statement.lstrip().upper().startswith("SELECT"): + selects += 1 + + event.listen(engine, "before_cursor_execute", count_selects) + try: + with Session(engine) as session: + result = list_projects(registry=manifests, session=session) + finally: + event.remove(engine, "before_cursor_execute", count_selects) + engine.dispose() + + assert {item.id for item in result} == {"examplerag", "examplevision", "example-ops"} + assert sum(len(item.bindings) for item in result) == 3 + assert selects == 4 diff --git a/backend/tests/test_audit_integrity_rc.py b/backend/tests/test_audit_integrity_rc.py index 225a91a..532e6ba 100644 --- a/backend/tests/test_audit_integrity_rc.py +++ b/backend/tests/test_audit_integrity_rc.py @@ -593,13 +593,17 @@ def test_exact_aliased_delete_and_head_rewrite_poc_is_refused( "statement", [ "/* leading /* nested */ comment */ DELETE\nFROM [main].[audit_events]", - "-- leading decoy\nUPDATE `main`.`audit_chain_heads` " - "SET event_count=0, last_sequence=0, last_event_hash=NULL", + ( + "-- leading decoy\nUPDATE `main`.`audit_chain_heads` " + "SET event_count=0, last_sequence=0, last_event_hash=NULL" + ), 'INSERT /* gap */ INTO "public"."audit_events" (id) VALUES (NULL)', "WITH doomed AS (SELECT 1) DELETE FROM audit_events", "TRUNCATE TABLE harmless_table, audit_events", - "MERGE INTO public.audit_chain_heads AS head USING incoming ON false " - "WHEN MATCHED THEN DELETE", + ( + "MERGE INTO public.audit_chain_heads AS head USING incoming ON false " + "WHEN MATCHED THEN DELETE" + ), "COPY audit_events FROM STDIN", "DROP TABLE harmless_table, audit_chain_heads", "DO $$ BEGIN DELETE FROM audit_events; END $$", @@ -736,8 +740,10 @@ def test_late_statement_rewrite_is_rolled_back_before_commit(tmp_path: Any) -> N [ r'ALTER TABLE IF EXISTS U&"audit\005fevents" DISABLE TRIGGER ALL', r'''ALTER TABLE IF EXISTS U&"audit!005fchain!005fheads" UESCAPE '!' DISABLE TRIGGER ALL''', - "CREATE OR REPLACE FUNCTION reset_chain() RETURNS void LANGUAGE SQL AS $$ " - "DELETE FROM audit_events $$", + ( + "CREATE OR REPLACE FUNCTION reset_chain() RETURNS void LANGUAGE SQL AS $$ " + "DELETE FROM audit_events $$" + ), "SELECT public.reset_chain()", "CALL reset_chain()", "DO $$ BEGIN EXECUTE 'DELETE FROM audit_events'; END $$", @@ -756,8 +762,10 @@ def test_runtime_sql_classifier_defaults_procedural_and_obfuscated_mutation_to_d [ "SELECT count(*) FROM audit_events", "SELECT * FROM audit_chain_heads", - "SELECT * FROM modelforge_audit.append_event_v2(NULL, NULL, NULL, NULL, NULL, " - "NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", + ( + "SELECT * FROM modelforge_audit.append_event_v2(NULL, NULL, NULL, NULL, NULL, " + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)" + ), ], ) def test_runtime_sql_classifier_keeps_read_and_canonical_append_controls( diff --git a/backend/tests/test_audit_migration_rc.py b/backend/tests/test_audit_migration_rc.py index ccd2108..36fd221 100644 --- a/backend/tests/test_audit_migration_rc.py +++ b/backend/tests/test_audit_migration_rc.py @@ -12,6 +12,8 @@ from types import ModuleType from unittest.mock import Mock import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations from sqlalchemy import ( JSON, BigInteger, @@ -28,8 +30,6 @@ from sqlalchemy import ( from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from alembic.migration import MigrationContext -from alembic.operations import Operations from modelforge_api.domain.audit import AUDIT_EMPTY_LEGACY_PREFIX_SEAL from modelforge_api.persistence.models import ( AuditChainHead, diff --git a/backend/tests/test_audit_postgres_boundary_rc.py b/backend/tests/test_audit_postgres_boundary_rc.py index c3a30c7..54aeb0e 100644 --- a/backend/tests/test_audit_postgres_boundary_rc.py +++ b/backend/tests/test_audit_postgres_boundary_rc.py @@ -9,21 +9,29 @@ from __future__ import annotations import hashlib import importlib.util +import os import re from pathlib import Path from types import ModuleType, SimpleNamespace -from typing import Any, cast -from unittest.mock import Mock +from typing import Any, Self, cast +import psycopg import pytest from pydantic import SecretStr +from sqlalchemy import create_engine, func, select from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session from modelforge_api.persistence.audit_postgres import ( AUDIT_APPEND_BODY_SHA256, AUDIT_GUARD_BODY_SHA256, ) -from modelforge_api.persistence.models import _textual_audit_dml_targets +from modelforge_api.persistence.models import ( + AuditChainHead, + AuditEvent, + _textual_audit_dml_targets, +) +from modelforge_api.services.audit import AuditWriter from modelforge_api.services.startup_validation import ( AUDIT_RUNTIME_BOUNDARY_SQL, StartupFailureCode, @@ -150,22 +158,20 @@ def test_generated_sql_is_executed_as_driver_safe_complete_statements() -> None: assert append_function.rstrip().endswith("$append$") assert all(statement.strip() and not statement.rstrip().endswith(";") for statement in statements) + class DriverConnection: + dialect = SimpleNamespace(name="postgresql", paramstyle="pyformat") -def test_postgres_percent_syntax_is_escaped_only_at_the_dbapi_boundary() -> None: - module = _migration() - connection = SimpleNamespace( - dialect=SimpleNamespace(name="postgresql", paramstyle="pyformat"), - exec_driver_sql=Mock(), - ) + def __init__(self) -> None: + self.statements: list[str] = [] - module._exec_postgres_sql( - connection, - "declare value public.audit_events%rowtype; select format('%I', 'value')", - ) + def exec_driver_sql(self, statement: str) -> None: + self.statements.append(statement) - connection.exec_driver_sql.assert_called_once_with( - "declare value public.audit_events%%rowtype; select format('%%I', 'value')" - ) + driver = DriverConnection() + module._install_postgres_audit_boundary(driver) + assert driver.statements + assert any("%%rowtype" in statement.lower() for statement in driver.statements) + assert any("%%I" in statement for statement in driver.statements) class _Rows: @@ -324,10 +330,10 @@ class _StartupConnection: def __init__(self, facts: dict[str, Any]) -> None: self.facts = facts - def __enter__(self) -> _StartupConnection: + def __enter__(self) -> Self: return self - def __exit__(self, *_args: Any) -> None: + def __exit__(self, *_args: object) -> None: return None def exec_driver_sql(self, _sql: str) -> _ScalarResult: @@ -335,7 +341,7 @@ class _StartupConnection: def execute(self, statement: Any) -> _ScalarResult: if "alembic_version" in str(statement): - return _ScalarResult("20260830_0024") + return _ScalarResult("20260909_0030") return _ScalarResult(self.facts) @@ -363,6 +369,118 @@ def _production_settings(tmp_path: Path, **overrides: Any) -> Settings: return Settings(**values) +def _psycopg_url(sqlalchemy_url: str) -> str: + return sqlalchemy_url.replace("postgresql+psycopg://", "postgresql://", 1) + + +def _assert_postgres_statement_is_denied(database_url: str, statement: str) -> None: + with psycopg.connect(_psycopg_url(database_url)) as connection: + # Keep the cursor scope nested so rollback happens before the connection context can + # commit; combining these contexts would make an unexpectedly allowed DDL statement real. + with connection.cursor() as cursor: # noqa: SIM117 + with pytest.raises(psycopg.errors.InsufficientPrivilege): + cursor.execute(statement) + connection.rollback() + + +def test_managed_postgresql_executes_the_runtime_privilege_boundary( + tmp_path: Path, +) -> None: + """Prove the generated grant contract against PostgreSQL, not merely against SQL text.""" + + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + if not owner_url or not runtime_url: + pytest.skip("managed PostgreSQL owner/runtime URLs are not configured") + + with psycopg.connect(_psycopg_url(owner_url)) as owner_connection: + owner_facts = owner_connection.execute( + """ + select current_user, session_user, rolsuper, rolcreaterole, rolcreatedb, + rolreplication, rolbypassrls + from pg_catalog.pg_roles + where rolname = current_user + """ + ).fetchone() + assert owner_facts == ("modelforge", "modelforge", False, False, False, False, False) + + with psycopg.connect(_psycopg_url(runtime_url)) as runtime_connection: + runtime_facts = runtime_connection.execute( + """ + select current_user, session_user, rolsuper, rolcreaterole, rolcreatedb, + rolreplication, rolbypassrls, rolinherit + from pg_catalog.pg_roles + where rolname = current_user + """ + ).fetchone() + memberships = runtime_connection.execute( + """ + select count(*) + from pg_catalog.pg_auth_members + where member = (select oid from pg_catalog.pg_roles where rolname = current_user) + """ + ).fetchone() + visible_events = runtime_connection.execute( + "select count(*) from public.audit_events" + ).fetchone() + assert runtime_facts == ( + "modelforge_runtime", + "modelforge_runtime", + False, + False, + False, + False, + False, + False, + ) + assert memberships == (0,) + assert visible_events == (0,) + + _assert_postgres_statement_is_denied(runtime_url, "set role modelforge") + _assert_postgres_statement_is_denied(runtime_url, "truncate public.audit_events") + _assert_postgres_statement_is_denied( + runtime_url, + "insert into public.audit_events default values", + ) + _assert_postgres_statement_is_denied( + runtime_url, + "create table public.modelforge_forbidden_runtime_ddl (id integer)", + ) + + engine = create_engine(runtime_url) + try: + with Session(engine) as session: + AuditWriter(session, "system", "managed-postgres-boundary").write( + "MANAGED_POSTGRES_BOUNDARY_VERIFIED", + "release_validation", + None, + {"credential": "runtime", "direct_audit_dml": "denied"}, + ) + session.commit() + + with Session(engine) as session: + checkpoint = session.get(AuditChainHead, 1) + event_count = session.scalar(select(func.count()).select_from(AuditEvent)) + assert checkpoint is not None + assert event_count == 1 + assert checkpoint.event_count == 1 + assert checkpoint.last_sequence == 1 + assert checkpoint.last_event_hash is not None + + report = validate_startup( + _production_settings(tmp_path, database_url=runtime_url), + engine, + ) + incompatible = [ + problem + for problem in report.problems + if problem.code is StartupFailureCode.INCOMPATIBLE_DATABASE + ] + assert incompatible == [] + finally: + engine.dispose() + + def test_production_startup_uses_catalog_policy_and_refuses_a_broken_trigger( tmp_path: Path, ) -> None: @@ -400,9 +518,12 @@ def test_production_configuration_rejects_owner_role_and_owner_secret_presence( def test_compose_and_image_keep_admin_owner_credentials_out_of_the_api_process() -> None: compose = (ROOT / "docker-compose.yml").read_text("utf-8") dockerfile = (ROOT / "backend" / "Dockerfile").read_text("utf-8") + postgres_section = compose.split("\n postgres:", 1)[1].split("\n redis:", 1)[0] api_section = compose.split("\n api:", 1)[1].split("\n migrate:", 1)[0] migrate_section = compose.split("\n migrate:", 1)[1].split("\n web:", 1)[0] + assert "MODELFORGE_POSTGRES_ADMIN_USER:" in postgres_section + assert "MODELFORGE_POSTGRES_ADMIN_PASSWORD:" in postgres_section assert "MODELFORGE_RUNTIME_DATABASE_URL" in api_section assert "MODELFORGE_MIGRATION_DATABASE_URL" not in api_section assert "MODELFORGE_POSTGRES_ADMIN_PASSWORD" not in api_section @@ -413,21 +534,32 @@ def test_compose_and_image_keep_admin_owner_credentials_out_of_the_api_process() def test_role_provisioning_contains_no_password_literal_and_demotes_both_app_roles() -> None: - for relative_path in ( - "deploy/postgres/init/001-modelforge-roles.sql", - "deploy/postgres/provision-existing-1.2.1.sql", - ): - provisioning = (ROOT / relative_path).read_text("utf-8") + provisioning = ( + ROOT / "deploy" / "postgres" / "init" / "001-modelforge-roles.sql" + ).read_text("utf-8") - assert "\\getenv owner_password" in provisioning - assert "\\getenv runtime_password" in provisioning - assert "nosuperuser nocreatedb nocreaterole" in provisioning.lower() - assert "modelforge_runtime" in provisioning - assert "noinherit" in provisioning.lower() - assert "pg_auth_members" in provisioning - assert "revoke create, temporary on database %I from public" in provisioning - assert "password 'modelforge'" not in provisioning.lower() - assert "password 'postgres'" not in provisioning.lower() + assert "\\getenv owner_password" in provisioning + assert "\\getenv runtime_password" in provisioning + assert "nosuperuser nocreatedb nocreaterole" in provisioning.lower() + assert "modelforge_runtime" in provisioning + assert "noinherit" in provisioning.lower() + assert "pg_auth_members" in provisioning + assert "password 'modelforge'" not in provisioning.lower() + assert "password 'postgres'" not in provisioning.lower() + + +def test_existing_cluster_provisioning_refuses_bootstrap_owner_before_mutation() -> None: + provisioning = ( + ROOT / "deploy" / "postgres" / "provision-existing-1.2.1.sql" + ).read_text("utf-8") + lowered = provisioning.lower() + + refusal = lowered.index("oid = 10") + first_role_mutation = lowered.index("create role") + assert refusal < first_role_mutation + assert "fresh cluster" in lowered + assert "--no-owner --no-privileges" in lowered + assert "pg_authid mutation" in lowered @pytest.mark.parametrize( diff --git a/backend/tests/test_audit_timeline_api_rc.py b/backend/tests/test_audit_timeline_api_rc.py new file mode 100644 index 0000000..640d37e --- /dev/null +++ b/backend/tests/test_audit_timeline_api_rc.py @@ -0,0 +1,252 @@ +"""Read-only, bounded audit timeline query contract regressions.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.db import get_session +from modelforge_api.main import app +from modelforge_api.persistence.models import AuditEvent, Base +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.settings import Settings, get_settings + +TIMELINE_PATH = "/api/v1/admin/audit/events" +ADMIN_CREDENTIAL = "audit-timeline-test-admin" + + +@pytest.fixture +def session() -> Iterator[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + engine.dispose() + + +@pytest.fixture(autouse=True) +def clean_overrides() -> Iterator[None]: + yield + app.dependency_overrides.clear() + + +def _client(session: Session, role: str = "admin") -> tuple[TestClient, dict[str, str]]: + app.dependency_overrides[get_session] = lambda: session + if role == "admin": + settings = Settings(_env_file=None, operator_api_key=SecretStr(ADMIN_CREDENTIAL)) # type: ignore[call-arg] + token = ADMIN_CREDENTIAL + else: + token = "mfop_" + "A" * 43 + identities = [ + { + "credential_id": f"audit-{role}", + "subject": f"audit-{role}", + "role": role, + "token_sha256": hashlib.sha256(token.encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + } + ] + settings = Settings( + _env_file=None, + operator_identities=SecretStr(json.dumps(identities)), + ) # type: ignore[call-arg] + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app), {"X-ModelForge-Admin-Token": token} + + +def _append( + session: Session, + *, + principal: str = "alice", + action: str = "MODEL_UPDATED", + resource_type: str = "model", + resource_id: str = "model-1", + outcome: str = "success", + correlation_id: str = "correlation-1", + details: dict[str, object] | None = None, +) -> AuditEvent: + event = AuditWriter( + session, + context=AuditContext("operator", principal, correlation_id), + ).write( + action, + resource_type, + resource_id, + details or {"changed_fields": ["display_name"]}, + outcome=outcome, + ) + session.commit() + return event + + +@pytest.mark.parametrize("role", ["viewer", "operator"]) +def test_timeline_requires_admin_human_authentication(session: Session, role: str) -> None: + client, headers = _client(session, role) + + assert client.get(TIMELINE_PATH).status_code == 401 + assert client.get(TIMELINE_PATH, headers=headers).status_code == 403 + assert ( + client.get( + TIMELINE_PATH, + headers={"Authorization": "Bearer mfsvc_project-scoped"}, + ).status_code + == 401 + ) + assert ( + client.get( + TIMELINE_PATH, + headers={"Authorization": "Bearer mfnode_node-scoped"}, + ).status_code + == 401 + ) + + +def test_timeline_applies_typed_filters_and_omits_untrusted_details(session: Session) -> None: + _append( + session, + principal="alice-operator", + action="NODE_ENROLLMENT_COMPLETED", + resource_type="compute_node", + resource_id="gpu_node-node-42", + outcome="failure", + correlation_id="corr-audit-42", + details={ + "access_token": "mfpw_NEVER_RETURN_THIS", + "nested": {"api-key": "NEVER_RETURN_THIS_EITHER", "changed": True}, + "comment": "authorization: Bearer mfpw_NEVER_RETURN_THIS", + "operator_note": "opaque-credential-value-do-not-return-7f93", + }, + ) + _append( + session, + principal="bob-operator", + action="NODE_ENROLLMENT_COMPLETED", + resource_type="compute_node", + resource_id="gpu_node-node-43", + outcome="success", + correlation_id="corr-audit-43", + ) + client, headers = _client(session) + moment = datetime.now(UTC) + response = client.get( + TIMELINE_PATH, + headers=headers, + params={ + "principal": "alice", + "action": "enrollment", + "resource": "node-42", + "outcome": "failure", + "correlation_id": "corr-audit-42", + "occurred_after": (moment - timedelta(minutes=1)).isoformat(), + "occurred_before": (moment + timedelta(minutes=1)).isoformat(), + "limit": 10, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert len(payload["events"]) == 1 + assert payload["events"][0]["actor_id"] == "alice-operator" + assert payload["events"][0]["action"] == "NODE_ENROLLMENT_COMPLETED" + assert "details" not in payload["events"][0] + for secret_like in ( + "NEVER_RETURN_THIS", + "NEVER_RETURN_THIS_EITHER", + "opaque-credential-value-do-not-return-7f93", + ): + assert secret_like not in response.text + + +def test_timeline_cursor_pages_stably_when_new_events_are_appended(session: Session) -> None: + seeded = [_append(session, principal=f"operator-{index}") for index in range(3)] + client, headers = _client(session) + + first = client.get(TIMELINE_PATH, headers=headers, params={"limit": 2}) + assert first.status_code == 200 + first_payload = first.json() + assert [item["sequence"] for item in first_payload["events"]] == [ + seeded[2].sequence, + seeded[1].sequence, + ] + cursor = first_payload["next_cursor"] + assert isinstance(cursor, str) and cursor + + _append(session, principal="operator-new") + second = client.get( + TIMELINE_PATH, + headers=headers, + params={"limit": 2, "cursor": cursor}, + ) + assert second.status_code == 200 + second_payload = second.json() + assert [item["sequence"] for item in second_payload["events"]] == [seeded[0].sequence] + assert second_payload["next_cursor"] is None + assert not ( + {item["id"] for item in first_payload["events"]} + & {item["id"] for item in second_payload["events"]} + ) + + mismatched_filters = client.get( + TIMELINE_PATH, + headers=headers, + params={"limit": 2, "cursor": cursor, "principal": "another-principal"}, + ) + assert mismatched_filters.status_code == 422 + + +@pytest.mark.parametrize( + "params", + [ + {"limit": 101}, + {"cursor": "not-a-valid-cursor"}, + {"principal": "p" * 256}, + {"action": "a" * 129}, + {"resource": "r" * 129}, + {"outcome": "o" * 33}, + {"correlation_id": "c" * 65}, + {"occurred_after": "2026-01-01T00:00:00Z"}, + {"occurred_after": "not-a-time", "occurred_before": "2026-01-02T00:00:00Z"}, + { + "occurred_after": "2024-01-01T00:00:00Z", + "occurred_before": "2026-01-01T00:00:00Z", + }, + { + "occurred_after": "2026-02-01T00:00:00Z", + "occurred_before": "2026-01-01T00:00:00Z", + }, + {"unexpected_filter": "not-accepted"}, + ], +) +def test_timeline_rejects_malformed_or_unbounded_query_input( + session: Session, params: dict[str, str | int] +) -> None: + client, headers = _client(session) + + response = client.get(TIMELINE_PATH, headers=headers, params=params) + + assert response.status_code == 422 + + +def test_timeline_returns_a_typed_empty_page_and_does_not_append_audit_events( + session: Session, +) -> None: + client, headers = _client(session) + + response = client.get(TIMELINE_PATH, headers=headers) + + assert response.status_code == 200 + assert response.json() == {"events": [], "next_cursor": None, "limit": 50} + assert session.scalar(select(func.count()).select_from(AuditEvent)) == 0 diff --git a/backend/tests/test_benchmark_run_recovery_compatibility.py b/backend/tests/test_benchmark_run_recovery_compatibility.py new file mode 100644 index 0000000..91afa80 --- /dev/null +++ b/backend/tests/test_benchmark_run_recovery_compatibility.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +from alembic import command +from modelforge_api.persistence.models import Base +from modelforge_api.services.recovery_fingerprint import ( + BENCHMARK_RUN_SCHEMA, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from tests.recovery_fixtures import recreate_0038_benchmark_runs + +_PRIOR = "20260924_0038" + + +def _migration_config(database_url: str) -> Config: + config = Config(str(Path(__file__).parents[1] / "alembic.ini")) + config.set_main_option("sqlalchemy.url", database_url) + return config + + +def _create_0038_benchmark_table(engine) -> None: + with engine.begin() as connection: + recreate_0038_benchmark_runs(connection) + connection.exec_driver_sql( + "CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)" + ) + connection.exec_driver_sql( + "INSERT INTO alembic_version (version_num) VALUES (?)", (_PRIOR,) + ) + + +def _insert_legacy_benchmark_run(engine) -> str: + run_id = uuid4().hex + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO benchmark_runs ( + id, deployment_id, suite_id, model_revision_sha, artifact_sha256, + runtime_type, runtime_version, runtime_image_digest, launch_arguments, + suite_revision, dataset_revision, environment_fingerprint, + environment_digest, seed, results, status, comparable, stale_reason, + started_at, completed_at + ) VALUES ( + :id, :deployment_id, :suite_id, :model_revision_sha, :artifact_sha256, + 'native', '1', NULL, '{}', 'suite-r1', 'dataset-r1', '{}', + :environment_digest, 7, '{"cases":[]}', 'completed', 1, NULL, + '2026-09-24T00:00:00', '2026-09-24T00:00:01' + ) + """ + ), + { + "id": run_id, + "deployment_id": uuid4().hex, + "suite_id": uuid4().hex, + "model_revision_sha": "a" * 64, + "artifact_sha256": "b" * 64, + "environment_digest": "c" * 64, + }, + ) + return run_id + + +def _fingerprint(engine): + with engine.connect() as connection: + return semantic_fingerprint(connection) + + +def _migrated_database(tmp_path): + database_path = tmp_path / "benchmark-recovery.db" + database_url = f"sqlite:///{database_path.as_posix()}" + engine = create_engine(database_url) + Base.metadata.create_all(engine) + _create_0038_benchmark_table(engine) + run_id = _insert_legacy_benchmark_run(engine) + + prior_columns = {column["name"] for column in inspect(engine).get_columns("benchmark_runs")} + assert "actor_subject" not in prior_columns + source = _fingerprint(engine) + assert set(source["tables"]["benchmark_runs"]) == { + "row_count", + "digest", + "status", + "redacted_columns", + } + + command.upgrade(_migration_config(database_url), "head") + assert _fingerprint(engine)["tables"]["benchmark_runs"]["row_count"] == 1 + return engine, source, run_id + + +def test_0038_benchmark_history_fingerprints_and_restores_through_0039(tmp_path) -> None: + engine, source, _run_id = _migrated_database(tmp_path) + try: + restored = _fingerprint(engine) + contract = fingerprint_compatibility(source, restored, source_schema_revision=_PRIOR) + + assert contract.compatible + assert contract.mode.startswith("BENCHMARK_RUNS_UPGRADE|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + finally: + engine.dispose() + + +def test_0039_upgrade_rejects_changed_legacy_benchmark_history(tmp_path) -> None: + engine, source, run_id = _migrated_database(tmp_path) + try: + with engine.begin() as connection: + connection.execute( + text("UPDATE benchmark_runs SET model_revision_sha=:sha WHERE id=:id"), + {"sha": "d" * 64, "id": run_id}, + ) + restored = _fingerprint(engine) + + contract = fingerprint_compatibility(source, restored, source_schema_revision=_PRIOR) + + assert not contract.compatible + finally: + engine.dispose() + + +def test_0039_upgrade_rejects_operator_state_absent_from_0038_backup(tmp_path) -> None: + engine, source, run_id = _migrated_database(tmp_path) + try: + with engine.begin() as connection: + connection.execute( + text( + "UPDATE benchmark_runs SET actor_subject='operator-a', " + "idempotency_key='run-1', request_fingerprint=:fingerprint, " + "capability_deployment_id=:deployment WHERE id=:id" + ), + { + "fingerprint": "e" * 64, + "deployment": uuid4().hex, + "id": run_id, + }, + ) + restored = _fingerprint(engine) + + contract = fingerprint_compatibility(source, restored, source_schema_revision=_PRIOR) + + assert not contract.compatible + finally: + engine.dispose() + + +def test_unversioned_current_database_keeps_operator_state_in_fingerprint(tmp_path) -> None: + engine, source, run_id = _migrated_database(tmp_path) + try: + with engine.begin() as connection: + connection.execute( + text( + "UPDATE benchmark_runs SET actor_subject='operator-a', " + "idempotency_key='run-1', request_fingerprint=:fingerprint, " + "capability_deployment_id=:deployment WHERE id=:id" + ), + { + "fingerprint": "e" * 64, + "deployment": uuid4().hex, + "id": run_id, + }, + ) + connection.execute(text("DROP TABLE alembic_version")) + restored = _fingerprint(engine) + projection = restored["tables"]["benchmark_runs"][ + "benchmark_run_schema_projection" + ] + + assert projection["non_legacy_row_count"] == 1 + assert not fingerprint_compatibility( + source, restored, source_schema_revision=_PRIOR + ).compatible + finally: + engine.dispose() + + +def test_unversioned_partial_benchmark_migration_fails_closed(tmp_path) -> None: + engine, source, _run_id = _migrated_database(tmp_path) + try: + with engine.begin() as connection: + connection.execute(text("DROP TABLE alembic_version")) + connection.execute(text("ALTER TABLE benchmark_runs DROP COLUMN generation")) + restored = _fingerprint(engine) + + assert restored["tables"]["benchmark_runs"]["benchmark_runs_schema_observed"] is True + assert not fingerprint_compatibility( + source, restored, source_schema_revision=_PRIOR + ).compatible + finally: + engine.dispose() + + +def test_0038_revision_with_0039_columns_fails_closed(tmp_path) -> None: + engine, source, _run_id = _migrated_database(tmp_path) + try: + with engine.begin() as connection: + connection.execute( + text("UPDATE alembic_version SET version_num=:revision"), + {"revision": _PRIOR}, + ) + restored = _fingerprint(engine) + + assert restored["tables"]["benchmark_runs"]["benchmark_runs_schema_observed"] is True + assert not fingerprint_compatibility( + source, restored, source_schema_revision=_PRIOR + ).compatible + finally: + engine.dispose() + + +def test_0039_revision_with_incomplete_benchmark_columns_fails_closed(tmp_path) -> None: + engine, _source, _run_id = _migrated_database(tmp_path) + try: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE benchmark_runs DROP COLUMN generation")) + current = _fingerprint(engine) + + assert current["tables"]["benchmark_runs"]["benchmark_runs_schema_observed"] is True + assert not fingerprint_compatibility( + current, current, source_schema_revision=BENCHMARK_RUN_SCHEMA + ).compatible + finally: + engine.dispose() + + +def test_0039_benchmark_history_is_exact_when_both_sides_are_current(tmp_path) -> None: + engine, _legacy_source, run_id = _migrated_database(tmp_path) + try: + source = _fingerprint(engine) + contract = fingerprint_compatibility( + source, source, source_schema_revision=BENCHMARK_RUN_SCHEMA + ) + assert contract.compatible + assert contract.mode.startswith("BENCHMARK_RUNS_STRICT|") + assert audit_fingerprint_differences_are_compatible(source, source, contract) + + with engine.begin() as connection: + connection.execute( + text("UPDATE benchmark_runs SET generation=2 WHERE id=:id"), {"id": run_id} + ) + changed = _fingerprint(engine) + assert not fingerprint_compatibility( + source, changed, source_schema_revision=BENCHMARK_RUN_SCHEMA + ).compatible + finally: + engine.dispose() diff --git a/backend/tests/test_benchmark_runs.py b/backend/tests/test_benchmark_runs.py new file mode 100644 index 0000000..a08722a --- /dev/null +++ b/backend/tests/test_benchmark_runs.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import create_engine, event, func, select, update +from sqlalchemy.orm import Session + +from modelforge_api.domain.benchmarking import BenchmarkRunStartRequest +from modelforge_api.persistence.models import ( + Base, + BenchmarkRun, + BenchmarkSuite, + GatewayRequest, + LabExecutionRun, + RuntimeProfile, +) +from modelforge_api.services.benchmark_runs import BenchmarkRunService +from modelforge_api.services.lab_runs import LabRunService +from modelforge_api.services.serving import ServingError, ServingService +from tests.test_dispatch_context import operator_fixture + + +@pytest.fixture +def session(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + engine.dispose() + + +def _request(serving, target_request, selected) -> BenchmarkRunStartRequest: + suite = BenchmarkRunService(serving).suites()[0] + return BenchmarkRunStartRequest( + capability="rag.embedding@1", + suite_id=suite.id, + target=target_request.target, + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + + +def _run(serving, target_request, selected, *, actor="operator-one", key="benchmark-once"): + request = _request(serving, target_request, selected) + return BenchmarkRunService(serving).start(request, actor_subject=actor, idempotency_key=key) + + +def test_suite_listing_is_read_only_and_advertises_declared_contract(session: Session) -> None: + serving, _target, _selected = operator_fixture(session) + writes: list[str] = [] + + def capture(_connection, _cursor, statement, _parameters, _context, _executemany): + if statement.lstrip().split(maxsplit=1)[0].upper() in {"INSERT", "UPDATE", "DELETE"}: + writes.append(statement) + + event.listen(session.get_bind(), "before_cursor_execute", capture) + suites = BenchmarkRunService(serving).suites() + + assert len(suites) == 1 + assert suites[0].capability == "rag.embedding@1" + assert suites[0].immutable_at.year == 2026 + assert not writes + assert session.scalar(select(func.count()).select_from(BenchmarkSuite)) == 0 + + +def test_operator_run_is_idempotent_actor_scoped_and_links_real_lab_gateway( + session: Session, +) -> None: + serving, target_request, selected = operator_fixture(session) + request = _request(serving, target_request, selected) + service = BenchmarkRunService(serving) + + first = service.start(request, actor_subject="operator-one", idempotency_key="same-run") + replay = service.start(request, actor_subject="operator-one", idempotency_key="same-run") + + assert first.id == replay.id + assert first.status == "running" + assert first.progress.active_case == "request-1" + assert first.contract.deployment_id == str(selected.capability_deployment_id) + assert first.contract.model_revision_sha + assert first.contract.artifact_sha256 + assert first.target_snapshot["deployment_id"] == str(selected.capability_deployment_id) + assert session.scalar(select(func.count()).select_from(BenchmarkRun)) == 1 + child = session.scalar( + select(LabExecutionRun).where(LabExecutionRun.actor_subject == "operator-one") + ) + assert child is not None + gateway = session.get(GatewayRequest, child.gateway_request_id) + assert gateway is not None + assert gateway.capability_deployment_id == selected.capability_deployment_id + assert gateway.status == "queued" + assert gateway.service_client_id is None + assert service.list(actor_subject="someone-else") == [] + with pytest.raises(ServingError) as raised: + service.get(first.id, actor_subject="someone-else") + assert raised.value.status_code == 404 + + changed = request.model_copy(update={"experiment_route_id": uuid4()}) + with pytest.raises(ServingError) as raised: + service.start(changed, actor_subject="operator-one", idempotency_key="same-run") + assert raised.value.code == "BENCHMARK_IDEMPOTENCY_CONFLICT" + + +def test_history_keyset_pages_tied_timestamps_without_skipping_when_new_run_arrives( + session: Session, +) -> None: + serving, target_request, selected = operator_fixture(session) + service = BenchmarkRunService(serving) + original = [ + _run( + serving, + target_request, + selected, + key=f"history-page-{index}", + ) + for index in range(5) + ] + _run(serving, target_request, selected, actor="operator-two", key="other-actor-run") + + tied_created_at = datetime(2026, 9, 24, 12, tzinfo=UTC) + session.execute( + update(BenchmarkRun) + .where(BenchmarkRun.actor_subject == "operator-one") + .values(created_at=tied_created_at) + ) + session.commit() + expected = sorted((run.id for run in original), key=lambda run_id: run_id.int, reverse=True) + + first_page = service.list_page(actor_subject="operator-one", limit=2) + assert [run.id for run in first_page.runs] == expected[:2] + assert first_page.next_cursor is not None + + inserted = _run( + serving, + target_request, + selected, + key="history-page-arrived-after-first-page", + ) + session.execute( + update(BenchmarkRun) + .where(BenchmarkRun.id == inserted.id) + .values(created_at=tied_created_at + timedelta(days=1)) + ) + session.commit() + + second_page = service.list_page( + actor_subject="operator-one", limit=2, cursor=first_page.next_cursor + ) + third_page = service.list_page( + actor_subject="operator-one", limit=2, cursor=second_page.next_cursor + ) + assert [run.id for run in second_page.runs] == expected[2:4] + assert [run.id for run in third_page.runs] == expected[4:] + assert third_page.next_cursor is None + assert service.list_page(actor_subject="operator-two", limit=2).runs[0].id != expected[0] + + with pytest.raises(ServingError) as raised: + service.list_page( + actor_subject="operator-two", limit=2, cursor=first_page.next_cursor + ) + assert raised.value.code == "BENCHMARK_CURSOR_INVALID" + with pytest.raises(ServingError) as raised: + service.list_page(actor_subject="operator-one", limit=2, cursor="") + assert raised.value.code == "BENCHMARK_CURSOR_INVALID" + + +def test_target_mismatch_fails_before_persisting_parent(session: Session) -> None: + serving, target_request, selected = operator_fixture(session) + request = _request(serving, target_request, selected) + request = request.model_copy( + update={"target": request.target.model_copy(update={"compute_node_id": uuid4()})} + ) + + with pytest.raises(ServingError) as raised: + BenchmarkRunService(serving).start( + request, actor_subject="operator-one", idempotency_key="bad-target" + ) + + assert raised.value.code == "BENCHMARK_TARGET_MISMATCH" + assert session.scalar(select(func.count()).select_from(BenchmarkRun)) == 0 + assert session.scalar(select(func.count()).select_from(LabExecutionRun)) == 0 + + +def test_secret_like_launch_arguments_are_not_persisted_in_provenance( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + serving, target_request, selected = operator_fixture(session) + request = _request(serving, target_request, selected) + service = BenchmarkRunService(serving) + service._suite(request.suite_id) + original_snapshot = serving._operator_lab_target_snapshot + + def snapshot_with_secret(*args, **kwargs): + snapshot = original_snapshot(*args, **kwargs) + serving.session.execute( + update(RuntimeProfile) + .where(RuntimeProfile.id == target_request.target.runtime_profile_id) + .values(launch_arguments={"environment": ["HF_TOKEN=private-canary"]}) + ) + serving.session.commit() + return snapshot + + monkeypatch.setattr(serving, "_operator_lab_target_snapshot", snapshot_with_secret) + + with pytest.raises(ServingError) as raised: + service.start(request, actor_subject="operator-one", idempotency_key="secret-arguments") + + assert raised.value.code == "BENCHMARK_RUNTIME_ARGUMENTS_UNSAFE" + assert "private-canary" not in str(raised.value) + assert session.scalar(select(func.count()).select_from(BenchmarkRun)) == 0 + + +def test_lost_parent_link_reuses_same_child_idempotency_key(session: Session) -> None: + serving, target_request, selected = operator_fixture(session) + response = _run(serving, target_request, selected, key="lost-link") + parent = session.get(BenchmarkRun, response.id) + assert parent is not None + execution = {**parent.results["execution"], "active_lab_run_id": None, "active_case": None} + parent.results = {**parent.results, "execution": execution} + parent.status = "accepted" + session.commit() + + # A process crash after child commit but before parent relink replays the deterministic + # actor-scoped LAB key. The child uniqueness constraint returns the same durable run. + resumed = BenchmarkRunService(serving) + assert resumed.reconcile_one(parent.id) + parent = session.get(BenchmarkRun, response.id) + assert parent is not None + assert UUID(parent.results["execution"]["active_lab_run_id"]) == UUID( + response.results["execution"]["active_lab_run_id"] + ) + assert ( + session.scalar( + select(func.count()) + .select_from(LabExecutionRun) + .where( + LabExecutionRun.actor_subject == "operator-one", + LabExecutionRun.idempotency_key == f"benchmark-{response.id.hex}-00", + ) + ) + == 1 + ) + + +def test_vector_throughput_requires_valid_count_and_timing_from_same_attempt() -> None: + results = BenchmarkRunService._initial_results(comparable=True) + results["execution"]["attempts"] = [ + {"status": "completed", "gateway": {"input_count": 4, "inference_time_ms": None}}, + {"status": "completed", "gateway": {"input_count": True, "inference_time_ms": 20.0}}, + {"status": "completed", "gateway": {"input_count": -2, "inference_time_ms": 10.0}}, + {"status": "completed", "gateway": {"input_count": 1, "inference_time_ms": 10.0}}, + ] + + refreshed = BenchmarkRunService._refresh_metrics(results, {}) + + throughput = refreshed["metrics"]["throughput"] + assert throughput["successful_vectors_per_inference_second"] == 100.0 + assert throughput["inference_samples"] == 1 + assert throughput["measured_vectors"] == 1 + assert throughput["successful_vectors_coverage"] == pytest.approx(1 / 5) + + +def test_unmeasured_or_mismatched_worker_resources_remain_unknown() -> None: + results = BenchmarkRunService._initial_results(comparable=True) + results["execution"]["attempts"] = [ + { + "worker_observations": { + "device_matches_target": True, + "metrics": {"temperature_c": 61}, + } + }, + { + "worker_observations": { + "device_matches_target": False, + "metrics": {"used_vram_bytes": 40_000}, + } + }, + ] + + metrics = BenchmarkRunService._refresh_metrics(results, {"accelerator_uuid": "GPU-0"})[ + "metrics" + ]["gpu_vram"] + + assert metrics["telemetry_status"] == "UNKNOWN" + assert metrics["vram_status"] == "UNKNOWN" + assert metrics["gpu_utilization_status"] == "UNKNOWN" + assert metrics["peak_used_vram_bytes"] is None + assert metrics["target_accelerator_uuid"] == "GPU-0" + + +def test_cancellation_racing_lab_admission_preserves_terminal_parent_and_stops_child( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + serving, target_request, selected = operator_fixture(session) + request = _request(serving, target_request, selected) + original_start = LabRunService.start + + def start_then_cancel(lab_service, lab_request, *, actor_subject, idempotency_key): + child_response = original_start( + lab_service, + lab_request, + actor_subject=actor_subject, + idempotency_key=idempotency_key, + ) + parent = session.scalar( + select(BenchmarkRun).where( + BenchmarkRun.actor_subject == "operator-one", + BenchmarkRun.idempotency_key == "racing-cancel", + ) + ) + assert parent is not None + BenchmarkRunService(serving).cancel(parent.id, actor_subject="operator-one") + return child_response + + monkeypatch.setattr(LabRunService, "start", start_then_cancel) + + # Start commits the parent before admission; the injected cancellation lands after the + # child is durable but before the benchmark reconciler can link it. + response = BenchmarkRunService(serving).start( + request, actor_subject="operator-one", idempotency_key="racing-cancel" + ) + parent = session.get(BenchmarkRun, response.id) + assert parent is not None and parent.status == "cancelled" + execution = parent.results["execution"] + child = session.get(LabExecutionRun, UUID(execution["active_lab_run_id"])) + assert child is not None and child.status == "cancelled" + assert response.status == "cancelled" + assert response.progress.active_case is None + + +def test_normal_reconciliation_resumes_children_and_aggregates_actual_gateway_fields( + session: Session, +) -> None: + serving, target_request, selected = operator_fixture(session) + response = _run(serving, target_request, selected, key="resume-run") + parent_id = response.id + baseline = datetime.now(UTC).replace(microsecond=0) + + for index in range(4): + parent = session.get(BenchmarkRun, parent_id) + assert parent is not None + execution = parent.results["execution"] + child_id = UUID(execution["active_lab_run_id"]) + child = session.get(LabExecutionRun, child_id) + assert child is not None + gateway = session.get(GatewayRequest, child.gateway_request_id) + assert gateway is not None + gateway.created_at = baseline + timedelta(seconds=index * 2) + gateway.finished_at = gateway.created_at + timedelta(milliseconds=250) + gateway.cold = index == 0 + gateway.load_time_ms = 75.0 if index == 0 else None + gateway.inference_time_ms = 100.0 if index == 0 else (None if index == 1 else 200.0) + gateway.total_latency_ms = 125.0 + index * 10 + gateway.input_count = 1 if index != 1 else 4 + child.status = "completed" + child.failure_code = None + child.finished_at = gateway.finished_at + child.worker_observations = ( + { + "device_matches_target": True, + "metrics": { + "used_vram_bytes": 3_000, + "process_used_vram_bytes": 2_500, + "free_vram_bytes": 9_000, + "gpu_utilization_percent": 30, + }, + } + if index == 0 + else {"device_matches_target": False, "metrics": {"used_vram_bytes": 99}} + ) + session.commit() + + # Recreate the service object to prove accepted progress is recoverable from rows, + # then use the same normal reconciliation entry point as the background tick. + resumed = ServingService( + session, serving._deployment_settings, serving.manifests, serving.payload_store + ) + summary = resumed.reconcile() + assert "benchmark_runs_driven" in summary + + parent = session.get(BenchmarkRun, parent_id) + assert parent is not None and parent.status == "completed" + metrics = parent.results["metrics"] + assert metrics["cold_load_latency_ms"] == {"p50": 75.0, "p95": 75.0, "samples": 1} + assert metrics["warm_latency_ms"]["samples"] == 3 + assert metrics["failures"] == {"attempted": 4, "failed": 0, "rate": 0.0} + assert metrics["throughput"]["inference_samples"] == 3 + assert metrics["throughput"]["measured_vectors"] == 3 + assert metrics["throughput"]["successful_vectors_coverage"] == pytest.approx(3 / 7) + assert metrics["gpu_vram"]["telemetry_status"] == "MEASURED" + assert metrics["gpu_vram"]["peak_used_vram_bytes"] == 3_000 + assert metrics["quality"]["status"] == "UNKNOWN" diff --git a/backend/tests/test_benchmark_runs_api.py b/backend/tests/test_benchmark_runs_api.py new file mode 100644 index 0000000..fc3ac6e --- /dev/null +++ b/backend/tests/test_benchmark_runs_api.py @@ -0,0 +1,192 @@ +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from modelforge_api.api.authorization import ( + Principal, + PrincipalRole, + authenticate_operator_principal, +) +from modelforge_api.api.routes.lab import get_benchmark_run_service, router +from modelforge_api.domain.benchmarking import ( + BenchmarkRunProgress, + BenchmarkRunResponse, + BenchmarkSuiteResponse, +) +from modelforge_api.domain.contracts import BenchmarkEnvironmentFingerprint, BenchmarkRunContract + + +def _responses() -> tuple[BenchmarkSuiteResponse, BenchmarkRunResponse]: + now = datetime.now(UTC) + suite = BenchmarkSuiteResponse( + id=uuid4(), + key="modelforge.rag-embedding.operator-performance", + revision="1", + capability="rag.embedding@1", + dataset_revision="builtin:rag-embedding-performance-v1", + definition={"schema_version": 1}, + immutable_at=now, + ) + environment = BenchmarkEnvironmentFingerprint( + runtime_type="onnx", + runtime_version="1.0", + runtime_image_digest=None, + launch_arguments={}, + cuda_version=None, + driver_version=None, + accelerator_name=None, + accelerator_uuid=None, + context_length=None, + concurrency=1, + seed=None, + operating_system=None, + python_version=None, + ) + run = BenchmarkRunResponse( + id=uuid4(), + capability="rag.embedding@1", + suite_id=suite.id, + capability_deployment_id=uuid4(), + status="accepted", + contract=BenchmarkRunContract( + deployment_id=str(uuid4()), + model_revision_sha="a" * 40, + artifact_sha256="b" * 64, + suite_key=suite.key, + suite_revision=suite.revision, + dataset_revision=suite.dataset_revision, + environment=environment, + started_at=now, + ), + target_snapshot={}, + progress=BenchmarkRunProgress( + completed_invocations=0, total_invocations=4, active_case=None + ), + results={"metrics": {"gpu_vram": {"telemetry_status": "UNKNOWN"}}}, + created_at=now, + ) + return suite, run + + +def _harness( + role: PrincipalRole, +) -> tuple[TestClient, Mock, BenchmarkSuiteResponse, BenchmarkRunResponse]: + app = FastAPI() + app.include_router(router) + service = Mock() + suite, run = _responses() + service.suites.return_value = [suite] + service.list.return_value = [run] + service.list_page.return_value = SimpleNamespace(runs=[run], next_cursor=None) + service.start.return_value = run + service.get.return_value = run + service.cancel.return_value = run + app.dependency_overrides[authenticate_operator_principal] = lambda: Principal( + "operator-one", role, "test" + ) + app.dependency_overrides[get_benchmark_run_service] = lambda: service + return TestClient(app), service, suite, run + + +def _request(suite: BenchmarkSuiteResponse) -> dict[str, object]: + return { + "capability": "rag.embedding@1", + "suite_id": str(suite.id), + "target": { + key: str(uuid4()) + for key in ( + "deployment_candidate_id", + "artifact_set_id", + "runtime_profile_id", + "compute_node_id", + "execution_approval_id", + ) + }, + "deployment_id": str(uuid4()), + "experiment_route_id": str(uuid4()), + } + + +def test_benchmark_endpoints_forward_operator_identity_and_disable_caching() -> None: + client, service, suite, run = _harness(PrincipalRole.OPERATOR) + + suites = client.get("/api/v1/lab/benchmark-suites") + history = client.get("/api/v1/lab/benchmark-runs") + started = client.post( + "/api/v1/lab/benchmark-runs", + headers={"Idempotency-Key": "operator-benchmark"}, + json=_request(suite), + ) + status = client.get(f"/api/v1/lab/benchmark-runs/{run.id}") + cancelled = client.post(f"/api/v1/lab/benchmark-runs/{run.id}/cancel") + + assert suites.status_code == history.status_code == 200 + assert started.status_code == 202 + assert status.status_code == cancelled.status_code == 200 + assert all( + response.headers["cache-control"] == "no-store" + for response in (suites, history, started, status, cancelled) + ) + assert service.start.call_args.kwargs == { + "actor_subject": "operator-one", + "idempotency_key": "operator-benchmark", + } + assert service.list_page.call_args.kwargs == { + "actor_subject": "operator-one", + "limit": 50, + "cursor": None, + } + assert service.get.call_args.kwargs == {"actor_subject": "operator-one"} + assert service.cancel.call_args.kwargs == {"actor_subject": "operator-one"} + + +def test_benchmark_history_cursor_preserves_the_existing_list_response_contract() -> None: + client, service, _suite, run = _harness(PrincipalRole.OPERATOR) + service.list_page.return_value = SimpleNamespace(runs=[run], next_cursor="opaque-next-page") + + response = client.get( + "/api/v1/lab/benchmark-runs", + params={"limit": 1, "cursor": "opaque-current-page"}, + ) + + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert [item["id"] for item in response.json()] == [str(run.id)] + assert response.headers["x-next-cursor"] == "opaque-next-page" + assert response.headers["access-control-expose-headers"] == "X-Next-Cursor" + assert service.list_page.call_args.kwargs == { + "actor_subject": "operator-one", + "limit": 1, + "cursor": "opaque-current-page", + } + + +def test_benchmark_write_routes_require_operator_and_strict_declared_capability() -> None: + client, service, suite, _run = _harness(PrincipalRole.VIEWER) + + denied = client.post( + "/api/v1/lab/benchmark-runs", + headers={"Idempotency-Key": "viewer-benchmark"}, + json=_request(suite), + ) + invalid = _request(suite) + invalid["capability"] = "rag.rerank@1" + rejected = _harness(PrincipalRole.OPERATOR)[0].post( + "/api/v1/lab/benchmark-runs", + headers={"Idempotency-Key": "bad-capability"}, + json=invalid, + ) + operator_client, operator_service, operator_suite, _operator_run = _harness( + PrincipalRole.OPERATOR + ) + missing_key = operator_client.post("/api/v1/lab/benchmark-runs", json=_request(operator_suite)) + + assert denied.status_code == 403 + assert rejected.status_code == 422 + assert missing_key.status_code == 422 + service.start.assert_not_called() + operator_service.start.assert_not_called() diff --git a/backend/tests/test_benchmark_runs_migration.py b/backend/tests/test_benchmark_runs_migration.py new file mode 100644 index 0000000..11c4090 --- /dev/null +++ b/backend/tests/test_benchmark_runs_migration.py @@ -0,0 +1,89 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from alembic.config import Config +from sqlalchemy import create_engine, inspect + +from alembic import command +from modelforge_api.persistence.models import Base + + +def test_operator_benchmark_migration_upgrades_and_downgrades_sqlite() -> None: + with TemporaryDirectory() as temp: + database_path = Path(temp) / "benchmark-migration.db" + url = f"sqlite:///{database_path.as_posix()}" + engine = create_engine(url) + Base.metadata.create_all(engine) + with engine.begin() as connection: + connection.exec_driver_sql("DROP TABLE benchmark_runs") + connection.exec_driver_sql( + """ + CREATE TABLE benchmark_runs ( + id CHAR(32) NOT NULL, + deployment_id CHAR(32) NOT NULL, + suite_id CHAR(32) NOT NULL, + model_revision_sha VARCHAR(64) NOT NULL, + artifact_sha256 VARCHAR(64) NOT NULL, + runtime_type VARCHAR(32) NOT NULL, + runtime_version VARCHAR(128) NOT NULL, + runtime_image_digest VARCHAR(71), + launch_arguments JSON NOT NULL, + suite_revision VARCHAR(128) NOT NULL, + dataset_revision VARCHAR(255) NOT NULL, + environment_fingerprint JSON NOT NULL, + environment_digest VARCHAR(64) NOT NULL, + seed INTEGER, + results JSON NOT NULL, + status VARCHAR(32) NOT NULL, + comparable BOOLEAN NOT NULL, + stale_reason TEXT, + started_at DATETIME NOT NULL, + completed_at DATETIME, + PRIMARY KEY (id), + FOREIGN KEY(deployment_id) REFERENCES deployments (id) ON DELETE RESTRICT, + FOREIGN KEY(suite_id) REFERENCES benchmark_suites (id) ON DELETE RESTRICT + ) + """ + ) + connection.exec_driver_sql( + "CREATE INDEX ix_benchmark_runs_deployment_id ON benchmark_runs (deployment_id)" + ) + connection.exec_driver_sql( + "CREATE INDEX ix_benchmark_runs_suite_id ON benchmark_runs (suite_id)" + ) + connection.exec_driver_sql( + "CREATE INDEX ix_benchmark_runs_artifact_sha256 ON benchmark_runs (artifact_sha256)" + ) + connection.exec_driver_sql( + "CREATE INDEX ix_benchmark_runs_environment_digest ON benchmark_runs (environment_digest)" + ) + connection.exec_driver_sql( + "CREATE INDEX ix_benchmark_runs_deployment_completed " + "ON benchmark_runs (deployment_id, completed_at)" + ) + + config = Config(str(Path(__file__).parents[1] / "alembic.ini")) + config.set_main_option("sqlalchemy.url", url) + command.stamp(config, "20260924_0038") + command.upgrade(config, "head") + + upgraded = inspect(engine) + columns = {column["name"]: column for column in upgraded.get_columns("benchmark_runs")} + assert columns["deployment_id"]["nullable"] is True + assert "capability_deployment_id" in columns + indexes = {index["name"] for index in upgraded.get_indexes("benchmark_runs")} + assert "ix_benchmark_runs_actor_subject" in indexes + assert "ix_benchmark_runs_capability_deployment_completed" in indexes + + command.downgrade(config, "20260924_0038") + downgraded = { + column["name"]: column for column in inspect(engine).get_columns("benchmark_runs") + } + assert downgraded["deployment_id"]["nullable"] is False + assert "capability_deployment_id" not in downgraded + + command.upgrade(config, "head") + assert "capability_deployment_id" in { + column["name"] for column in inspect(engine).get_columns("benchmark_runs") + } + engine.dispose() diff --git a/backend/tests/test_benchmark_runs_postgres.py b/backend/tests/test_benchmark_runs_postgres.py new file mode 100644 index 0000000..cece23f --- /dev/null +++ b/backend/tests/test_benchmark_runs_postgres.py @@ -0,0 +1,446 @@ +"""Managed PostgreSQL qualification for benchmark-run persistence and restart behavior. + +This test is enabled only by the disposable PostgreSQL boundary in managed-validation.yml. +It creates one uniquely named database on that boundary, then drops only that database if it +still exists and is still owned by the expected validation role. +""" + +from __future__ import annotations + +import os +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from pathlib import Path + +import pytest +import sqlalchemy as sa +from alembic.config import Config +from sqlalchemy import create_engine, func, inspect, select, text +from sqlalchemy.engine import URL, make_url +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from alembic import command +from modelforge_api.domain.benchmarking import BenchmarkRunStartRequest +from modelforge_api.persistence.models import ( + ArtifactSet, + ArtifactSetMember, + BenchmarkRun, + Deployment, + GatewayRequest, + LabExecutionRun, +) +from modelforge_api.services.benchmark_runs import BenchmarkRunService +from modelforge_api.services.serving import ServingService +from modelforge_api.services.transient_payloads import MemoryPayloadStore +from tests.test_dispatch_context import operator_fixture + +_PRIOR = "20260924_0038" +_CURRENT = "20260924_0039" +_LIFECYCLE_OPT_IN = "managed-validation-v1" + + +def _managed_test_urls() -> tuple[URL, URL, URL]: + """Fail closed unless the dedicated disposable PostgreSQL boundary opted in.""" + if os.getenv("MODELFORGE_TEST_POSTGRES_DATABASE_LIFECYCLE") is None: + pytest.skip("requires the managed-validation disposable PostgreSQL lifecycle opt-in") + if os.getenv("MODELFORGE_TEST_POSTGRES_DATABASE_LIFECYCLE") != _LIFECYCLE_OPT_IN: + pytest.fail("managed PostgreSQL database lifecycle opt-in has an unexpected value") + + raw_urls = ( + os.getenv("MODELFORGE_TEST_POSTGRES_URL"), + os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL"), + os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL"), + ) + if not all(raw_urls): + pytest.fail("managed PostgreSQL lifecycle opt-in requires all three test role URLs") + + urls = tuple(make_url(value) for value in raw_urls if value is not None) + expected_users = ("postgres", "modelforge", "modelforge_runtime") + expected_passwords = ( + os.getenv("MODELFORGE_POSTGRES_ADMIN_PASSWORD"), + os.getenv("MODELFORGE_MIGRATION_DB_PASSWORD"), + os.getenv("MODELFORGE_RUNTIME_DB_PASSWORD"), + ) + labels = ("admin", "migration owner", "runtime") + if not all(expected_passwords): + pytest.fail("managed PostgreSQL role passwords are unavailable in this test process") + + for url, username, password, label in zip( + urls, expected_users, expected_passwords, labels, strict=True + ): + if ( + url.drivername != "postgresql+psycopg" + or url.host != "postgres" + or url.port != 5432 + or url.database != "modelforge" + or url.username != username + or url.query + ): + pytest.fail( + f"managed PostgreSQL {label} URL does not match the isolated CI test boundary" + ) + if url.password != password: + pytest.fail( + f"managed PostgreSQL {label} URL does not match its dedicated test role password" + ) + return urls # type: ignore[return-value] + + +def _current_revision(engine) -> str: + with engine.connect() as connection: + return connection.scalar(text("SELECT version_num FROM alembic_version")) + + +def _alembic_config(database_url: URL) -> Config: + config = Config() + config.set_main_option("script_location", str(Path(__file__).resolve().parents[1] / "alembic")) + rendered_url = database_url.render_as_string(hide_password=False).replace("%", "%%") + config.set_main_option("sqlalchemy.url", rendered_url) + return config + + +def _insert_legacy_run( + connection: sa.Connection, + *, + run_id: uuid.UUID, + deployment_id: uuid.UUID, + suite_id: uuid.UUID, +) -> None: + """Seed the exact pre-0039 row shape to prove the additive migration preserves history.""" + legacy_runs = sa.table( + "benchmark_runs", + sa.column("id", sa.Uuid()), + sa.column("deployment_id", sa.Uuid()), + sa.column("suite_id", sa.Uuid()), + sa.column("model_revision_sha", sa.String(64)), + sa.column("artifact_sha256", sa.String(64)), + sa.column("runtime_type", sa.String(32)), + sa.column("runtime_version", sa.String(128)), + sa.column("runtime_image_digest", sa.String(71)), + sa.column("launch_arguments", sa.JSON()), + sa.column("suite_revision", sa.String(128)), + sa.column("dataset_revision", sa.String(255)), + sa.column("environment_fingerprint", sa.JSON()), + sa.column("environment_digest", sa.String(64)), + sa.column("seed", sa.Integer()), + sa.column("results", sa.JSON()), + sa.column("status", sa.String(32)), + sa.column("comparable", sa.Boolean()), + sa.column("stale_reason", sa.Text()), + sa.column("started_at", sa.DateTime(timezone=True)), + sa.column("completed_at", sa.DateTime(timezone=True)), + ) + now = datetime.now(UTC) + connection.execute( + legacy_runs.insert().values( + id=run_id, + deployment_id=deployment_id, + suite_id=suite_id, + model_revision_sha="a" * 40, + artifact_sha256="b" * 64, + runtime_type="onnx", + runtime_version="1.0", + runtime_image_digest=None, + launch_arguments={}, + suite_revision="1", + dataset_revision="builtin:legacy-benchmark-fixture-v1", + environment_fingerprint={}, + environment_digest="c" * 64, + seed=None, + results={"legacy_fixture": True}, + status="completed", + comparable=True, + stale_reason=None, + started_at=now, + completed_at=now, + ) + ) + + +def test_managed_postgresql_benchmark_migration_race_and_restart( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise migration 0039 and concurrent/restarted starts against real PostgreSQL.""" + admin_url, owner_base_url, runtime_base_url = _managed_test_urls() + database_name = f"test_benchmark_run_{uuid.uuid4().hex}" + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT", pool_pre_ping=True) + created_database = False + owner_engine = None + runtime_engine = None + + try: + with admin_engine.connect() as connection: + already_exists = connection.scalar( + text("SELECT 1 FROM pg_database WHERE datname = :database_name"), + {"database_name": database_name}, + ) + assert already_exists is None + connection.execute(text(f'CREATE DATABASE "{database_name}" OWNER modelforge')) + created_database = True + + owner_url = owner_base_url.set(database=database_name) + runtime_url = runtime_base_url.set(database=database_name) + owner_engine = create_engine(owner_url, pool_pre_ping=True) + runtime_engine = create_engine( + runtime_url, + pool_pre_ping=True, + connect_args={ + "connect_timeout": 15, + "options": "-c statement_timeout=30000 -c lock_timeout=15000", + }, + ) + migration = _alembic_config(owner_url) + command.upgrade(migration, _PRIOR) + + legacy_run_id = uuid.uuid4() + with Session(owner_engine) as setup_session: + serving, target_request, selected = operator_fixture(setup_session) + benchmark_service = BenchmarkRunService(serving) + advertised_suite = benchmark_service.suites()[0] + suite = benchmark_service._suite(advertised_suite.id) + request = BenchmarkRunStartRequest( + capability="rag.embedding@1", + suite_id=suite.id, + target=target_request.target, + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + artifact_set = setup_session.get(ArtifactSet, selected.deployment.artifact_set_id) + artifact_id = setup_session.scalar( + select(ArtifactSetMember.artifact_id) + .where(ArtifactSetMember.artifact_set_id == selected.deployment.artifact_set_id) + .limit(1) + ) + assert artifact_set is not None and artifact_id is not None + legacy_deployment = Deployment( + name=f"benchmark-migration-fixture-{uuid.uuid4().hex}", + revision_id=artifact_set.revision_id, + artifact_id=artifact_id, + derived_artifact_id=None, + runtime_profile_id=selected.deployment.runtime_profile_id, + compute_node_id=selected.deployment.compute_node_id, + channel="candidate", + status="draft", + capability_contract_ids=[], + ) + setup_session.add(legacy_deployment) + setup_session.flush() + legacy_deployment_id = legacy_deployment.id + setup_session.commit() + deployment_settings = serving._deployment_settings + manifests = serving.manifests + with owner_engine.begin() as connection: + _insert_legacy_run( + connection, + run_id=legacy_run_id, + deployment_id=legacy_deployment_id, + suite_id=suite.id, + ) + + command.upgrade(migration, "head") + assert _current_revision(owner_engine) == _CURRENT + + column_metadata = { + column["name"]: column for column in inspect(owner_engine).get_columns("benchmark_runs") + } + upgraded_columns = set(column_metadata) + assert column_metadata["deployment_id"]["nullable"] is True + assert { + "capability_deployment_id", + "target_snapshot", + "actor_subject", + "idempotency_key", + "request_fingerprint", + "failure_code", + "cancellation_requested_at", + "created_at", + "generation", + } <= upgraded_columns + constraints = inspect(owner_engine) + assert "ck_benchmark_runs_operator_identity" in { + constraint["name"] for constraint in constraints.get_check_constraints("benchmark_runs") + } + assert "uq_benchmark_runs_actor_idempotency" in { + constraint["name"] + for constraint in constraints.get_unique_constraints("benchmark_runs") + } + capability_fk = next( + foreign_key + for foreign_key in constraints.get_foreign_keys("benchmark_runs") + if foreign_key["name"] == "fk_benchmark_runs_capability_deployment" + ) + assert capability_fk["referred_table"] == "capability_deployments" + assert capability_fk["options"]["ondelete"] == "RESTRICT" + + with Session(owner_engine) as session: + historical = session.get(BenchmarkRun, legacy_run_id) + assert historical is not None + assert historical.deployment_id == legacy_deployment_id + assert historical.actor_subject is None + assert historical.capability_deployment_id is None + assert historical.created_at is not None + assert historical.generation == 1 + + with owner_engine.connect() as connection: + historical_values = dict( + connection.execute( + select(BenchmarkRun.__table__).where(BenchmarkRun.id == legacy_run_id) + ) + .mappings() + .one() + ) + historical_values["id"] = uuid.uuid4() + historical_values["actor_subject"] = "partial-identity-must-fail" + with pytest.raises(IntegrityError), owner_engine.begin() as connection: + connection.execute(BenchmarkRun.__table__.insert().values(historical_values)) + + with runtime_engine.connect() as connection: + assert connection.scalar(text("SELECT version_num FROM alembic_version")) == _CURRENT + for privilege in ("SELECT", "INSERT", "UPDATE"): + assert connection.scalar( + text("SELECT has_table_privilege(current_user, 'benchmark_runs', :privilege)"), + {"privilege": privilege}, + ) + for privilege in ("DELETE", "TRUNCATE"): + assert not connection.scalar( + text("SELECT has_table_privilege(current_user, 'benchmark_runs', :privilege)"), + {"privilege": privilege}, + ) + + original_provenance = BenchmarkRunService._provenance + provenance_barrier = threading.Barrier(2) + + def synchronized_provenance(service, benchmark_request): + provenance = original_provenance(service, benchmark_request) + provenance_barrier.wait(timeout=30) + return provenance + + monkeypatch.setattr(BenchmarkRunService, "_provenance", synchronized_provenance) + + def start_once() -> uuid.UUID: + with Session(runtime_engine) as session: + serving = ServingService( + session, + deployment_settings, + manifests, + MemoryPayloadStore(), + ) + return ( + BenchmarkRunService(serving) + .start( + request, + actor_subject="managed-postgres-operator", + idempotency_key="same-benchmark-start", + ) + .id + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + run_ids = list(pool.map(lambda _index: start_once(), range(2))) + assert run_ids[0] == run_ids[1] + + benchmark_run_id = run_ids[0] + with Session(runtime_engine) as session: + benchmark = session.get(BenchmarkRun, benchmark_run_id) + assert benchmark is not None + assert benchmark.actor_subject == "managed-postgres-operator" + assert benchmark.idempotency_key == "same-benchmark-start" + assert benchmark.capability_deployment_id == request.deployment_id + assert benchmark.target_snapshot["deployment_id"] == str(request.deployment_id) + assert ( + session.scalar( + select(func.count()) + .select_from(BenchmarkRun) + .where( + BenchmarkRun.actor_subject == "managed-postgres-operator", + BenchmarkRun.idempotency_key == "same-benchmark-start", + ) + ) + == 1 + ) + child_id = uuid.UUID(benchmark.results["execution"]["active_lab_run_id"]) + child = session.get(LabExecutionRun, child_id) + assert child is not None + assert child.actor_subject == "managed-postgres-operator" + assert child.idempotency_key == f"benchmark-{benchmark_run_id.hex}-00" + assert session.get(GatewayRequest, child.gateway_request_id) is not None + assert ( + session.scalar( + select(func.count()) + .select_from(LabExecutionRun) + .where( + LabExecutionRun.actor_subject == "managed-postgres-operator", + LabExecutionRun.idempotency_key == f"benchmark-{benchmark_run_id.hex}-00", + ) + ) + == 1 + ) + + # Recreate the engine and service after the race: the committed parent/child link must + # remain readable and replaying the same actor/key must not create another child. + runtime_engine.dispose() + runtime_engine = create_engine( + runtime_url, + pool_pre_ping=True, + connect_args={ + "connect_timeout": 15, + "options": "-c statement_timeout=30000 -c lock_timeout=15000", + }, + ) + with Session(runtime_engine) as session: + serving = ServingService( + session, + deployment_settings, + manifests, + MemoryPayloadStore(), + ) + service = BenchmarkRunService(serving) + recovered = service.get(benchmark_run_id, actor_subject="managed-postgres-operator") + replay = service.start( + request, + actor_subject="managed-postgres-operator", + idempotency_key="same-benchmark-start", + ) + assert recovered.id == replay.id == benchmark_run_id + assert replay.contract.deployment_id == str(request.deployment_id) + assert len(service.list(actor_subject="managed-postgres-operator")) == 1 + assert ( + session.scalar( + select(func.count()) + .select_from(LabExecutionRun) + .where(LabExecutionRun.actor_subject == "managed-postgres-operator") + ) + == 1 + ) + + # The downgrade must refuse to erase operator history and leave the migrated schema live. + with pytest.raises(RuntimeError, match="Operator benchmark history exists"): + command.downgrade(migration, _PRIOR) + assert _current_revision(owner_engine) == _CURRENT + with Session(owner_engine) as session: + assert session.get(BenchmarkRun, benchmark_run_id) is not None + assert session.get(BenchmarkRun, legacy_run_id) is not None + finally: + if runtime_engine is not None: + runtime_engine.dispose() + if owner_engine is not None: + owner_engine.dispose() + if created_database: + with admin_engine.connect() as connection: + database_owner = connection.scalar( + text( + "SELECT pg_get_userbyid(datdba) FROM pg_database " + "WHERE datname = :database_name" + ), + {"database_name": database_name}, + ) + if database_owner is not None: + if database_owner != "modelforge": + raise RuntimeError( + "refusing to remove the qualification database after its owner changed" + ) + connection.execute(text(f'DROP DATABASE "{database_name}" WITH (FORCE)')) + admin_engine.dispose() diff --git a/backend/tests/test_catalog_query_performance.py b/backend/tests/test_catalog_query_performance.py new file mode 100644 index 0000000..16297a2 --- /dev/null +++ b/backend/tests/test_catalog_query_performance.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import create_engine, event, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.routes.catalog import capability_estate, model_installation_rationale +from modelforge_api.domain.schemas import ( + CapabilityEstateResponse, + InstallationDependencyResponse, + ModelInstallationRationaleResponse, +) +from modelforge_api.persistence.models import ( + ArtifactSet, + Base, + Capability, + CapabilityContract, + CapabilityDeployment, + CapabilityEvaluationRun, + CapabilityResourceEnvelope, + Model, + ModelRevision, + Project, + ProjectBinding, + ProjectFitEvidence, + ResidencyAllocation, + RuntimeProfile, + ServiceClient, +) +from modelforge_api.services.manifest_registry import ManifestRegistry + + +def _catalog_session() -> tuple[Engine, Session, dict[str, UUID]]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + session = Session(engine) + now = datetime(2026, 1, 1, tzinfo=UTC) + ids: dict[str, UUID] = {} + + models: dict[str, Model] = {} + revisions: dict[str, ModelRevision] = {} + artifact_sets: dict[str, ArtifactSet] = {} + for key, display_name in (("model-z", "Zulu model"), ("model-a", "Alpha model"), ("model-b", "Beta model")): + model = Model( + key=key, + display_name=display_name, + upstream_provider="huggingface", + upstream_source=f"itworx/{key}", + ) + session.add(model) + session.flush() + revision = ModelRevision( + model_id=model.id, + upstream_revision="main", + resolved_commit_sha=("a" if key == "model-a" else "b" if key == "model-b" else "c") * 40, + ) + session.add(revision) + session.flush() + models[key] = model + revisions[key] = revision + ids[key] = model.id + + if key != "model-z": + artifact_set = ArtifactSet( + revision_id=revision.id, + snapshot_id=uuid4(), + variant_key="full", + label=f"{display_name} full set", + selection_reason="installed local test fixture", + selected_paths=["weights.safetensors"], + total_size_bytes=1000 if key == "model-a" else 2000, + file_count=1, + availability="local", + status="complete", + completeness="complete", + security_status="verified", + license_status="approved", + immutable_at=now, + ) + session.add(artifact_set) + session.flush() + artifact_sets[key] = artifact_set + + capability_manifests = { + item.capability: item for item in ManifestRegistry().capabilities() + } + contracts: dict[str, CapabilityContract] = {} + for capability_key in ("rag.embedding", "vision.embedding", "speech.transcription"): + manifest = capability_manifests[capability_key] + capability = Capability(key=capability_key, description=manifest.description) + session.add(capability) + session.flush() + contract = CapabilityContract( + capability_id=capability.id, + version=manifest.version, + input_schema=manifest.input_schema, + output_schema=manifest.output_schema, + contract=manifest.model_dump(mode="json"), + upgrade_class=manifest.upgrade_class.value, + ) + session.add(contract) + session.flush() + contracts[capability_key] = contract + ids[f"contract:{capability_key}"] = contract.id + + profiles: list[RuntimeProfile] = [] + for profile_ix, key in enumerate(("model-a", "model-b")): + profile = RuntimeProfile( + name=f"profile-{key}", + runtime_type="vllm", + runtime_version="1.0", + artifact_sha256=("d" if profile_ix == 0 else "e") * 64, + fingerprint=("f" if profile_ix == 0 else "1") * 64, + ) + session.add(profile) + session.flush() + profiles.append(profile) + + deployments: dict[str, CapabilityDeployment] = {} + deployment_specs = ( + ("alpha-rag-prod", "model-a", "rag.embedding", profiles[0], True, "stable", "ready", 1), + ("alpha-rag-lab", "model-a", "rag.embedding", profiles[0], False, "lab", "lab_ready", 2), + ("alpha-vision", "model-a", "vision.embedding", profiles[0], False, "lab", "lab_ready", 3), + ("beta-rag", "model-b", "rag.embedding", profiles[1], False, "lab", "lab_ready", 4), + ("beta-speech", "model-b", "speech.transcription", profiles[1], False, "lab", "lab_ready", 5), + ) + for name, model_key, capability_key, profile, production, channel, status, seconds in deployment_specs: + deployment = CapabilityDeployment( + capability_contract_id=contracts[capability_key].id, + deployment_candidate_id=uuid4(), + artifact_set_id=artifact_sets[model_key].id, + runtime_profile_id=profile.id, + compute_node_id=uuid4(), + accelerator_id=uuid4(), + channel=channel, + status=status, + production=production, + health_status="ready_on_demand", + config_fingerprint=(f"{seconds:02x}" * 32), + provenance={"fixture": name}, + created_at=now.replace(second=seconds), + ) + session.add(deployment) + session.flush() + deployments[name] = deployment + ids[f"deployment:{name}"] = deployment.id + ids[f"node:{name}"] = deployment.compute_node_id + + first_rag = deployments["alpha-rag-prod"] + vision = deployments["alpha-vision"] + speech = deployments["beta-speech"] + for deployment in deployments.values(): + session.add( + CapabilityResourceEnvelope( + capability_deployment_id=deployment.id, + runtime_probe_id=uuid4(), + accelerator_kind="gpu", + accelerator_uuid=f"gpu-{deployment.id}", + environment_fingerprint="a" * 64, + concurrency=1, + batch_size=1, + max_sequence_length=512, + baseline_vram_bytes=100, + resident_vram_bytes=200, + peak_vram_bytes=300, + required_vram_bytes=400, + cold_load_time_ms=10.0, + inference_latency_ms=20.0, + evidence={"fixture": True}, + immutable_at=now, + ) + ) + for ix, (deployment, status) in enumerate( + ((first_rag, "passed"), (first_rag, "failed"), (vision, "passed"), (speech, "passed")) + ): + evaluation = CapabilityEvaluationRun( + suite_id=uuid4(), + capability_deployment_id=deployment.id, + status=status, + metric_values={}, + case_results=[], + resource_metrics={}, + environment_fingerprint="b" * 64, + evidence_digest=f"{ix:064x}", + evidence={"fixture": True}, + started_at=now.replace(second=ix), + completed_at=now.replace(second=ix), + # Equal times exercise the old ORDER BY created_at DESC LIMIT 1 behavior. + created_at=now if deployment is first_rag else now.replace(second=ix), + ) + session.add(evaluation) + session.flush() + ids[f"evaluation:{ix}"] = evaluation.id + + projects = [ + Project(key="alpha-project", name="Alpha", description="active consumer"), + Project(key="beta-project", name="Beta", description="deprecated consumer"), + Project(key="gamma-project", name="Gamma", description="second active consumer"), + ] + session.add_all(projects) + session.flush() + project_by_key = {item.key: item for item in projects} + bindings = [ + ProjectBinding( + project_id=project_by_key["alpha-project"].id, + capability_contract_id=contracts["rag.embedding"].id, + channel="stable", + priority="interactive", + ), + ProjectBinding( + project_id=project_by_key["beta-project"].id, + capability_contract_id=contracts["rag.embedding"].id, + channel="lab", + priority="background", + deprecated_at=now, + ), + ProjectBinding( + project_id=project_by_key["gamma-project"].id, + capability_contract_id=contracts["vision.embedding"].id, + channel="lab", + priority="interactive", + ), + ] + session.add_all(bindings) + session.flush() + session.add_all( + [ + ServiceClient( + name="alpha-client", + status="active", + allowed_capabilities=["rag.embedding"], + project_binding_id=bindings[0].id, + ), + ServiceClient( + name="deprecated-client", + status="active", + allowed_capabilities=["rag.embedding"], + project_binding_id=bindings[1].id, + ), + ServiceClient( + name="inactive-client", + status="disabled", + allowed_capabilities=["vision.embedding"], + project_binding_id=bindings[2].id, + ), + ] + ) + fit_evidence = [ + ProjectFitEvidence( + project_binding_id=bindings[0].id, + capability_deployment_id=first_rag.id, + environment="shadow", + recommendation="keep_lab", + evidence_class="project_fit", + engineering_integration="partial", + production_validation="not_validated", + production_action="no_change", + case_count=1, + metric_values={}, + critical_errors=0, + blockers=[], + evidence_digest="c" * 64, + evidence={"fixture": True}, + ), + ProjectFitEvidence( + project_binding_id=bindings[2].id, + capability_deployment_id=vision.id, + environment="lab", + recommendation="keep_lab", + evidence_class="project_fit", + engineering_integration="partial", + production_validation="not_validated", + production_action="no_change", + case_count=1, + metric_values={}, + critical_errors=0, + blockers=[], + evidence_digest="d" * 64, + evidence={"fixture": True}, + ), + ] + session.add_all(fit_evidence) + session.flush() + ids["fit:rag"] = fit_evidence[0].id + ids["fit:vision"] = fit_evidence[1].id + session.add( + ResidencyAllocation( + capability_deployment_id=first_rag.id, + compute_node_id=first_rag.compute_node_id, + accelerator_id=first_rag.accelerator_id, + state="cold", + last_used_at=now, + ) + ) + session.commit() + session.close() + return engine, Session(engine), ids + + +def _count_selects[T](engine: Engine, call: Callable[[], T]) -> tuple[T, int]: + statements: list[str] = [] + + def count( + _conn: Any, + _cursor: Any, + statement: str, + _params: Any, + _context: Any, + _many: bool, + ) -> None: + if statement.lstrip().upper().startswith("SELECT"): + statements.append(statement) + + event.listen(engine, "before_cursor_execute", count) + try: + response = call() + finally: + event.remove(engine, "before_cursor_execute", count) + return response, len(statements) + + +def test_capability_estate_uses_batched_reads_for_deployed_capabilities() -> None: + engine, session, ids = _catalog_session() + try: + latest_rag_evaluation = session.scalar( + select(CapabilityEvaluationRun) + .where( + CapabilityEvaluationRun.capability_deployment_id + == ids["deployment:alpha-rag-prod"] + ) + .order_by(CapabilityEvaluationRun.created_at.desc()) + .limit(1) + ) + assert latest_rag_evaluation is not None + result, reads = _count_selects( + engine, + lambda: capability_estate(ManifestRegistry(), session), + ) + + assert reads == 8 + deployment_facts = { + "rag.embedding": ( + "alpha-rag-prod", + "Alpha model", + "a" * 40, + "ready", + latest_rag_evaluation.status, + ), + "vision.embedding": ( + "alpha-vision", + "Alpha model", + "a" * 40, + "lab_ready", + "passed", + ), + "speech.transcription": ( + "beta-speech", + "Beta model", + "b" * 40, + "lab_ready", + "passed", + ), + } + expected = [] + for manifest in ManifestRegistry().capabilities(): + facts = deployment_facts.get(manifest.capability) + deployment_name, model_name, revision, state, evaluation_state = facts or ( + None, + None, + None, + "blocked" if manifest.estate.stability == "blocked" else "not_deployed", + "not_evaluated", + ) + expected.append( + CapabilityEstateResponse( + capability=manifest.capability, + version=manifest.version, + category=manifest.estate.category, + purpose=manifest.estate.purpose, + declared_stability=manifest.estate.stability, + operational_state=state, + current_deployment_id=( + ids[f"deployment:{deployment_name}"] if deployment_name else None + ), + model=model_name, + revision=revision, + runtime="vllm" if deployment_name else None, + node=str(ids[f"node:{deployment_name}"]) if deployment_name else None, + resource_class=manifest.estate.resource_class, + measured_required_vram_bytes=400 if deployment_name else None, + consumers=manifest.estate.consumers, + privacy_class=manifest.privacy.classification, + evaluation_type=manifest.estate.evaluation_type, + evaluation_state=evaluation_state, + ).model_dump(mode="json") + ) + assert [item.model_dump(mode="json") for item in result] == expected + finally: + session.close() + engine.dispose() + + +def test_installation_rationale_uses_batched_reads_across_models_and_dependencies() -> None: + engine, session, ids = _catalog_session() + try: + result, reads = _count_selects(engine, lambda: model_installation_rationale(session)) + + assert reads == 9 + expected_alpha_dependencies = [ + InstallationDependencyResponse( + capability="rag.embedding", + version=1, + deployment_id=ids["deployment:alpha-rag-prod"], + channel="stable", + production=True, + project_consumers=["alpha-project"], + active_project_consumers=["alpha-project", "beta-project"], + project_fit_evidence_ids=[ids["fit:rag"]], + evaluation_run_ids=[ids["evaluation:0"], ids["evaluation:1"]], + last_used_at=datetime.fromisoformat("2026-01-01T00:00:00"), + ), + InstallationDependencyResponse( + capability="rag.embedding", + version=1, + deployment_id=ids["deployment:alpha-rag-lab"], + channel="lab", + production=False, + project_consumers=["alpha-project"], + active_project_consumers=["alpha-project", "beta-project"], + project_fit_evidence_ids=[ids["fit:rag"]], + evaluation_run_ids=[], + last_used_at=None, + ), + InstallationDependencyResponse( + capability="vision.embedding", + version=1, + deployment_id=ids["deployment:alpha-vision"], + channel="lab", + production=False, + project_consumers=["gamma-project"], + active_project_consumers=[], + project_fit_evidence_ids=[ids["fit:vision"]], + evaluation_run_ids=[ids["evaluation:2"]], + last_used_at=None, + ), + ] + expected_beta_dependencies = [ + InstallationDependencyResponse( + capability="rag.embedding", + version=1, + deployment_id=ids["deployment:beta-rag"], + channel="lab", + production=False, + project_consumers=["alpha-project"], + active_project_consumers=["alpha-project", "beta-project"], + project_fit_evidence_ids=[ids["fit:rag"]], + evaluation_run_ids=[], + last_used_at=None, + ), + InstallationDependencyResponse( + capability="speech.transcription", + version=1, + deployment_id=ids["deployment:beta-speech"], + channel="lab", + production=False, + project_consumers=[], + active_project_consumers=[], + project_fit_evidence_ids=[], + evaluation_run_ids=[ids["evaluation:3"]], + last_used_at=None, + ), + ] + expected = [ + ModelInstallationRationaleResponse( + model_id=ids["model-a"], + display_name="Alpha model", + upstream_source="itworx/model-a", + installed=True, + installed_bytes=1000, + dependencies=expected_alpha_dependencies, + can_delete=False, + deletion_blockers=[ + "capability_deployment_dependency", + "production_dependency", + ], + ), + ModelInstallationRationaleResponse( + model_id=ids["model-b"], + display_name="Beta model", + upstream_source="itworx/model-b", + installed=True, + installed_bytes=2000, + dependencies=expected_beta_dependencies, + can_delete=False, + deletion_blockers=["capability_deployment_dependency"], + ), + ModelInstallationRationaleResponse( + model_id=ids["model-z"], + display_name="Zulu model", + upstream_source="itworx/model-z", + installed=False, + installed_bytes=0, + dependencies=[], + can_delete=False, + deletion_blockers=[], + ), + ] + assert [item.model_dump(mode="json") for item in result] == [ + item.model_dump(mode="json") for item in expected + ] + finally: + session.close() + engine.dispose() diff --git a/backend/tests/test_concurrency_m16.py b/backend/tests/test_concurrency_m16.py index 019d50e..666810d 100644 --- a/backend/tests/test_concurrency_m16.py +++ b/backend/tests/test_concurrency_m16.py @@ -12,6 +12,7 @@ the live chaos evidence; what these tests pin down is that the *code* claims a s from __future__ import annotations +import importlib import os import threading import uuid @@ -22,9 +23,10 @@ from pathlib import Path from typing import Any import pytest +from alembic.config import Config from pydantic import SecretStr from sqlalchemy import create_engine, event, func, select, text -from sqlalchemy.engine import Engine +from sqlalchemy.engine import Engine, make_url from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.orm import Session @@ -151,7 +153,9 @@ def test_a_single_use_enrolment_token_survives_a_concurrent_storm( with Session(engine) as session: assert session.scalar(select(func.count()).select_from(ComputeNode)) == 1 active = session.scalar( - select(func.count()).select_from(NodeCredential).where(NodeCredential.revoked_at.is_(None)) + select(func.count()) + .select_from(NodeCredential) + .where(NodeCredential.revoked_at.is_(None)) ) assert active == 1 enrollment = session.scalar(select(NodeEnrollment)) @@ -168,7 +172,7 @@ def test_managed_postgresql_claim_and_revocation_linearize_on_the_row_lock() -> """Managed gate for the PostgreSQL row-lock semantics SQLite cannot reproduce. Set MODELFORGE_TEST_POSTGRES_URL to an isolated, disposable PostgreSQL database. The test - creates and removes one UUID-named schema, pauses enrolment after its conditional claim has + creates and removes one UUID-named database, pauses enrolment after its conditional claim has acquired the row lock, then proves a concurrent revocation loses after the claim commits. """ @@ -180,15 +184,17 @@ def test_managed_postgresql_claim_and_revocation_linearize_on_the_row_lock() -> if database_url.startswith("postgresql://"): database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1) - schema = f"test_enrollment_linearization_{uuid.uuid4().hex}" - admin_engine = create_engine(database_url, pool_pre_ping=True) - with admin_engine.begin() as connection: - connection.execute(text(f'CREATE SCHEMA "{schema}"')) - engine = create_engine( - database_url, - pool_pre_ping=True, - connect_args={"options": f"-csearch_path={schema}"}, - ) + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + if not owner_url or not runtime_url: + pytest.fail("managed enrollment test requires owner and runtime URLs") + database = f"test_enrollment_linearization_{uuid.uuid4().hex}" + admin_engine = create_engine(database_url, isolation_level="AUTOCOMMIT") + with admin_engine.connect() as connection: + connection.execute(text(f'CREATE DATABASE "{database}" OWNER modelforge')) + # The canonical audit function deliberately targets public tables. A search_path-only + # fixture would read a private checkpoint but append to another schema's audit chain. + engine = create_engine(make_url(runtime_url).set(database=database), pool_pre_ping=True) claim_written = threading.Event() allow_claim_commit = threading.Event() revoke_update_started = threading.Event() @@ -207,7 +213,18 @@ def test_managed_postgresql_claim_and_revocation_linearize_on_the_row_lock() -> revoke_update_started.set() try: - Base.metadata.create_all(engine) + migration = Config() + migration.set_main_option( + "script_location", str(Path(__file__).resolve().parents[1] / "alembic") + ) + migration.set_main_option( + "sqlalchemy.url", + make_url(owner_url) + .set(database=database) + .render_as_string(hide_password=False) + .replace("%", "%%"), + ) + importlib.import_module("alembic.command").upgrade(migration, "head") with Session(engine) as session: created = NodeAgentService(session, settings()).create_enrollment( EnrollmentTokenCreate(display_name="PostgreSQL row-lock target") @@ -293,8 +310,8 @@ def test_managed_postgresql_claim_and_revocation_linearize_on_the_row_lock() -> finally: allow_claim_commit.set() engine.dispose() - with admin_engine.begin() as connection: - connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')) + with admin_engine.connect() as connection: + connection.execute(text(f'DROP DATABASE IF EXISTS "{database}"')) admin_engine.dispose() @@ -360,9 +377,7 @@ def test_re_enrolment_of_one_identity_under_load_keeps_one_active_credential( with Session(engine) as session: service = NodeAgentService(session, settings()) tokens = [ - service.create_enrollment( - EnrollmentTokenCreate(display_name="GPU Node") - ).enrollment_token + service.create_enrollment(EnrollmentTokenCreate(display_name="GPU Node")).enrollment_token for _ in range(rounds) ] @@ -421,9 +436,7 @@ def test_the_same_idempotency_key_in_parallel_produces_one_logical_operation( def attempt(_index: int) -> str: with Session(engine) as session: - existing = session.scalar( - select(ServingJob).where(ServingJob.idempotency_key == key) - ) + existing = session.scalar(select(ServingJob).where(ServingJob.idempotency_key == key)) if existing is not None: return "REUSED" session.add( @@ -478,9 +491,7 @@ def test_concurrent_audit_appends_form_one_strict_chain(engine: Engine) -> None: assert len(events) == attempts assert audit_chain_violations(events, session.get(AuditChainHead, 1)) == [] result = next( - item - for item in check_invariants(session).results - if item.key == "audit_chain_intact" + item for item in check_invariants(session).results if item.key == "audit_chain_intact" ) assert result.status is InvariantStatus.HOLDS diff --git a/backend/tests/test_configuration_catalog.py b/backend/tests/test_configuration_catalog.py new file mode 100644 index 0000000..f82ee62 --- /dev/null +++ b/backend/tests/test_configuration_catalog.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient +from pydantic import SecretStr +from pytest import MonkeyPatch +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.db import get_session +from modelforge_api.domain.configuration_policy import SERVING_OVERRIDE_KEYS +from modelforge_api.domain.configuration_reference import SETTING_DOCS, Sensitivity +from modelforge_api.main import app +from modelforge_api.persistence.models import Base +from modelforge_api.services.configuration import build_configuration_catalog +from modelforge_api.settings import Settings, get_settings + +OPERATOR_TOKEN = "configuration-test-operator-token" # noqa: S105 - synthetic test credential +DATABASE_SECRET = ( + "postgresql+psycopg://runtime:do-not-leak@db:5432/modelforge" # noqa: S105 +) +HF_SECRET = "hf_do-not-leak" # noqa: S105 - synthetic test credential +REDIS_PASSWORD = "redis-do-not-leak" # noqa: S105 - synthetic test credential +REDIS_URL = f"redis://cache-user:{REDIS_PASSWORD}@redis.internal:6379/0" +ALERT_WEBHOOK_SECRET = "https://hooks.example.test/token-that-must-not-leak" # noqa: S105 + + +def _settings() -> Settings: + return Settings( + _env_file=None, + env="test", + operator_api_key=SecretStr(OPERATOR_TOKEN), + database_url=DATABASE_SECRET, + hf_token=SecretStr(HF_SECRET), + redis_url=REDIS_URL, + alert_webhook_url=SecretStr(ALERT_WEBHOOK_SECRET), + log_level="WARNING", + ) + + +def test_catalog_covers_every_runtime_setting_and_never_serializes_secrets() -> None: + catalog = build_configuration_catalog(_settings()) + + by_key = {entry.key: entry for entry in catalog.settings} + assert set(by_key) == set(SETTING_DOCS) + assert catalog.read_only is False + assert {entry.key for entry in catalog.settings if entry.editable} == SERVING_OVERRIDE_KEYS + + secret_keys = { + key + for key, documentation in SETTING_DOCS.items() + if documentation.sensitivity is Sensitivity.SECRET + } + assert secret_keys + assert all(by_key[key].value is None for key in secret_keys) + assert by_key["database_url"].configured is True + assert by_key["hf_token"].configured is True + assert by_key["operator_api_key"].configured is True + assert by_key["alert_webhook_url"].configured is True + + payload = catalog.model_dump_json() + assert DATABASE_SECRET not in payload + assert HF_SECRET not in payload + assert OPERATOR_TOKEN not in payload + assert REDIS_PASSWORD not in payload + assert ALERT_WEBHOOK_SECRET not in payload + + +def test_catalog_reports_the_actual_pydantic_source_at_each_priority( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + dotenv = tmp_path / "model-forge.env" + dotenv.write_text( + "MODELFORGE_LOG_LEVEL=INFO\n" + "MODELFORGE_GATEWAY_MAX_BATCH_SIZE=12\n" + "MODELFORGE_GATEWAY_REQUEST_TIMEOUT_SECONDS=100\n" + "MODELFORGE_HF_TOKEN=hf_dotenv_secret\n" + "MODELFORGE_ALERT_WEBHOOK_ALLOWED_HOSTS=\n", + encoding="utf-8", + ) + monkeypatch.setenv("MODELFORGE_LOG_LEVEL", "WARNING") + monkeypatch.setenv("MODELFORGE_GATEWAY_REQUEST_TIMEOUT_SECONDS", "111") + monkeypatch.setenv("MODELFORGE_HF_TOKEN", "hf_process_secret") + + settings = Settings( + _env_file=dotenv, + env="test", + gateway_request_timeout_seconds=123, + ) + catalog = build_configuration_catalog(settings) + by_key = {entry.key: entry for entry in catalog.settings} + + assert settings.log_level == "WARNING" + assert by_key["log_level"].source == "process_environment" + assert by_key["log_level"].value == "WARNING" + assert settings.gateway_max_batch_size == 12 + assert by_key["gateway_max_batch_size"].source == "dotenv" + assert by_key["gateway_max_batch_size"].value == 12 + assert settings.gateway_request_timeout_seconds == 123 + assert by_key["gateway_request_timeout_seconds"].source == "initialization" + assert by_key["cors_origins"].source == "pydantic_default" + assert by_key["hf_token"].source == "process_environment" + assert by_key["hf_token"].value is None + assert by_key["alert_webhook_allowed_hosts"].source == "dotenv" + assert by_key["alert_webhook_allowed_hosts"].value == "" + assert settings.setting_source("hf_token") == "process_environment" + assert "hf_process_secret" not in repr(settings._setting_sources) + assert "hf_dotenv_secret" not in repr(settings._setting_sources) + assert "hf_process_secret" not in catalog.model_dump_json() + assert "hf_dotenv_secret" not in catalog.model_dump_json() + + effective = settings.model_copy(update={"gateway_max_batch_size": 24}) + overridden = build_configuration_catalog(effective, overrides={"gateway_max_batch_size": 24}) + overridden_setting = next(item for item in overridden.settings if item.key == "gateway_max_batch_size") + assert overridden_setting.source == "operator_override" + assert overridden_setting.value == 24 + + monkeypatch.setenv("MODELFORGE_LOG_LEVEL", "") + empty_environment_value = Settings(_env_file=dotenv, env="test") + empty_catalog = build_configuration_catalog(empty_environment_value) + empty_log_level = next(item for item in empty_catalog.settings if item.key == "log_level") + assert empty_environment_value.log_level == "" + assert empty_log_level.source == "process_environment" + assert empty_log_level.value == "" + + +def test_catalog_reports_a_secrets_file_source_without_returning_its_value( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + secret_dir = tmp_path / "settings-secrets" + secret_dir.mkdir() + (secret_dir / "MODELFORGE_LOG_LEVEL").write_text("ERROR", encoding="utf-8") + monkeypatch.delenv("MODELFORGE_LOG_LEVEL", raising=False) + + settings = Settings(_env_file=None, _secrets_dir=secret_dir, env="test") + catalog = build_configuration_catalog(settings) + log_level = next(item for item in catalog.settings if item.key == "log_level") + + assert settings.log_level == "ERROR" + assert log_level.source == "secrets_file" + assert log_level.value == "ERROR" + + +def test_settings_copy_and_mutation_never_keep_a_stale_field_source( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("MODELFORGE_LOG_LEVEL", "WARNING") + settings = Settings(_env_file=None, env="test") + + unchanged_copy = settings.model_copy() + assert unchanged_copy.setting_source("log_level") == "process_environment" + + copied_update = settings.model_copy(update={"log_level": "DEBUG"}) + assert copied_update.setting_source("log_level") == "runtime_update" + assert settings.setting_source("log_level") == "process_environment" + + settings.log_level = "ERROR" + assert settings.setting_source("log_level") == "runtime_update" + + reconstructed = Settings.model_validate(settings.model_dump()) + assert reconstructed.setting_source("log_level") == "initialization" + + +def test_catalog_exposes_the_queue_timeout_cross_setting_validation() -> None: + catalog = build_configuration_catalog(_settings()) + by_key = {entry.key: entry for entry in catalog.settings} + + assert by_key["gateway_queue_timeout_seconds"].validation_rules["less_than_setting"] == ( + "gateway_request_timeout_seconds" + ) + + +def test_configuration_catalog_endpoint_requires_control_plane_auth_and_stays_secret_safe() -> None: + settings = _settings() + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + session = Session(engine) + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_settings] = lambda: settings + client = TestClient(app) + try: + anonymous = client.get("/api/v1/configuration/settings") + assert anonymous.status_code == 401 + + response = client.get( + "/api/v1/configuration/settings", + headers={"X-ModelForge-Admin-Token": OPERATOR_TOKEN}, + ) + assert response.status_code == 200 + body = response.text + assert DATABASE_SECRET not in body + assert HF_SECRET not in body + assert OPERATOR_TOKEN not in body + assert REDIS_PASSWORD not in body + payload = response.json() + assert payload["environment"] == "test" + assert payload["read_only"] is False + assert {item["key"] for item in payload["settings"]} == set(SETTING_DOCS) + finally: + app.dependency_overrides.clear() + session.close() + engine.dispose() diff --git a/backend/tests/test_configuration_changes.py b/backend/tests/test_configuration_changes.py new file mode 100644 index 0000000..5eae1e1 --- /dev/null +++ b/backend/tests/test_configuration_changes.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.authorization import ( + Principal, + PrincipalRole, + authenticate_operator_principal, +) +from modelforge_api.db import get_session +from modelforge_api.domain.configuration import ( + ConfigurationApply, + ConfigurationChange, + ConfigurationImport, +) +from modelforge_api.domain.serving import EmbeddingInvokeRequest +from modelforge_api.main import app +from modelforge_api.persistence.models import ( + AuditEvent, + Base, + ConfigurationRevision, +) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.configuration_changes import ( + ConfigurationError, + ConfigurationService, + configuration_state, +) +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.serving import ServingError, ServingService +from modelforge_api.services.transient_payloads import MemoryPayloadStore +from modelforge_api.settings import Settings, get_settings + +TOKEN = "rc01-synthetic-administrator" # noqa: S105 + + +@pytest.fixture +def session() -> Iterator[Session]: + engine = create_engine( + "sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(engine) + with Session(engine) as database: + yield database + engine.dispose() + + +def settings() -> Settings: + return Settings( + _env_file=None, + env="test", + operator_api_key=SecretStr(TOKEN), + config_root=Path(__file__).resolve().parents[2] / "config", + ) + + +def service(session: Session) -> ConfigurationService: + return ConfigurationService( + session, settings(), AuditContext("operator", "reviewer", "config-test") + ) + + +def reviewed_apply( + subject: ConfigurationService, change: ConfigurationChange +) -> ConfigurationApply: + preview = subject.preview(change) + return ConfigurationApply( + **change.model_dump(), preview_digest=preview.preview_digest, confirm=True + ) + + +def test_preview_apply_rollback_and_real_serving_consumer(session: Session) -> None: + subject = service(session) + old_request = ServingService( + session, settings(), ManifestRegistry(settings().config_root), MemoryPayloadStore() + ) + assert old_request._validate_inputs(EmbeddingInvokeRequest(input=["one", "two"])) == ["one", "two"] + change = ConfigurationChange(expected_revision=0, changes={"gateway_max_batch_size": 1}) + reviewed = reviewed_apply(subject, change) + assert configuration_state(session) == (0, {}) + assert session.scalar(select(func.count()).select_from(AuditEvent)) == 0 + result = subject.apply(reviewed) + assert result.status == "accepted_for_new_requests" + session.expire_all() + assert configuration_state(session) == (1, {"gateway_max_batch_size": 1}) + request = EmbeddingInvokeRequest(input=["one", "two"]) + assert old_request._validate_inputs(request) == ["one", "two"] + new_request = ServingService( + session, settings(), ManifestRegistry(settings().config_root), MemoryPayloadStore() + ) + with pytest.raises(ServingError, match="configured maximum"): + new_request._validate_inputs(request) + event = session.scalar(select(AuditEvent).where(AuditEvent.action == "configuration.applied")) + assert ( + event is not None and event.actor_id == "reviewer" and event.correlation_id == "config-test" + ) + assert TOKEN not in str(event.details) + rollback = subject.rollback_change(0, 1) + subject.apply(reviewed_apply(subject, rollback)) + assert configuration_state(session) == (2, {}) + assert [row.revision for row in subject.history()] == [2, 1] + + +def test_preview_reports_the_exact_resolved_source_across_override_changes( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("MODELFORGE_GATEWAY_MAX_BATCH_SIZE", "16") + base_settings = Settings(_env_file=None, env="test") + subject = ConfigurationService( + session, base_settings, AuditContext("operator", "reviewer", "config-source-test") + ) + + add_override = ConfigurationChange(expected_revision=0, changes={"gateway_max_batch_size": 12}) + add_preview = subject.preview(add_override) + assert [ + (item.source_before, item.source_after) for item in add_preview.differences + ] == [("process_environment", "operator_override")] + subject.apply(reviewed_apply(subject, add_override)) + + remove_override = ConfigurationChange(expected_revision=1, changes={"gateway_max_batch_size": None}) + remove_preview = subject.preview(remove_override) + assert [ + (item.source_before, item.source_after) for item in remove_preview.differences + ] == [("operator_override", "process_environment")] + + +def test_preview_cannot_be_reused_or_changed_and_baseline_drift_conflicts(session: Session) -> None: + subject = service(session) + reviewed = reviewed_apply( + subject, ConfigurationChange(expected_revision=0, changes={"gateway_max_batch_size": 2}) + ) + tampered = reviewed.model_copy(update={"changes": {"gateway_max_batch_size": 3}}) + with pytest.raises(ConfigurationError, match="proposal changed"): + subject.apply(tampered) + changed_baseline = ConfigurationService( + session, + settings().model_copy(update={"gateway_request_timeout_seconds": 90}), + subject.context, + ) + with pytest.raises(ConfigurationError, match="proposal changed"): + changed_baseline.apply(reviewed) + subject.apply(reviewed) + with pytest.raises(ConfigurationError, match="Configuration changed"): + subject.apply(reviewed) + assert len(subject.history()) == 1 + + +def test_audit_failure_rolls_back_pointer_and_history( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + subject = service(session) + reviewed = reviewed_apply( + subject, ConfigurationChange(expected_revision=0, changes={"gateway_max_batch_size": 2}) + ) + + def fail(*args: object, **kwargs: object) -> None: + raise RuntimeError("audit unavailable") + + monkeypatch.setattr(AuditWriter, "write", fail) + with pytest.raises(RuntimeError, match="audit unavailable"): + subject.apply(reviewed) + assert configuration_state(session) == (0, {}) + assert session.scalar(select(func.count()).select_from(ConfigurationRevision)) == 0 + + +@pytest.mark.parametrize( + "changes", + [ + {"operator_api_key": 10}, + {"allow_remote_code": 1}, + {"scheduler_safety_reserve_bytes": 1}, + {"gateway_max_batch_size": 0}, + {"gateway_max_batch_size": 2.5}, + {"gateway_queue_timeout_seconds": 50}, + {"gateway_max_input_characters": 10**310}, + {"gateway_max_batch_size": float("inf")}, + ], +) +def test_unsafe_and_invalid_changes_fail_before_persistence( + session: Session, changes: dict[str, int | float] +) -> None: + with pytest.raises(ConfigurationError): + service(session).preview(ConfigurationChange(expected_revision=0, changes=changes)) + assert configuration_state(session) == (0, {}) + + +def test_export_import_compatibility_and_replacement(session: Session) -> None: + subject = service(session) + subject.apply( + reviewed_apply( + subject, ConfigurationChange(expected_revision=0, changes={"gateway_max_batch_size": 2}) + ) + ) + exported = subject.export() + assert TOKEN not in exported.model_dump_json() + assert "database_url" in exported.secret_slots + assert "postgresql" not in exported.model_dump_json() + exported.overrides = {"gateway_max_input_characters": 300} + change = subject.import_change(ConfigurationImport(expected_revision=1, document=exported)) + assert change.changes == {"gateway_max_batch_size": None, "gateway_max_input_characters": 300} + subject.apply(reviewed_apply(subject, change)) + assert configuration_state(session)[1] == exported.overrides + for replacement in ( + {"format": "unknown"}, + {"source_version": "2.0.0"}, + {"secret_slots": []}, + {"overrides": {"database_url": 1}}, + ): + incompatible = exported.model_copy(update=replacement) + with pytest.raises(ConfigurationError): + subject.import_change(ConfigurationImport(expected_revision=2, document=incompatible)) + + +def test_api_authorization_validation_redaction_and_audited_apply(session: Session) -> None: + app.dependency_overrides[get_settings] = settings + app.dependency_overrides[get_session] = lambda: session + client = TestClient(app) + headers = {"X-ModelForge-Admin-Token": TOKEN} + change = {"expected_revision": 0, "changes": {"gateway_max_batch_size": 2}} + try: + assert client.post("/api/v1/configuration/preview", json=change).status_code == 401 + for role in (PrincipalRole.VIEWER, PrincipalRole.OPERATOR): + app.dependency_overrides[authenticate_operator_principal] = lambda role=role: Principal( + "limited", role, "test" + ) + assert ( + client.post("/api/v1/configuration/apply", headers=headers, json={}).status_code + == 403 + ) + app.dependency_overrides.pop(authenticate_operator_principal) + preview = client.post("/api/v1/configuration/preview", headers=headers, json=change) + assert preview.status_code == 200 + body = {**change, "preview_digest": preview.json()["preview_digest"], "confirm": True} + assert ( + client.post("/api/v1/configuration/apply", headers=headers, json=body).status_code + == 200 + ) + catalog = client.get("/api/v1/configuration/settings", headers=headers).json() + entry = next(row for row in catalog["settings"] if row["key"] == "gateway_max_batch_size") + assert (catalog["revision"], entry["value"], entry["source"]) == (1, 2, "operator_override") + log_level = next(row for row in catalog["settings"] if row["key"] == "log_level") + assert log_level["source"] == "pydantic_default" + for malicious in ( + {"operator_api_key": "secret-must-not-leak"}, + {"secret-must-not-leak": "secret-must-not-leak"}, + ): + rejected = client.post( + "/api/v1/configuration/preview", + headers=headers, + json={"expected_revision": 1, "changes": malicious}, + ) + assert rejected.status_code == 422 + assert "secret-must-not-leak" not in rejected.text + finally: + app.dependency_overrides.clear() + + +def test_configuration_state_survives_a_new_connection(tmp_path: Path) -> None: + database_url = f"sqlite:///{tmp_path / 'config.db'}" + first_engine = create_engine(database_url) + Base.metadata.create_all(first_engine) + with Session(first_engine) as first: + subject = service(first) + subject.apply( + reviewed_apply( + subject, + ConfigurationChange(expected_revision=0, changes={"gateway_max_batch_size": 2}), + ) + ) + first_engine.dispose() + second_engine = create_engine(database_url) + with Session(second_engine) as second: + assert configuration_state(second) == (1, {"gateway_max_batch_size": 2}) + second_engine.dispose() diff --git a/backend/tests/test_configuration_postgres.py b/backend/tests/test_configuration_postgres.py new file mode 100644 index 0000000..cd86dab --- /dev/null +++ b/backend/tests/test_configuration_postgres.py @@ -0,0 +1,254 @@ +"""Real PostgreSQL migration, backup recovery, role and concurrent-write qualification.""" + +from __future__ import annotations + +import importlib +import os +import re +import subprocess +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest +from alembic.config import Config +from sqlalchemy import create_engine, select, text +from sqlalchemy.engine import make_url +from sqlalchemy.exc import DBAPIError +from sqlalchemy.orm import Session + +from modelforge_api.domain.configuration import ConfigurationApply, ConfigurationChange +from modelforge_api.domain.release import TARGET_SCHEMA_REVISION +from modelforge_api.persistence.models import AuditEvent, ConfigurationRevision, Project +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.configuration_changes import ( + ConfigurationError, + ConfigurationService, + configuration_state, +) +from modelforge_api.services.recovery_fingerprint import ( + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from modelforge_api.settings import Settings + + +def _database_tool(tool: str, arguments: list[str], password: str | None, archive: Path) -> None: + container = os.getenv("MODELFORGE_TEST_POSTGRES_CONTAINER") + environment = dict(os.environ) + if password: + environment["PGPASSWORD"] = password + command = [tool, *arguments] + if container: + assert re.fullmatch(r"[0-9a-f]{12,64}", container) + command = ["docker", "exec", "-i", "-e", "PGPASSWORD", container, *command] + with archive.open("wb" if tool == "pg_dump" else "rb") as handle: + result = subprocess.run( # noqa: S603 - bounded test tool and UUID database arguments + command, + env=environment, + timeout=120, + check=False, + stdin=handle if tool == "pg_restore" else subprocess.DEVNULL, + stdout=handle if tool == "pg_dump" else subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + assert result.returncode == 0, f"{tool} failed during isolated configuration qualification" + + +def test_postgres_configuration_upgrade_backup_recovery_and_concurrency( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + admin_url = os.getenv("MODELFORGE_TEST_POSTGRES_URL") + if not admin_url: + pytest.skip("requires managed MODELFORGE_TEST_POSTGRES_URL") + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + assert owner_url and runtime_url, ( + "managed qualification requires separate owner and runtime roles" + ) + database = f"test_config_{uuid.uuid4().hex}" + restored_database = f"test_config_restore_{uuid.uuid4().hex}" + admin = create_engine(admin_url, isolation_level="AUTOCOMMIT") + owner = make_url(owner_url).set(database=database) + runtime = create_engine(make_url(runtime_url).set(database=database)) + restored_runtime = create_engine(make_url(runtime_url).set(database=restored_database)) + migration = Config() + migration.set_main_option( + "script_location", str(Path(__file__).resolve().parents[1] / "alembic") + ) + migration.set_main_option( + "sqlalchemy.url", owner.render_as_string(hide_password=False).replace("%", "%%") + ) + alembic = importlib.import_module("alembic.command") + with admin.connect() as connection: + connection.execute(text(f'CREATE DATABASE "{database}" OWNER modelforge')) + connection.execute(text(f'CREATE DATABASE "{restored_database}" OWNER modelforge')) + try: + alembic.upgrade(migration, "20260830_0024") + with Session(runtime) as session: + session.add( + Project( + key="existing-consumer", + name="Existing consumer", + description="Prior-release fixture", + ) + ) + AuditWriter(session, "operator", "prior-release").write( + "qualification.baseline", "project", None, {"source_schema": "20260830_0024"} + ) + session.commit() + baseline = session.scalar(select(AuditEvent.event_hash)) + archive = tmp_path / "pre-configuration.dump" + host = ( + "127.0.0.1" + if os.getenv("MODELFORGE_TEST_POSTGRES_CONTAINER") + else (owner.host or "localhost") + ) + args = [ + "--host", + host, + "--port", + str(owner.port or 5432), + "--username", + owner.username or "modelforge", + "--dbname", + database, + ] + _database_tool("pg_dump", [*args, "--format=custom"], owner.password, archive) + assert archive.stat().st_size > 0 + alembic.upgrade(migration, "head") + with Session(runtime) as session: + assert session.scalar(select(Project.key)) == "existing-consumer" + assert session.scalar(select(AuditEvent.event_hash)) == baseline + assert configuration_state(session) == (0, {}) + # Mechanical round-trip is qualified before operator changes. Once changes exist, + # rollback uses the retained backup rather than destroying revision history. + alembic.downgrade(migration, "20260830_0024") + alembic.upgrade(migration, "head") + apply_preview_barrier = threading.Barrier(2) + synchronize_apply_preview = threading.local() + original_preview = ConfigurationService.preview + + def preview_at_cas( + subject: ConfigurationService, change: ConfigurationChange + ): + preview = original_preview(subject, change) + if getattr(synchronize_apply_preview, "enabled", False): + synchronize_apply_preview.enabled = False + # Both real PostgreSQL transactions have validated the same revision and digest; + # release them together immediately before their conditional UPDATE. + apply_preview_barrier.wait(timeout=30) + return preview + + monkeypatch.setattr(ConfigurationService, "preview", preview_at_cas) + + def contender(expected_revision: int, value: int) -> str: + with Session(runtime) as session: + subject = ConfigurationService( + session, + Settings(_env_file=None, env="test"), + AuditContext("operator", f"contender-{value}", "configuration-race"), + ) + change = ConfigurationChange( + expected_revision=expected_revision, + changes={"gateway_max_batch_size": value}, + ) + preview = subject.preview(change) + session.rollback() # Start both writes from independent fresh transactions. + synchronize_apply_preview.enabled = True + try: + subject.apply( + ConfigurationApply( + **change.model_dump(), + preview_digest=preview.preview_digest, + confirm=True, + ) + ) + return "applied" + except ConfigurationError as error: + assert error.code == "CONFIGURATION_CONFLICT" + return "conflict" + + with ThreadPoolExecutor(max_workers=2) as pool: + assert sorted(pool.map(lambda value: contender(0, value), [2, 3])) == [ + "applied", + "conflict", + ] + with Session(runtime) as session: + assert configuration_state(session)[0] == 1 + assert len(list(session.scalars(select(ConfigurationRevision)))) == 1 + assert ( + len( + list( + session.scalars( + select(AuditEvent).where(AuditEvent.action == "configuration.applied") + ) + ) + ) + == 1 + ) + # Exercise the existing-row UPDATE path as well as the empty-singleton insert race. + with ThreadPoolExecutor(max_workers=2) as pool: + assert sorted(pool.map(lambda value: contender(1, value), [4, 5])) == [ + "applied", + "conflict", + ] + with Session(runtime) as session: + assert configuration_state(session)[0] == 2 + assert [row.revision for row in session.scalars( + select(ConfigurationRevision).order_by(ConfigurationRevision.revision) + )] == [1, 2] + assert len(list(session.scalars( + select(AuditEvent).where(AuditEvent.action == "configuration.applied") + ))) == 2 + with pytest.raises(RuntimeError, match="Configuration history exists"): + alembic.downgrade(migration, "20260830_0024") + with Session(runtime) as session: + assert configuration_state(session)[0] == 2 + assert ( + session.execute(text("SELECT version_num FROM alembic_version")).scalar_one() + == TARGET_SCHEMA_REVISION + ) + with runtime.connect() as connection: + for statement in ( + "UPDATE configuration_revisions SET actor = 'tampered'", + "DELETE FROM configuration_revisions", + "TRUNCATE configuration_revisions", + ): + with pytest.raises(DBAPIError): + connection.execute(text(statement)) + connection.rollback() + args[-1] = restored_database + _database_tool("pg_restore", [*args, "--exit-on-error"], owner.password, archive) + with Session(restored_runtime) as session: + assert session.scalar(select(Project.key)) == "existing-consumer" + assert session.scalar(select(AuditEvent.event_hash)) == baseline + assert ( + session.execute(text("SELECT version_num FROM alembic_version")).scalar_one() + == "20260830_0024" + ) + migration.set_main_option( + "sqlalchemy.url", + owner.set(database=restored_database) + .render_as_string(hide_password=False) + .replace("%", "%%"), + ) + alembic.upgrade(migration, "head") + with Session(restored_runtime) as session: + assert configuration_state(session) == (0, {}) + with runtime.connect() as connection: + current = semantic_fingerprint(connection) + contract = fingerprint_compatibility( + current, current, source_schema_revision=TARGET_SCHEMA_REVISION + ) + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(current, current, contract) + finally: + runtime.dispose() + restored_runtime.dispose() + with admin.connect() as connection: + for target in (database, restored_database): + connection.execute(text(f'DROP DATABASE IF EXISTS "{target}" WITH (FORCE)')) + admin.dispose() diff --git a/backend/tests/test_configuration_recovery.py b/backend/tests/test_configuration_recovery.py new file mode 100644 index 0000000..dd480a5 --- /dev/null +++ b/backend/tests/test_configuration_recovery.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import copy + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from modelforge_api.domain.configuration import ConfigurationApply, ConfigurationChange +from modelforge_api.persistence.models import Base +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.configuration_changes import ConfigurationService +from modelforge_api.services.recovery_fingerprint import ( + CONFIGURATION_TABLES, + SELECTED_COMPARISON_SCHEMA, + _without_creative, + _without_lab_execution, + _without_managed_identities, + _without_model_changes, + _without_model_updates, + _without_projects, + _without_secrets, + _without_selected_comparisons, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from modelforge_api.settings import Settings + + +def test_upgrade_introduces_only_empty_configuration_and_current_restore_preserves_every_revision() -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + AuditWriter(session, "operator", "baseline").write("test.baseline", "configuration", None, {}) + session.commit() + fresh = semantic_fingerprint(session.connection()) + previous = _without_lab_execution(_without_projects( + _without_managed_identities( + _without_secrets( + _without_model_updates( + _without_selected_comparisons( + _without_creative(_without_model_changes(copy.deepcopy(fresh))) + ) + ) + ) + ), + legacy=True, + )) + previous["tables"] = {key: value for key, value in previous["tables"].items() if key not in CONFIGURATION_TABLES} + previous["physical_tables"] = [name for name in previous["physical_tables"] if name not in CONFIGURATION_TABLES] + upgrade = fingerprint_compatibility(previous, fresh, source_schema_revision="20260830_0024") + assert upgrade.compatible + assert audit_fingerprint_differences_are_compatible(previous, fresh, upgrade) + subject = ConfigurationService(session, Settings(_env_file=None), AuditContext("operator", "config", "restore-test")) + change = ConfigurationChange(expected_revision=0, changes={"gateway_max_batch_size": 2}) + preview = subject.preview(change) + subject.apply(ConfigurationApply(**change.model_dump(), preview_digest=preview.preview_digest, confirm=True)) + configured = semantic_fingerprint(session.connection()) + configured_0033 = _without_lab_execution(_without_model_changes(configured)) + assert not fingerprint_compatibility(previous, configured, source_schema_revision="20260830_0024").compatible + current = fingerprint_compatibility( + configured_0033, + configured_0033, + source_schema_revision=SELECTED_COMPARISON_SCHEMA, + ) + assert current.compatible + assert audit_fingerprint_differences_are_compatible(configured_0033, configured_0033, current) + for table in CONFIGURATION_TABLES: + altered = copy.deepcopy(configured_0033) + altered["tables"][table]["digest"] = "0" * 64 + assert not audit_fingerprint_differences_are_compatible(configured_0033, altered, current) + missing = copy.deepcopy(configured_0033) + missing["tables"].pop(table) + assert not fingerprint_compatibility( + configured_0033, + missing, + source_schema_revision=SELECTED_COMPARISON_SCHEMA, + ).compatible + engine.dispose() diff --git a/backend/tests/test_deployed_recovery_convergence.py b/backend/tests/test_deployed_recovery_convergence.py new file mode 100644 index 0000000..43cd63b --- /dev/null +++ b/backend/tests/test_deployed_recovery_convergence.py @@ -0,0 +1,192 @@ +"""Fresh deployed-source proofs retain history across the explicit 0033 convergence.""" +from __future__ import annotations + +import copy +import importlib +import os +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import create_engine, select, text +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import Base, ModelRevision, ProjectBinding +from modelforge_api.services.recovery import RecoveryService +from modelforge_api.services.recovery_fingerprint import ( + CREATIVE_RESTORE_ERROR, + CREATIVE_TABLES, + _actual_table, + _without_model_changes, + _without_portfolio, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, +) +from modelforge_api.services.recovery_fingerprint import ( + semantic_fingerprint as _current_semantic_fingerprint, +) +from modelforge_api.services.recovery_postgres import PostgresTarget +from modelforge_api.settings import Settings +from tests.recovery_fixtures import recreate_0038_benchmark_runs +from tests.test_deployed_schema_convergence import _migration +from tests.test_managed_identities_postgres import IdentityDatabase, identity_database +from tests.test_selected_comparison_postgres import _valid_selection +from tests.test_selected_comparison_recovery import SQLiteRecoveryTarget +from tests.test_serving_generation_recovery import ( + _remove_lab_serving_job_link, + legacy_generation_fixture, +) + +__all__ = ["identity_database"] +TARGET = PostgresTarget("localhost", 5432, "fixture", "fixture", None) + + +def semantic_fingerprint(connection): + return _without_portfolio(_without_model_changes(_current_semantic_fingerprint(connection))) + + +def _populate(session: Session, state: str = "granted"): + request, client_id = _valid_selection(session) + connection = session.connection() + project_id = session.scalar(select(ProjectBinding.project_id).where(ProjectBinding.id == request.project_binding_id)) + tables = {name: _actual_table(connection, name) for name in (*CREATIVE_TABLES, "model_update_watches")} + revision = session.execute(select(ModelRevision.id, ModelRevision.model_id, ModelRevision.resolved_commit_sha).order_by(ModelRevision.id).limit(1)).one() + from uuid import UUID + project_id = UUID(str(project_id)) + revision_id, model_id = UUID(str(revision.id)), UUID(str(revision.model_id)) + allocation_id, watch_id = uuid4(), uuid4() + connection.execute(tables["external_creative_allocations"].insert().values(id=allocation_id, + service_client_id=client_id, project_id=project_id, request_id="preserve", idempotency_key="preserve", + request_sha256="a" * 64, workload="audio", resource_ref="audio.fixture", provider_resource_id="fixture", + required_vram_bytes=1024, priority="background", ttl_seconds=600, state=state, + decision_code="PRIOR_DECISION", decision_evidence={"preserve": "exact"}, required_actions=[], + acquired_at=datetime.now(UTC))) + connection.execute(tables["external_creative_resources"].insert().values(id=uuid4(), project_id=project_id, + resource_ref="audio.fixture", workload="audio", provider_resource_id="fixture", approved=False, + configured_vram_bytes=1024)) + connection.execute(tables["external_creative_action_attestations"].insert().values(id=uuid4(), allocation_id=allocation_id, + action="release_idle_comfy", before_vram_bytes=1024, after_vram_bytes=0, + observed_at=datetime.now(UTC), validated=True)) + connection.execute(tables["external_creative_profile_observations"].insert().values(id=uuid4(), allocation_id=allocation_id, + resource_ref="audio.fixture", provider_resource_id="fixture", workload="audio", observed_peak_vram_bytes=512, + reserved_vram_bytes=1024, duration_ms=1, outcome="succeeded")) + connection.execute(tables["model_update_watches"].insert().values(id=watch_id, model_id=model_id, + baseline_revision_id=revision_id, baseline_commit_sha=revision.resolved_commit_sha, upstream_ref="main", + check_token=uuid4(), check_until=datetime.now(UTC))) + session.commit() + return allocation_id, watch_id + + +@pytest.fixture +def legacy(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with engine.begin() as connection: + # `create_all` uses today's metadata; this fixture represents schema 0032, before the + # notification outbox and LAB evidence existed, so remove those future physical artifacts. + connection.execute(text('DROP TABLE "alert_notification_deliveries"')) + connection.execute(text("DROP TABLE selected_model_comparisons")) + recreate_0038_benchmark_runs(connection) + _remove_lab_serving_job_link(connection) + connection.execute(text("ALTER TABLE model_update_watches DROP COLUMN enabled")) + connection.execute(text("ALTER TABLE model_update_watches DROP COLUMN version")) + connection.execute(text("CREATE TABLE alembic_version(version_num VARCHAR(32))")) + connection.execute(text("INSERT INTO alembic_version VALUES ('20260909_0032')")) + with Session(engine) as session: + _populate(session) + source = semantic_fingerprint(session.connection()) + module = _migration() + module.op = Operations(MigrationContext.configure(session.connection())) + module.upgrade() + session.execute(text("UPDATE alembic_version SET version_num='20260909_0033'")) + service = RecoveryService(session, Settings(_env_file=None)) + service.engine = SQLiteRecoveryTarget(session) # type: ignore[assignment] + yield session, source, service + engine.dispose() + + +def test_populated_source_projection_and_restore_preserve_all_other_identity(legacy): + session, source, service = legacy + assert "hub_controls_projection" in source["tables"]["model_update_watches"] + assert service._creative_allocation_residuals(TARGET) == 1 + assert service._reconcile_creative_allocations(TARGET) == 1 + session.execute(text("UPDATE model_update_watches SET check_token=NULL,check_until=NULL")) + restored = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(source, restored, source_schema_revision="20260909_0032") + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + row = session.execute(text("SELECT state,decision_code,uncertain_at,decision_evidence FROM external_creative_allocations")).one() + assert row.state == "uncertain" and row.decision_code == CREATIVE_RESTORE_ERROR + assert row.uncertain_at is None + assert service._creative_allocation_residuals(TARGET) == 0 + assert service._reconcile_creative_allocations(TARGET) == 0 + for name in CREATIVE_TABLES - {"external_creative_allocations"}: + assert source["tables"][name] == restored["tables"][name] + + +def test_legacy_opaque_populated_watch_digest_is_not_projectable(legacy): + session, source, _ = legacy + opaque = copy.deepcopy(source) + del opaque["tables"]["model_update_watches"]["hub_controls_projection"] + contract = fingerprint_compatibility(opaque, semantic_fingerprint(session.connection()), source_schema_revision="20260909_0032") + assert not contract.compatible and "opaque" in contract.reason + + +@pytest.mark.parametrize("table", sorted(CREATIVE_TABLES | {"model_update_watches"})) +def test_populated_state_tampering_and_missing_tables_fail_closed(legacy, table): + session, source, service = legacy + service._reconcile_creative_allocations(TARGET) + session.execute(text("UPDATE model_update_watches SET check_token=NULL,check_until=NULL")) + restored = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(source, restored, source_schema_revision="20260909_0032") + changed = copy.deepcopy(restored) + changed["tables"][table]["digest"] = "0" * 64 + assert not audit_fingerprint_differences_are_compatible(source, changed, contract) + del changed["tables"][table] + assert not fingerprint_compatibility(source, changed, source_schema_revision="20260909_0032").compatible + + +def test_unreconciled_grant_and_unknown_physical_table_block_proof(legacy): + session, source, _ = legacy + restored = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(source, restored, source_schema_revision="20260909_0032") + assert not audit_fingerprint_differences_are_compatible(source, restored, contract) + restored["physical_tables"] = sorted([*restored["physical_tables"], "unknown_private_state"]) + assert not fingerprint_compatibility(source, restored, source_schema_revision="20260909_0032").compatible + + +def test_unknown_revision_and_forged_current_watch_projection_fail_closed(legacy): + session, source, _ = legacy + restored = semantic_fingerprint(session.connection()) + assert not fingerprint_compatibility(source, restored, source_schema_revision="20260909_0031x").compatible + restored["tables"]["model_update_watches"]["hub_controls_projection"] = source["tables"]["model_update_watches"]["hub_controls_projection"] + assert not fingerprint_compatibility(source, restored, source_schema_revision="20260909_0032").compatible + + +def test_actual_postgres_populated_source_dump_upgrade_restore_projection(identity_database: IdentityDatabase, tmp_path): + database = identity_database + alembic = importlib.import_module("alembic.command") + alembic.upgrade(database.migration(), "20260909_0032") + with legacy_generation_fixture(database.owner), Session(database.runtime) as session: + _populate(session, "queued") + with Session(database.runtime) as session: + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "populated-deployed0032.dump" + database.archive(archive) + database.archive(archive, restore=True) + alembic.upgrade(database.migration(restored=True), "20260910_0034") + with Session(database.restored_runtime) as session: + from tests.test_managed_identities_postgres import ManagedPostgresTools + recovery = RecoveryService(session, Settings(_env_file=None)) + recovery.engine = ManagedPostgresTools(base_environment=dict(os.environ), timeout_seconds=60) + target = PostgresTarget(database.restored_owner.host or "localhost", database.restored_owner.port or 5432, + database.restored_owner.database or "", database.restored_owner.username or "", database.restored_owner.password) + assert recovery._reconcile_creative_allocations(target) == 1 + recovery.engine.scalar(target, "UPDATE model_update_watches SET check_token=NULL,check_until=NULL") + restored = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(source, restored, source_schema_revision="20260909_0032") + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + assert recovery._creative_allocation_residuals(target) == 0 diff --git a/backend/tests/test_deployed_schema_convergence.py b/backend/tests/test_deployed_schema_convergence.py new file mode 100644 index 0000000..5265342 --- /dev/null +++ b/backend/tests/test_deployed_schema_convergence.py @@ -0,0 +1,255 @@ +"""Preserve deployed migration meaning and prove additive convergence on isolated databases.""" + +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +import pytest +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations +from alembic.script import ScriptDirectory +from sqlalchemy import Column, Integer, MetaData, Table, create_engine, inspect, select, text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import AuditEvent, ModelRevision, ProjectBinding +from modelforge_api.services.recovery_fingerprint import _actual_table +from tests.test_managed_identities_postgres import IdentityDatabase, identity_database +from tests.test_selected_comparison_postgres import _valid_selection +from tests.test_serving_generation_recovery import legacy_generation_fixture + +__all__ = ["identity_database"] +ROOT = Path(__file__).resolve().parents[1] +VERSIONS = ROOT / "alembic" / "versions" +FROZEN = { + "20260909_0029_external_creative_coordination.py": "ac41923b2bd7a8729abeaae5c872afa3186d4b188fe002015d77c9bc23b9c5db", + "20260909_0030_model_update_inbox.py": "265379bd5e1c2cee78a9c4a4e6004ec802a6dbbc57e5f30c422e8eb91fea99cf", + "20260909_0031_external_creative_governance.py": "397d4b288bb7564eb50da5950e1478db237f2a91d801de3d3786112b638f2c55", + "20260909_0032_audio_creative_workloads.py": "ede826fa5ada798e5730ef99b0a8380d1831d3ed11a1baccda9ed5bc0e60d362", +} +HISTORY_TABLES = ( + "model_update_watches", + "model_update_notices", + "external_creative_allocations", + "external_creative_resources", + "external_creative_action_attestations", + "external_creative_profile_observations", +) + + +def _migration(): + specification = importlib.util.spec_from_file_location( + "convergence", VERSIONS / "20260909_0033_deployed_hub_convergence.py" + ) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def test_deployed_migration_content_remains_frozen_and_graph_is_linear() -> None: + for name, digest in FROZEN.items(): + # Normalize checkout line endings only; the deployed repository blobs are LF. + assert ( + hashlib.sha256((VERSIONS / name).read_bytes().replace(b"\r\n", b"\n")).hexdigest() + == digest + ) + config = Config() + config.set_main_option("script_location", str(ROOT / "alembic")) + graph = ScriptDirectory.from_config(config) + assert graph.get_heads() == ["20260924_0039"] + assert Path(graph.get_revision("20260909_0029").path).name in FROZEN + assert Path(graph.get_revision("20260909_0030").path).name in FROZEN + assert graph.get_revision("20260909_0033").down_revision == "20260909_0032" + assert graph.get_revision("20260923_0036").down_revision == "20260911_0035" + assert graph.get_revision("20260923_0037").down_revision == "20260923_0036" + assert graph.get_revision("20260924_0038").down_revision == "20260923_0037" + assert graph.get_revision("20260924_0039").down_revision == "20260924_0038" + assert not (VERSIONS / "20260909_0029_model_update_inbox.py").exists() + assert not (VERSIONS / "20260909_0030_selected_model_comparisons.py").exists() + + +@pytest.mark.parametrize("populated", HISTORY_TABLES) +def test_new_downgrade_refuses_populated_deployed_history(populated: str) -> None: + engine = create_engine("sqlite://") + with engine.begin() as connection: + for name in HISTORY_TABLES: + Table(name, MetaData(), Column("id", Integer)).create(connection) + connection.execute(text("ALTER TABLE model_update_watches ADD COLUMN upstream_ref TEXT")) + module = _migration() + module.op = Operations(MigrationContext.configure(connection)) + module.upgrade() + table = Table(populated, MetaData(), autoload_with=connection) + connection.execute(table.insert().values(id=1)) + with pytest.raises(RuntimeError, match="history exists"): + module.downgrade() + assert inspect(connection).has_table("selected_model_comparisons") + assert { + column["name"] for column in inspect(connection).get_columns("model_update_watches") + } >= {"enabled", "version"} + engine.dispose() + + +def test_populated_0032_upgrade_preserves_creative_watch_audit_history_and_restricts_runtime( + identity_database: IdentityDatabase, +) -> None: + database = identity_database + alembic = importlib.import_module("alembic.command") + alembic.upgrade(database.migration(), "20260909_0032") + with legacy_generation_fixture(database.owner), Session(database.runtime) as session: + request, client_id = _valid_selection(session) + connection = session.connection() + tables = {name: _actual_table(connection, name) for name in HISTORY_TABLES} + project_id = connection.scalar( + select(ProjectBinding.project_id).where(ProjectBinding.id == request.project_binding_id), + ) + revision = connection.execute( + select(ModelRevision.id, ModelRevision.model_id, ModelRevision.resolved_commit_sha).order_by(ModelRevision.id).limit(1) + ).one() + watch_id, allocation_id, resource_id = uuid4(), uuid4(), uuid4() + connection.execute( + tables["model_update_watches"] + .insert() + .values( + id=watch_id, + model_id=revision.model_id, + baseline_revision_id=revision.id, + baseline_commit_sha=revision.resolved_commit_sha, + upstream_ref="main", + ) + ) + connection.execute( + tables["model_update_notices"] + .insert() + .values( + id=uuid4(), + watch_id=watch_id, + model_id=revision.model_id, + baseline_commit_sha=revision.resolved_commit_sha, + candidate_revision_id=revision.id, + candidate_commit_sha=revision.resolved_commit_sha, + status="dismissed", + ) + ) + connection.execute( + tables["external_creative_allocations"] + .insert() + .values( + id=allocation_id, + service_client_id=client_id, + project_id=project_id, + request_id="preserved", + idempotency_key="preserved", + request_sha256="a" * 64, + workload="audio", + resource_ref="fixture.audio@1", + provider_resource_id="fixture", + required_vram_bytes=1024, + priority="background", + ttl_seconds=600, + state="released", + decision_code="OFFLINE_FIXTURE", + decision_evidence={"preserve": True}, + required_actions=[], + ) + ) + connection.execute( + tables["external_creative_resources"] + .insert() + .values( + id=resource_id, + project_id=project_id, + resource_ref="fixture.audio@1", + workload="audio", + provider_resource_id="fixture", + approved=False, + configured_vram_bytes=1024, + ) + ) + connection.execute( + tables["external_creative_action_attestations"] + .insert() + .values( + id=uuid4(), + allocation_id=allocation_id, + action="release_idle_comfy", + before_vram_bytes=1024, + after_vram_bytes=0, + observed_at=datetime.now(UTC), + validated=True, + ) + ) + connection.execute( + tables["external_creative_profile_observations"] + .insert() + .values( + id=uuid4(), + allocation_id=allocation_id, + resource_ref="fixture.audio@1", + provider_resource_id="fixture", + workload="audio", + observed_peak_vram_bytes=512, + reserved_vram_bytes=1024, + duration_ms=1, + outcome="succeeded", + ) + ) + session.commit() + before = { + name: session.execute(select(table).order_by(table.c.id)).mappings().all() + for name, table in tables.items() + } + audit_before = session.execute( + select(AuditEvent.id, AuditEvent.canonical_payload, AuditEvent.event_hash).order_by(AuditEvent.sequence) + ).all() + assert "enabled" not in { + column["name"] + for column in inspect(session.connection()).get_columns("model_update_watches") + } + alembic.upgrade(database.migration(), "20260909_0033") + with database.runtime.connect() as connection: + for name, table in tables.items(): + assert ( + connection.execute(select(table).order_by(table.c.id)).mappings().all() + == before[name] + ) + assert ( + connection.execute( + select(AuditEvent.id, AuditEvent.canonical_payload, AuditEvent.event_hash).order_by(AuditEvent.sequence) + ).all() + == audit_before + ) + assert connection.execute( + text("SELECT enabled, version FROM model_update_watches WHERE id=:id"), {"id": watch_id} + ).one() == (True, 1) + assert connection.scalar(text("SELECT count(*) FROM selected_model_comparisons")) == 0 + for table_name in ( + "model_update_watches", + "model_update_notices", + "selected_model_comparisons", + ): + for privilege in ("SELECT", "INSERT", "UPDATE"): + assert connection.scalar( + text("SELECT has_table_privilege(current_user, :table, :privilege)"), + {"table": table_name, "privilege": privilege}, + ) + for privilege in ("DELETE", "TRUNCATE"): + assert not connection.scalar( + text("SELECT has_table_privilege(current_user, :table, :privilege)"), + {"table": table_name, "privilege": privilege}, + ) + with pytest.raises(DBAPIError): + connection.execute( + text("DELETE FROM model_update_watches WHERE id=:id"), {"id": watch_id} + ) + connection.rollback() + with pytest.raises(RuntimeError, match="history exists"): + alembic.downgrade(database.migration(), "20260909_0032") + with database.runtime.connect() as connection: + assert connection.scalar(text("SELECT version_num FROM alembic_version")) == "20260909_0033" diff --git a/backend/tests/test_dispatch_context.py b/backend/tests/test_dispatch_context.py new file mode 100644 index 0000000..e3fc6e5 --- /dev/null +++ b/backend/tests/test_dispatch_context.py @@ -0,0 +1,472 @@ +import hashlib +import json +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session + +from modelforge_api.domain.dispatch_context import DispatchContext +from modelforge_api.domain.lab import LabRerankingInput +from modelforge_api.domain.serving import ( + CapabilityExperimentCreate, + EmbeddingInvokeRequest, + RerankingInvokeRequest, + ServiceClientCreate, +) +from modelforge_api.persistence.models import ( + Base, + DeploymentCandidate, + GatewayRequest, + SchedulerPolicyRevision, + ServiceClient, + ServingJob, +) +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.serving import ServingError +from modelforge_api.services.transient_payloads import MemoryPayloadStore +from tests.test_lab_preflight import setup_lab +from tests.test_serving import InlineWorkerServingService, approve_and_promote, setup_m5 + + +@pytest.fixture +def session() -> Iterator[Session]: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + engine.dispose() + + +@pytest.mark.parametrize("priority", ["production", "interactive", "background", "benchmark"]) +def test_persisted_service_priority_and_lease_owner_are_preserved(priority: str) -> None: + client_id = uuid4() + context = DispatchContext.for_service_client(client_id, "project-client", priority) + assert context.service_client_id == client_id + assert context.workload_priority == priority + assert context.lease_owner == "project-client" + assert context.provenance()["actor_id"] == str(client_id) + assert context.operation_id is None + with pytest.raises(ValidationError, match="frozen"): + context.workload_priority = "lab" + + +@pytest.mark.parametrize("priority", ["lab", "maintenance", "admin", "", "PRODUCTION"]) +def test_service_dispatch_rejects_unconfigured_priority_domain(priority: str) -> None: + with pytest.raises(ValidationError): + DispatchContext.for_service_client(uuid4(), "project-client", priority) + + +def test_operator_identity_is_separate_and_cannot_elevate_priority() -> None: + context = DispatchContext.for_operator("jens", uuid4()) + assert context.service_client_id is None + assert context.workload_priority == "lab" + assert context.lease_owner == "operator:jens" + for changes in ( + {"workload_priority": "production"}, {"service_client_id": uuid4()}, + {"operation_id": None}, {"actor_kind": "service_client"}, + {"lease_owner": "a-different-owner"}, {"token": "not-accepted"}, + ): + with pytest.raises(ValidationError): + DispatchContext.model_validate({**context.model_dump(), **changes}) + assert set(context.provenance()) == { + "actor_kind", "actor_id", "service_client_id", "operation_id", "workload_priority", + } + + +def test_operator_scheduler_admission_requires_no_fabricated_service_client(session: Session) -> None: + service, candidate, _ = setup_m5(session) + _, response = approve_and_promote(service, candidate.id) + deployment = service.repo.deployment(response.id) + assert deployment is not None + context = DispatchContext.for_operator("operator-test", uuid4()) + request = GatewayRequest(request_id=uuid4(), service_client_id=None, + capability_key="rag.embedding", capability_version=1, + capability_deployment_id=deployment.id, compute_node_id=deployment.compute_node_id, + status="queued", priority="lab", input_sha256="a" * 64, input_count=1) + session.add(request) + session.commit() + lease = service._acquire_request_lease(deployment, request, context) + assert lease.priority == "lab" + assert lease.owner == "operator:operator-test" + assert lease.request_id == request.request_id + assert request.decision_evidence["dispatch_context"] == context.provenance() + assert session.scalar(select(func.count()).select_from(ServiceClient)) == 0 + + +def test_operator_context_cannot_bypass_paused_lab_policy(session: Session) -> None: + service, candidate, _ = setup_m5(session) + _, response = approve_and_promote(service, candidate.id) + deployment = service.repo.deployment(response.id) + assert deployment is not None + session.add(SchedulerPolicyRevision(revision="lab-paused-test", active=True, + configuration={"lab_paused": True})) + context = DispatchContext.for_operator("operator-test", uuid4()) + request = GatewayRequest(request_id=uuid4(), service_client_id=None, + capability_key="rag.embedding", capability_version=1, + capability_deployment_id=deployment.id, compute_node_id=deployment.compute_node_id, + status="queued", priority="lab", input_sha256="a" * 64, input_count=1) + session.add(request) + session.commit() + with pytest.raises(ServingError) as raised: + service._acquire_request_lease(deployment, request, context) + assert raised.value.code == "POLICY_BLOCKED" + assert service.repo.active_leases(deployment.accelerator_id, datetime.now(UTC)) == [] + + +@pytest.mark.parametrize("mismatch", ["client", "priority", "deployment"]) +def test_context_mismatch_fails_before_scheduler_mutation(session: Session, mismatch: str) -> None: + service, candidate, _ = setup_m5(session) + _, response = approve_and_promote(service, candidate.id) + deployment = service.repo.deployment(response.id) + assert deployment is not None + context = DispatchContext.for_operator("operator-test", uuid4()) + request = GatewayRequest(request_id=uuid4(), service_client_id=uuid4() if mismatch == "client" else None, + capability_key="rag.embedding", capability_version=1, + capability_deployment_id=uuid4() if mismatch == "deployment" else deployment.id, + compute_node_id=deployment.compute_node_id, status="queued", + priority="production" if mismatch == "priority" else "lab", input_sha256="a" * 64, input_count=1) + with pytest.raises(ServingError) as raised: + service._acquire_request_lease(deployment, request, context) + assert raised.value.code == "DISPATCH_CONTEXT_MISMATCH" + assert request.status == "queued" + assert service.repo.active_leases(deployment.accelerator_id, datetime.now(UTC)) == [] + + +def operator_fixture(session: Session): + preflight, request = setup_lab(session) + service = InlineWorkerServingService(session, preflight.settings, + ManifestRegistry(), MemoryPayloadStore()) + experiment = service.create_experiment(request.target.deployment_candidate_id, + CapabilityExperimentCreate(route_key="operator-selected", purpose="Bounded exact-target unit test", + execution_approval_id=request.target.execution_approval_id)) + return service, request, experiment + + +def test_operator_embedding_executes_exact_selected_experiment_without_client(session: Session) -> None: + service, request, selected = operator_fixture(session) + service.synthetic_worker_metrics = { + "gpu_uuid": service.repo.accelerator(selected.deployment.accelerator_id).device_uuid, + "used_vram_bytes": 4_096, + "process_used_vram_bytes": 2_048, + "free_vram_bytes": 8_192, + "gpu_utilization_percent": 32, + "temperature_c": 63, + "power_draw_w": 101.25, + "private_metric_canary": "must-not-be-persisted", + } + other = service.create_experiment(request.target.deployment_candidate_id, + CapabilityExperimentCreate(route_key="operator-other", purpose="Unselected comparison fixture", + execution_approval_id=request.target.execution_approval_id)) + service.authenticated_credential_id = uuid4() # stale service-instance state must not cross domains + operation_id = uuid4() + context = DispatchContext.for_operator("operator-test", operation_id) + result = service.invoke_operator_lab(request.input, context, + deployment_id=selected.capability_deployment_id, experiment_route_id=selected.id) + record = session.scalar(select(GatewayRequest).where(GatewayRequest.request_id == result.request_id)) + assert record is not None + assert record.capability_deployment_id == selected.capability_deployment_id != other.capability_deployment_id + assert record.service_client_id is None and record.priority == "lab" + assert record.decision_evidence["service_credential_id"] is None + assert record.decision_evidence["dispatch_context"] == context.provenance() + assert record.decision_evidence["operator_route_id"] == str(selected.id) + assert result.embedding_space_id == selected.deployment.embedding_space.id + evidence = record.decision_evidence["lab_run_evidence"] + candidate = session.get(DeploymentCandidate, request.target.deployment_candidate_id) + profile = service.repo.profile(request.target.runtime_profile_id) + deployment = service.repo.deployment(selected.capability_deployment_id) + assert candidate is not None and profile is not None and deployment is not None + accelerator = service.repo.accelerator(deployment.accelerator_id) + assert accelerator is not None + assert evidence["schema_version"] == 1 + assert evidence["status"] == "completed" + assert evidence["capability"] == "rag.embedding@1" + assert evidence["operation_id"] == str(operation_id) + assert evidence["input"] == { + "sha256": record.input_sha256, + "item_count": 1, + "content_logged": False, + } + assert evidence["target"] == { + "deployment_id": str(selected.capability_deployment_id), + "candidate_id": str(candidate.id), + "experiment_route_id": str(selected.id), + "capability_contract_id": str(deployment.capability_contract_id), + "contract_version": 1, + "artifact_set_id": str(deployment.artifact_set_id), + "artifact_sha256": profile.artifact_sha256, + "runtime_profile_id": str(profile.id), + "runtime_profile_fingerprint": profile.fingerprint, + "compatibility_assessment_id": str(candidate.compatibility_assessment_id), + "runtime_probe_id": str(candidate.runtime_probe_id), + "compute_node_id": str(deployment.compute_node_id), + "accelerator_id": str(deployment.accelerator_id), + "accelerator_uuid": accelerator.device_uuid, + "execution_approval_id": str(request.target.execution_approval_id), + "embedding_space_id": str(deployment.embedding_space_id), + "deployment_fingerprint": deployment.config_fingerprint, + } + expected_output_hash = hashlib.sha256( + json.dumps(result.data, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert evidence["output"] == { + "type": "float_vector", + "dimension": result.dimension, + "item_count": len(result.data), + "embedding_space_id": str(result.embedding_space_id), + "sha256": expected_output_hash, + "content_logged": False, + } + serving_job = session.scalar( + select(ServingJob).where( + ServingJob.gateway_request_id == record.id, + ServingJob.operation == "invoke", + ) + ) + expected_observations = { + "source": "runtime-worker", + "phase": "after_inference", + "availability": "complete", + "device_matches_target": True, + "metrics": { + "gpu_uuid": accelerator.device_uuid, + "used_vram_bytes": 4_096, + "process_used_vram_bytes": 2_048, + "free_vram_bytes": 8_192, + "gpu_utilization_percent": 32, + "temperature_c": 63, + "power_draw_w": 101.25, + }, + } + assert evidence["worker_observations"] == expected_observations + assert serving_job is not None + assert serving_job.result_summary["resource_observation"] == expected_observations + assert "vectors" not in evidence["output"] and "data" not in evidence["output"] + assert "private input content" not in json.dumps(evidence) + assert "must-not-be-persisted" not in json.dumps(evidence) + assert session.scalar(select(func.count()).select_from(ServiceClient)) == 0 + + +def test_operator_lab_without_worker_resource_metrics_does_not_invent_gpu_evidence( + session: Session, +) -> None: + service, request, selected = operator_fixture(session) + result = service.invoke_operator_lab( + request.input, + DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + record = session.scalar(select(GatewayRequest).where(GatewayRequest.request_id == result.request_id)) + assert record is not None + observations = record.decision_evidence["lab_run_evidence"]["worker_observations"] + assert observations == { + "source": "runtime-worker", + "phase": "after_inference", + "availability": "unavailable", + "device_matches_target": None, + "metrics": {}, + } + + +def test_operator_lab_rejects_unbounded_worker_resource_metrics(session: Session) -> None: + service, request, selected = operator_fixture(session) + deployment = service.repo.deployment(selected.capability_deployment_id) + assert deployment is not None + accelerator = service.repo.accelerator(deployment.accelerator_id) + assert accelerator is not None + service.synthetic_worker_metrics = { + "gpu_uuid": accelerator.device_uuid, + "used_vram_bytes": True, + "process_used_vram_bytes": 2**100, + "free_vram_bytes": 8_192, + "gpu_utilization_percent": 101, + "temperature_c": 10**1000, + "power_draw_w": 10**1000, + "private_metric_canary": "must-not-be-persisted", + } + result = service.invoke_operator_lab( + request.input, + DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + record = session.scalar(select(GatewayRequest).where(GatewayRequest.request_id == result.request_id)) + assert record is not None + observations = record.decision_evidence["lab_run_evidence"]["worker_observations"] + assert observations == { + "source": "runtime-worker", + "phase": "after_inference", + "availability": "partial", + "device_matches_target": True, + "metrics": {"gpu_uuid": accelerator.device_uuid, "free_vram_bytes": 8_192}, + } + + +def test_operator_lab_scheduler_failure_is_durable_and_redacted( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + service, request, selected = operator_fixture(session) + + def reject_admission(*_args, **_kwargs): + raise ServingError(429, "LAB_QUEUE_REJECTED", "private scheduler detail") + + monkeypatch.setattr(service, "_acquire_request_lease", reject_admission) + with pytest.raises(ServingError, match="private scheduler detail"): + service.invoke_operator_lab( + request.input, + DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + + record = session.scalar(select(GatewayRequest).where(GatewayRequest.priority == "lab")) + assert record is not None and record.status == "failed" + assert record.failure_code == "LAB_QUEUE_REJECTED" + evidence = record.decision_evidence["lab_run_evidence"] + assert evidence["status"] == "failed" + assert evidence["failure_code"] == "LAB_QUEUE_REJECTED" + assert "output" not in evidence and "worker_observations" not in evidence + assert "private input content" not in json.dumps(evidence) + assert "private scheduler detail" not in json.dumps(evidence) + + +@pytest.mark.parametrize("bad_target", ["missing_deployment", "missing_route", "inactive_route", "stable"]) +def test_operator_embedding_rejects_non_exact_or_non_lab_target(session: Session, bad_target: str) -> None: + service, request, selected = operator_fixture(session) + deployment_id, route_id = selected.capability_deployment_id, selected.id + if bad_target == "missing_deployment": + deployment_id = uuid4() + elif bad_target == "missing_route": + route_id = uuid4() + elif bad_target == "inactive_route": + service.deactivate_experiment(route_id) + else: + _, stable = approve_and_promote(service, request.target.deployment_candidate_id) + deployment_id = stable.id + with pytest.raises(ServingError) as raised: + service.invoke_operator_lab(request.input, DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=deployment_id, experiment_route_id=route_id) + assert raised.value.code == "LAB_TARGET_NOT_ROUTABLE" + assert session.scalar(select(func.count()).select_from(GatewayRequest)) == 0 + + +def test_operator_entry_rejects_service_context_and_unimplemented_family(session: Session) -> None: + service, request, selected = operator_fixture(session) + with pytest.raises(ServingError) as denied: + service.invoke_operator_lab(request.input, DispatchContext.for_service_client(uuid4(), "test-client", "production"), + deployment_id=selected.capability_deployment_id, experiment_route_id=selected.id) + assert denied.value.code == "OPERATOR_CONTEXT_REQUIRED" + reranking = LabRerankingInput(capability="rag.reranking", payload=RerankingInvokeRequest( + query="test", documents=[{"id": "a", "text": "test"}], top_n=1)) + with pytest.raises(ServingError) as unsupported: + service.invoke_operator_lab(reranking, DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=selected.capability_deployment_id, experiment_route_id=selected.id) + assert unsupported.value.code == "LAB_INPUT_NOT_SUPPORTED" + assert session.scalar(select(func.count()).select_from(GatewayRequest)) == 0 + + +@pytest.mark.parametrize("invalid", ["approval_expired", "wrong_contract"]) +def test_operator_entry_rechecks_exact_approval_and_contract(session: Session, invalid: str) -> None: + service, request, selected = operator_fixture(session) + if invalid == "approval_expired": + approval = service.repo.execution_approval(request.target.execution_approval_id) + assert approval is not None + approval.expires_at = datetime.now(UTC) - timedelta(seconds=1) + else: + route = service.repo.experiment_route_by_id(selected.id) + assert route is not None + route.capability_contract_id = service.ensure_contract("rag.reranking", 1).id + session.commit() + with pytest.raises(ServingError) as denied: + service.invoke_operator_lab(request.input, DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=selected.capability_deployment_id, experiment_route_id=selected.id) + assert denied.value.code == ("LAB_ADMISSION_BLOCKED" if invalid == "approval_expired" else "LAB_TARGET_NOT_ROUTABLE") + assert session.scalar(select(func.count()).select_from(GatewayRequest)) == 0 + + +def test_route_deactivation_after_load_prevents_operator_inference(session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + service, request, selected = operator_fixture(session) + original = service._ensure_loaded + original_put = service.payload_store.put + input_references = [] + def track_put(payload, ttl_seconds): + reference = original_put(payload, ttl_seconds) + if "input" in payload: + input_references.append(reference) + return reference + monkeypatch.setattr(service.payload_store, "put", track_put) + def load_then_deactivate(deployment, deadline, *, priority): + loaded = original(deployment, deadline, priority=priority) + route = service.repo.experiment_route_by_id(selected.id) + assert route is not None + route.status = "inactive" + session.commit() + return loaded + monkeypatch.setattr(service, "_ensure_loaded", load_then_deactivate) + with pytest.raises(ServingError) as denied: + service.invoke_operator_lab(request.input, DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=selected.capability_deployment_id, experiment_route_id=selected.id) + assert denied.value.code == "LAB_TARGET_NOT_ROUTABLE" + assert session.scalar(select(func.count()).select_from(ServingJob).where(ServingJob.operation == "invoke")) == 0 + record = session.scalar(select(GatewayRequest)) + assert record is not None and record.status == "failed" + assert len(input_references) == 1 + assert service.payload_store.get(input_references[0]) is None + + +def test_gateway_times_validation_once_before_stable_resolution(session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + service, request, _ = operator_fixture(session) + approve_and_promote(service, request.target.deployment_candidate_id) + created = service.create_client(ServiceClientCreate(name="timing-client", allowed_capabilities=["rag.embedding@1"])) + client = service.authenticate(f"Bearer {created.credential}", "rag.embedding@1") + events = [] + clock = [1000.0] + original_validate = service._validate_inputs + original_resolve = service._stable_embedding_deployment + def validate(value): + events.append("validate") + clock[0] += 0.125 + return original_validate(value) + def resolve(): + events.append("resolve") + clock[0] += 0.375 + return original_resolve() + monkeypatch.setattr(service, "_validate_inputs", validate) + monkeypatch.setattr(service, "_stable_embedding_deployment", resolve) + monkeypatch.setattr("modelforge_api.services.serving.time.perf_counter", lambda: clock[0]) + response = service.invoke(EmbeddingInvokeRequest(input="timed request"), client) + assert events == ["validate", "resolve"] + record = session.scalar(select(GatewayRequest).where(GatewayRequest.request_id == response.request_id)) + assert record is not None + latency = record.decision_evidence["latency_ms"] + assert latency["validation"] == 125.0 + assert latency["resolution"] == 375.0 + assert latency["total"] >= 500.0 + + +def test_failed_enqueue_deletes_input_without_a_job_owner(session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + service, request, selected = operator_fixture(session) + original_enqueue = service._enqueue_job + original_put = service.payload_store.put + input_references = [] + def track_put(payload, ttl_seconds): + reference = original_put(payload, ttl_seconds) + if "input" in payload: + input_references.append(reference) + return reference + def enqueue(deployment, operation, **kwargs): + if operation == "invoke": + raise ServingError(409, "ENQUEUE_REJECTED", "isolated enqueue failure") + return original_enqueue(deployment, operation, **kwargs) + monkeypatch.setattr(service.payload_store, "put", track_put) + monkeypatch.setattr(service, "_enqueue_job", enqueue) + with pytest.raises(ServingError, match="isolated enqueue failure"): + service.invoke_operator_lab(request.input, DispatchContext.for_operator("operator-test", uuid4()), + deployment_id=selected.capability_deployment_id, experiment_route_id=selected.id) + assert len(input_references) == 1 + assert service.payload_store.get(input_references[0]) is None + assert session.scalar(select(func.count()).select_from(ServingJob).where(ServingJob.operation == "invoke")) == 0 diff --git a/backend/tests/test_evaluation_project_boundary.py b/backend/tests/test_evaluation_project_boundary.py new file mode 100644 index 0000000..288a151 --- /dev/null +++ b/backend/tests/test_evaluation_project_boundary.py @@ -0,0 +1,120 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select, update + +from modelforge_api.domain.evaluation import ( + EvaluationCaseResultCreate, + EvaluationComparisonCreate, + EvaluationRunComplete, + EvaluationRunCreate, +) +from modelforge_api.persistence.models import ( + EvaluationCase, + EvaluationCaseResult, + EvaluationComparison, + EvaluationRun, + Project, +) +from modelforge_api.services.evaluation import EvaluationError, EvaluationService +from tests.test_evaluation import _run, _session, _suite + + +def test_run_creation_rejects_another_projects_suite_without_writing_run() -> None: + with _session() as session: + owner = Project(key="owner", name="Owner", description="test") + other = Project(key="other", name="Other", description="test") + session.add_all([owner, other]) + session.commit() + service = EvaluationService(session) + suite = _suite(service, owner.id, uuid4()) + request = EvaluationRunCreate(project_id=other.id, suite_revision_id=suite.latest_revision_id, + target_kind="current", target_index_ref="current", embedding_space_ref="space", + corpus_revision="corpus", retrieval_config_digest="a" * 64, + environment_fingerprint={"engine": "test"}) + with pytest.raises(EvaluationError) as rejected: + service.create_run(request) + assert rejected.value.code == "suite_project_mismatch" + assert session.scalar(select(func.count()).select_from(EvaluationRun)) == 0 + + +def test_legacy_wrong_project_run_cannot_complete_or_become_eligible() -> None: + with _session() as session: + owner = Project(key="owner", name="Owner", description="test") + other = Project(key="other", name="Other", description="test") + session.add_all([owner, other]) + session.commit() + service = EvaluationService(session) + relevant = uuid4() + suite = _suite(service, owner.id, relevant) + case = session.scalar(select(EvaluationCase)) + assert case is not None + baseline = _run(service, owner.id, suite.latest_revision_id, case.id, relevant, + target="current", rank=1) + candidate = _run(service, owner.id, suite.latest_revision_id, case.id, relevant, + target="shadow", rank=1) + # Model historical rows admitted before the ownership check, without changing their suite. + session.execute(update(EvaluationRun).values(project_id=other.id)) + session.commit() + comparison = service.compare(EvaluationComparisonCreate( + baseline_run_id=baseline.id, candidate_run_id=candidate.id)) + assert comparison.comparability == "not_comparable" + assert comparison.promotion_eligibility == "requires_review" + assert comparison.comparability_evidence["baseline_suite_project"] is False + session.execute(update(EvaluationRun).where(EvaluationRun.id == candidate.id) + .values(status="running")) + session.commit() + with pytest.raises(EvaluationError) as rejected: + service.complete_run(candidate.id, EvaluationRunComplete(results=[ + EvaluationCaseResultCreate(case_id=case.id, ranked_results=[], latency_ms=1)])) + assert rejected.value.code == "suite_project_mismatch" + + +def test_failed_baseline_remains_reportable_but_never_eligible_even_with_error_tolerance() -> None: + with _session() as session: + project = Project(key="owner", name="Owner", description="test") + session.add(project) + session.commit() + service = EvaluationService(session) + relevant = uuid4() + suite = _suite(service, project.id, relevant) + case = session.scalar(select(EvaluationCase)) + assert case is not None + baseline = service.create_run(EvaluationRunCreate(project_id=project.id, + suite_revision_id=suite.latest_revision_id, target_kind="current", target_index_ref="current", + embedding_space_ref="space", corpus_revision="corpus-v1", retrieval_config_digest="a" * 64, + environment_fingerprint={"engine": "test-v1"})) + service.complete_run(baseline.id, EvaluationRunComplete(results=[EvaluationCaseResultCreate( + case_id=case.id, ranked_results=[], latency_ms=1, error_code="upstream_failed")])) + candidate = _run(service, project.id, suite.latest_revision_id, case.id, relevant, + target="shadow", rank=1) + comparison = service.compare(EvaluationComparisonCreate(baseline_run_id=baseline.id, + candidate_run_id=candidate.id, maximum_error_rate=1)) + assert comparison.comparability == "comparable" + assert comparison.metric_deltas["recall_at_10"] == 1 + assert comparison.promotion_eligibility == "not_eligible" + assert comparison.eligibility_evidence["baseline_error_count"] == 1 + assert comparison.eligibility_evidence["baseline_error_free"] is False + + +def test_caller_owned_transaction_rolls_back_runs_case_results_and_comparison() -> None: + with _session() as session: + project = Project(key="owner", name="Owner", description="test") + session.add(project) + session.commit() + relevant = uuid4() + suite = _suite(EvaluationService(session), project.id, relevant) + case = session.scalar(select(EvaluationCase)) + assert case is not None + service = EvaluationService(session, commit=False) + baseline = _run(service, project.id, suite.latest_revision_id, case.id, relevant, + target="current", rank=2) + candidate = _run(service, project.id, suite.latest_revision_id, case.id, relevant, + target="shadow", rank=1) + comparison = service.compare(EvaluationComparisonCreate( + baseline_run_id=baseline.id, candidate_run_id=candidate.id)) + assert comparison.promotion_eligibility == "eligible" + assert session.scalar(select(func.count()).select_from(EvaluationCaseResult)) == 2 + session.rollback() + for model in (EvaluationRun, EvaluationCaseResult, EvaluationComparison): + assert session.scalar(select(func.count()).select_from(model)) == 0 diff --git a/backend/tests/test_external_creative_admission.py b/backend/tests/test_external_creative_admission.py new file mode 100644 index 0000000..3978632 --- /dev/null +++ b/backend/tests/test_external_creative_admission.py @@ -0,0 +1,456 @@ +"""Defensive allocation authority and reservation regressions; no GPU invocation.""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from sqlalchemy import create_engine, event, select +from sqlalchemy.orm import Session + +from modelforge_api.api.routes.external_creative import operator_allocations +from modelforge_api.domain.external_creative import ( + ExternalActionAttestation, + ExternalAllocationCreate, + ExternalProfileObservation, + ExternalResourceApproval, +) +from modelforge_api.persistence.models import ( + Accelerator, + Base, + ExternalCreativeAllocation, + Project, + ProjectBinding, + ServiceClient, +) +from modelforge_api.services import external_creative as external_creative_module +from modelforge_api.services.external_creative import ( + ExternalCreativeService, + attestations, + observations, + resources, +) +from modelforge_api.services.serving import ServingError +from tests.test_serving import setup_m5 + + +@pytest.fixture +def allocation_setup(): + engine = create_engine("sqlite://") + + @event.listens_for(engine, "connect") + def foreign_keys(connection, _record): + connection.execute("PRAGMA foreign_keys=ON") + + Base.metadata.create_all(engine) + with Session(engine) as session: + serving, _candidate, node = setup_m5(session) + contract = serving.ensure_contract("rag.embedding", 1) + project = Project(key="creative-fixture", name="Creative", description="Offline fixture") + session.add(project) + session.flush() + binding = ProjectBinding( + project_id=project.id, + capability_contract_id=contract.id, + channel="stable", + priority="interactive", + ) + session.add(binding) + session.flush() + client = ServiceClient( + name="Creative client", + project_binding_id=binding.id, + allowed_capabilities=["creative.resource@1"], + workload_priority="interactive", + ) + session.add(client) + session.commit() + accelerator = session.scalar( + select(Accelerator).where(Accelerator.compute_node_id == node.id) + ) + budget = SimpleNamespace( + accelerator_id=accelerator.id, + attribution_confidence="HIGH", + pressure_state="LOW", + schedulable_free_vram_bytes=100, + policy_revision="fixture", + observed_at=None, + ) + + # Contract matches root's shared accounting: subtract retained reservations once. + def overview(*, commit): + assert commit is False + retained = sum( + row.required_vram_bytes + for row in session.scalars( + select(ExternalCreativeAllocation).where( + ExternalCreativeAllocation.state.in_(["granted", "uncertain"]) + ) + ) + ) + return [ + SimpleNamespace( + **{ + **vars(budget), + "schedulable_free_vram_bytes": budget.schedulable_free_vram_bytes + - retained, + } + ) + ] + + serving.scheduler_overview = Mock(side_effect=overview) + service = ExternalCreativeService(session, serving) + service.upsert_resource( + ExternalResourceApproval( + project_id=project.id, + resource_ref="chosen-model", + workload="image", + provider_resource_id="private-provider", + configured_vram_bytes=60, + ), + "fixture", + ) + request = ExternalAllocationCreate( + request_id="one", + idempotency_key="one", + installation_id="fixture", + workload="image", + resource_ref="chosen-model", + provider_resource_id="private-provider", + required_vram_bytes=60, + ) + yield session, service, request, client, budget, binding, project + engine.dispose() + + +def test_attestation_requires_evidence_and_records_authorized_action(allocation_setup): + session, service, request, client, _, _, _ = allocation_setup + allocation = service.acquire(request, client) + assert allocation.state == "granted" + assert allocation.required_actions == [] + # The normal free-capacity grant needs no eviction. Exercise the retained + # action attestation contract with an explicitly action-bearing fixture. + row = session.get(ExternalCreativeAllocation, allocation.allocation_id) + row.required_actions = ["release_idle_comfy"] + session.commit() + allocation = service.acquire(request, client) + + with pytest.raises(ServingError, match="attestation is required"): + service.action(allocation.allocation_id, request.request_id, "attest", client) + assert session.execute(select(attestations)).all() == [] + + service.action( + allocation.allocation_id, + request.request_id, + "attest", + client, + attestation=ExternalActionAttestation( + action=allocation.required_actions[0], + observed_before=100, + observed_after=40, + completed_at=datetime.now(UTC), + ), + ) + recorded = session.execute(select(attestations)).one() + assert recorded.action == allocation.required_actions[0] + assert recorded.before_vram_bytes == 100 + assert recorded.after_vram_bytes == 40 + + +def test_priority_cannot_exceed_either_client_or_binding(allocation_setup): + session, service, request, client, _, binding, _ = allocation_setup + with pytest.raises(ServingError, match="priority"): + service.acquire(request.model_copy(update={"priority": "production"}), client) + client.workload_priority = "production" + session.commit() + with pytest.raises(ServingError, match="priority"): + service.acquire(request.model_copy(update={"priority": "production"}), client) + assert list(session.scalars(select(ExternalCreativeAllocation))) == [] + binding.priority = "production" + session.commit() + assert ( + service.acquire(request.model_copy(update={"priority": "production"}), client).priority + == "production" + ) + + +@pytest.mark.parametrize( + "revocation", ["client", "scope", "binding", "project", "resource", "priority"] +) +def test_queue_rechecks_current_authority(allocation_setup, revocation): + session, service, request, client, budget, binding, project = allocation_setup + budget.schedulable_free_vram_bytes = 0 + created = service.acquire(request, client) + assert created.state == "queued" + if revocation == "client": + client.status = "disabled" + elif revocation == "scope": + client.allowed_capabilities = [] + elif revocation == "binding": + binding.deprecated_at = datetime.now(UTC) + elif revocation == "project": + project.active = False + elif revocation == "priority": + client.workload_priority = "background" + else: + session.execute(resources.update().values(approved=False)) + session.commit() + budget.schedulable_free_vram_bytes = 100 + service._promote_queue() + session.commit() + row = session.get(ExternalCreativeAllocation, created.allocation_id) + assert row.state == "blocked" and row.acquired_at is None and row.accelerator_id is None + + +def test_queue_recalculates_resource_floor_and_accounts_new_grants_once(allocation_setup): + session, service, request, client, budget, _, _ = allocation_setup + budget.schedulable_free_vram_bytes = 0 + first = service.acquire(request, client) + second = service.acquire( + request.model_copy(update={"idempotency_key": "two", "request_id": "two"}), client + ) + session.execute(resources.update().values(configured_vram_bytes=80)) + session.commit() + budget.schedulable_free_vram_bytes = 150 + service._promote_queue() + session.commit() + pair = [session.get(ExternalCreativeAllocation, item.allocation_id) for item in (first, second)] + assert sorted(row.state for row in pair) == ["granted", "queued"] + assert all(row.required_vram_bytes == 80 for row in pair) + assert all(row.required_actions == [] for row in pair) + + +def test_permanently_oversized_queue_head_does_not_starve_fitting_request(allocation_setup): + _, service, request, client, _, _, _ = allocation_setup + oversized = service.acquire( + request.model_copy( + update={ + "idempotency_key": "oversized", + "request_id": "oversized", + "required_vram_bytes": 120, + } + ), + client, + ) + fitting = service.acquire( + request.model_copy(update={"idempotency_key": "fitting", "request_id": "fitting"}), + client, + ) + + assert oversized.state == "queued" + assert fitting.state == "granted" + assert fitting.required_actions == [] + assert fitting.decision_evidence["headroom_before_bytes"] == 100 + + +def test_new_request_promotes_fitting_queue_head_before_competing_for_capacity(allocation_setup): + session, service, request, client, budget, _, _ = allocation_setup + budget.schedulable_free_vram_bytes = 0 + waiting = service.acquire(request, client) + budget.schedulable_free_vram_bytes = 100 + + newcomer = service.acquire( + request.model_copy(update={"idempotency_key": "new", "request_id": "new"}), client + ) + + session.refresh(waiting_row := session.get(ExternalCreativeAllocation, waiting.allocation_id)) + assert waiting_row.state == "granted" + assert newcomer.state == "queued" + + +def test_queued_decision_reports_observed_capacity_instead_of_zero_sentinel(allocation_setup): + _, service, request, client, budget, _, _ = allocation_setup + observed_at = datetime(2026, 9, 22, 8, 30, tzinfo=UTC) + budget.schedulable_free_vram_bytes = 50 + budget.observed_at = observed_at + + queued = service.acquire(request, client) + + assert queued.state == "queued" + assert queued.decision_evidence["headroom_before_bytes"] == 50 + assert queued.decision_evidence["observed_at"] == observed_at.isoformat() + + +def test_expired_heartbeat_cannot_resurrect_or_release_reservation(allocation_setup): + session, service, request, client, _, _, _ = allocation_setup + first = service.acquire(request, client) + row = session.get(ExternalCreativeAllocation, first.allocation_id) + row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + with pytest.raises(ServingError, match="expired"): + service.action(row.id, request.request_id, "heartbeat", client) + session.expire_all() + assert row.state == "uncertain" and row.required_vram_bytes == 60 and row.accelerator_id + second = service.acquire( + request.model_copy(update={"idempotency_key": "two", "request_id": "two"}), client + ) + assert second.state == "queued" + service.action(row.id, request.request_id, "release", client) + assert session.get(ExternalCreativeAllocation, second.allocation_id).state == "granted" + + +def test_confirming_expired_grant_released_promotes_waiting_allocation(allocation_setup): + session, service, request, client, _, _, _ = allocation_setup + granted = service.acquire(request, client) + expired = session.get(ExternalCreativeAllocation, granted.allocation_id) + expired.expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + + waiting = service.acquire( + request.model_copy(update={"idempotency_key": "waiting", "request_id": "waiting"}), + client, + ) + session.refresh(expired) + assert expired.state == "uncertain" + assert expired.released_at is None + assert waiting.state == "queued" + + service.reconcile(expired.id, "confirm_released", "provider verified idle", "operator") + + waiting_row = session.get(ExternalCreativeAllocation, waiting.allocation_id) + session.refresh(waiting_row) + assert waiting_row.state == "granted" + + +@pytest.mark.parametrize("mutation", ["queued", "reserved", "expired"]) +def test_profile_requires_actual_grant_and_authoritative_reservation(allocation_setup, mutation): + session, service, request, client, budget, _, _ = allocation_setup + if mutation == "queued": + budget.schedulable_free_vram_bytes = 0 + first = service.acquire(request, client) + row = session.get(ExternalCreativeAllocation, first.allocation_id) + if mutation == "expired": + row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + body = ExternalProfileObservation( + allocation_id=row.id, + resource_ref=request.resource_ref, + provider_resource_id=request.provider_resource_id, + workload="image", + observed_peak_vram_bytes=50, + reserved_vram_bytes=61 if mutation == "reserved" else 60, + duration_ms=1000, + outcome="succeeded", + ) + with pytest.raises(ServingError, match="actual grant"): + service.observe(body, client) + assert list(session.execute(select(observations))) == [] + + +def test_released_grant_can_report_exact_profile_without_reopening_capacity(allocation_setup): + session, service, request, client, _, _, _ = allocation_setup + first = service.acquire(request, client) + service.action(first.allocation_id, request.request_id, "release", client) + body = ExternalProfileObservation( + allocation_id=first.allocation_id, + resource_ref=request.resource_ref, + provider_resource_id=request.provider_resource_id, + workload="image", + observed_peak_vram_bytes=50, + reserved_vram_bytes=60, + duration_ms=1000, + outcome="succeeded", + ) + assert service.observe(body, client).measurement_count == 1 + assert service.observe(body, client).measurement_count == 1 + assert session.get(ExternalCreativeAllocation, first.allocation_id).state == "released" + + +def test_history_read_projects_expiry_without_inventing_release(allocation_setup): + session, service, request, client, _, _, _ = allocation_setup + first = service.acquire(request, client) + row = session.get(ExternalCreativeAllocation, first.allocation_id) + row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + page = service.list_allocations("uncertain", 20, 0) + assert page.total == 1 and page.items[0].allocation_id == row.id + assert page.items[0].state == "uncertain" + assert page.items[0].decision_code == "ALLOCATION_EXPIRED_UNCERTAIN" + assert page.items[0].expires_at is None + assert page.items[0].uncertain_at is None + session.expire_all() + stored = session.get(ExternalCreativeAllocation, first.allocation_id) + assert stored.state == "granted" and stored.released_at is None + assert stored.expires_at.replace(tzinfo=UTC) < datetime.now(UTC) + assert stored.uncertain_at is None + assert stored.required_vram_bytes == 60 and stored.accelerator_id is not None + + +def test_operator_allocation_get_performs_no_dml_or_commit(allocation_setup): + session, service, request, client, _, _, _ = allocation_setup + first = service.acquire(request, client) + row = session.get(ExternalCreativeAllocation, first.allocation_id) + row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + client.name = "pending read-only probe" + + statements = [] + commits = [] + + def record_statement(_connection, _cursor, statement, _parameters, _context, _many): + operation = statement.lstrip().split(None, 1)[0].upper() + if operation in {"INSERT", "UPDATE", "DELETE", "REPLACE"}: + statements.append(operation) + + def record_commit(_session): + commits.append(True) + + bind = session.get_bind() + event.listen(bind, "before_cursor_execute", record_statement) + event.listen(session, "before_commit", record_commit) + try: + page = operator_allocations(service, object(), "uncertain", 20, 0) + finally: + event.remove(bind, "before_cursor_execute", record_statement) + event.remove(session, "before_commit", record_commit) + + assert page.total == 1 and page.items[0].state == "uncertain" + assert statements == [] + assert commits == [] + session.rollback() + session.refresh(client) + assert client.name == "Creative client" + + +def test_history_read_expiry_boundary_is_inclusive(allocation_setup, monkeypatch): + session, service, request, client, _, _, _ = allocation_setup + first = service.acquire(request, client) + row = session.get(ExternalCreativeAllocation, first.allocation_id) + boundary = datetime(2030, 1, 1, tzinfo=UTC) + row.expires_at = boundary + session.commit() + monkeypatch.setattr(external_creative_module, "_now", lambda: boundary) + + expired = service.list_allocations("uncertain", 20, 0) + assert expired.total == 1 + assert expired.items[0].state == "uncertain" + assert expired.items[0].decision_code == "ALLOCATION_EXPIRED_UNCERTAIN" + assert expired.items[0].uncertain_at is None + + row.expires_at = boundary + timedelta(microseconds=1) + session.commit() + before_expiry = service.list_allocations("uncertain", 20, 0) + active = service.list_allocations("granted", 20, 0) + + assert before_expiry.total == 0 + assert active.total == 1 + assert active.items[0].state == "granted" + assert active.items[0].expires_at.replace(tzinfo=UTC) == boundary + timedelta(microseconds=1) + + +def test_restored_queued_uncertainty_requires_explicit_reconciliation(allocation_setup): + session, service, request, client, budget, _, _ = allocation_setup + budget.schedulable_free_vram_bytes = 0 + first = service.acquire(request, client) + row = session.get(ExternalCreativeAllocation, first.allocation_id) + row.state, row.decision_code = "uncertain", "CREATIVE_RESTORE_INTERRUPTED" + session.commit() + budget.schedulable_free_vram_bytes = 100 + assert service.get(row.id, client).state == "uncertain" + assert row.accelerator_id is None and row.acquired_at is None + assert ( + service.reconcile( + row.id, "confirm_released", "External owner verified idle", "operator" + ).state + == "released" + ) diff --git a/backend/tests/test_external_creative_governance.py b/backend/tests/test_external_creative_governance.py new file mode 100644 index 0000000..8a11900 --- /dev/null +++ b/backend/tests/test_external_creative_governance.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import uuid +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from modelforge_api.domain.external_creative import ( + ExternalActionAttestation, + ExternalAllocationCreate, + ExternalAllocationReconcile, + ExternalProfileObservation, + ExternalResourceApproval, +) +from modelforge_api.services.external_creative import PRIORITY, _digest + +AUDIO_WORKLOADS = ( + "speech_generation", + "voice_clone", + "music_generation", + "sound_generation", + "foley_generation", + "transcription", + "audio_enhancement", + "lip_sync", +) + + +def test_priority_order_keeps_production_before_interactive_and_background() -> None: + assert PRIORITY["production"] < PRIORITY["interactive"] < PRIORITY["background"] + assert PRIORITY["background"] < PRIORITY["benchmark"] + + +def test_resource_binding_requires_bounded_positive_profile() -> None: + binding = ExternalResourceApproval( + project_id=uuid.uuid4(), + resource_ref="wan.video@2.2", + workload="video", + provider_resource_id="configured-comfyui", + configured_vram_bytes=12 * 1024**3, + ) + assert binding.approved is True + with pytest.raises(ValidationError): + binding.model_copy(update={"configured_vram_bytes": 0}).model_validate( + binding.model_copy(update={"configured_vram_bytes": 0}).model_dump() + ) + + +@pytest.mark.parametrize("workload", AUDIO_WORKLOADS) +def test_audio_workloads_are_explicitly_admitted_by_the_contract(workload: str) -> None: + binding = ExternalResourceApproval( + project_id=uuid.uuid4(), + resource_ref="candidate.audio@exact-revision", + workload=workload, + provider_resource_id="configured-audio-provider", + approved=False, + configured_vram_bytes=4 * 1024**3, + ) + assert binding.workload == workload + assert binding.approved is False + + +def test_unknown_audio_workload_is_rejected() -> None: + with pytest.raises(ValidationError): + ExternalResourceApproval( + project_id=uuid.uuid4(), + resource_ref="candidate.audio@exact-revision", + workload="generic_audio", + provider_resource_id="configured-audio-provider", + approved=False, + configured_vram_bytes=4 * 1024**3, + ) + + +def test_workload_is_part_of_the_idempotent_allocation_identity() -> None: + common = { + "request_id": "director-shot-42", + "idempotency_key": "director-shot-42-sound", + "installation_id": "private-muse", + "resource_ref": "candidate.audio@exact-revision", + "provider_resource_id": "configured-audio-provider", + "required_vram_bytes": 4 * 1024**3, + } + speech = ExternalAllocationCreate(**common, workload="speech_generation") + clone = ExternalAllocationCreate(**common, workload="voice_clone") + assert _digest(speech) != _digest(clone) + + +def test_attestation_and_reconciliation_are_closed_enums() -> None: + attestation = ExternalActionAttestation( + action="release_idle_comfy", + observed_before=10, + observed_after=4, + completed_at="2026-09-09T12:00:00Z", + ) + assert attestation.observed_before - attestation.observed_after == 6 + assert ExternalAllocationReconcile(action="confirm_released", reason="provider verified idle") + with pytest.raises(ValidationError): + ExternalAllocationReconcile(action="force", reason="provider verified idle") + + +def test_profile_observation_is_bounded_and_allocation_bound() -> None: + observation = ExternalProfileObservation( + allocation_id=uuid.uuid4(), + resource_ref="flux.image@9b", + provider_resource_id="configured-comfyui", + workload="image", + observed_peak_vram_bytes=14 * 1024**3, + reserved_vram_bytes=16 * 1024**3, + duration_ms=120_000, + outcome="succeeded", + ) + assert observation.observed_peak_vram_bytes <= observation.reserved_vram_bytes + + +def test_migration_retains_each_profile_observation() -> None: + repository_root = Path(__file__).resolve().parents[2] + migration = ( + repository_root / "backend/alembic/versions/20260909_0031_external_creative_governance.py" + ).read_text() + assert '"external_creative_profile_observations"' in migration + assert '"uq_external_creative_profile_observation_allocation"' in migration + assert 'down_revision = "20260909_0030"' in migration + + +def test_audio_workload_migration_widens_every_persisted_route_component() -> None: + repository_root = Path(__file__).resolve().parents[2] + migration = ( + repository_root / "backend/alembic/versions/20260909_0032_audio_creative_workloads.py" + ).read_text() + assert 'down_revision = "20260909_0031"' in migration + assert '"external_creative_allocations"' in migration + assert '"external_creative_resources"' in migration + assert '"external_creative_profile_observations"' in migration + assert "type_=sa.String(32)" in migration diff --git a/backend/tests/test_external_creative_postgres.py b/backend/tests/test_external_creative_postgres.py new file mode 100644 index 0000000..5573655 --- /dev/null +++ b/backend/tests/test_external_creative_postgres.py @@ -0,0 +1,302 @@ +"""Real PostgreSQL admission serialization with deterministic offline capacity.""" + +import importlib +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock +from uuid import uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from modelforge_api.domain.dispatch_context import DispatchContext +from modelforge_api.domain.external_creative import ( + ExternalAllocationCreate, + ExternalResourceApproval, +) +from modelforge_api.persistence.models import ( + Accelerator, + ExternalCreativeAllocation, + GatewayRequest, + ServiceClient, + ServingGpuLease, +) +from modelforge_api.services import external_creative as external_creative_module +from modelforge_api.services.external_creative import ExternalCreativeService +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.serving import ServingError, ServingService +from modelforge_api.settings import Settings +from tests.test_managed_identities_postgres import IdentityDatabase, identity_database +from tests.test_selected_comparison_postgres import _valid_selection +from tests.test_serving import MemoryPayloadStore + +__all__ = ["identity_database"] + + +def test_competing_external_admissions_retain_one_capacity_reservation( + identity_database: IdentityDatabase, +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + with Session(database.runtime) as session: + _request, client_id = _valid_selection(session) + client = session.get(ServiceClient, client_id) + client.allowed_capabilities = ["creative.resource@1"] + session.commit() + accelerator_id = session.scalar(select(Accelerator.id)) + service = ExternalCreativeService(session, Mock()) + project_id = service._project(client) + service.upsert_resource( + ExternalResourceApproval( + project_id=project_id, + resource_ref="race-fixture", + workload="image", + provider_resource_id="offline-provider", + configured_vram_bytes=60, + ), + "qualification", + ) + + barrier = threading.Barrier(2) + + def contender(index: int) -> str: + with Session(database.runtime) as session: + + def overview(*, commit): + assert commit is False + retained = sum( + session.scalars( + select(ExternalCreativeAllocation.required_vram_bytes).where( + ExternalCreativeAllocation.state.in_(["granted", "uncertain"]) + ) + ) + ) + return [ + SimpleNamespace( + accelerator_id=accelerator_id, + attribution_confidence="HIGH", + pressure_state="LOW", + schedulable_free_vram_bytes=100 - retained, + policy_revision="offline-fixture", + observed_at=None, + ) + ] + + serving = Mock() + serving.scheduler_overview.side_effect = overview + service = ExternalCreativeService(session, serving) + client = session.get(ServiceClient, client_id) + session.commit() + barrier.wait(timeout=30) + return service.acquire( + ExternalAllocationCreate( + request_id=f"race-{index}", + idempotency_key=f"race-{index}", + installation_id="fixture", + workload="image", + resource_ref="race-fixture", + provider_resource_id="offline-provider", + required_vram_bytes=60, + ), + client, + ).state + + with ThreadPoolExecutor(max_workers=2) as pool: + assert sorted(pool.map(contender, [1, 2])) == ["granted", "queued"] + with Session(database.runtime) as session: + rows = list(session.scalars(select(ExternalCreativeAllocation))) + assert len(rows) == 2 + assert sum(row.required_vram_bytes for row in rows if row.state == "granted") == 60 + + +def test_native_and_external_admission_share_actual_postgres_capacity( + identity_database: IdentityDatabase, +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + settings = Settings( + _env_file=None, + env="test", + gateway_queue_timeout_seconds=1, + gateway_request_timeout_seconds=60, + ) + + def serving(session): + return ServingService(session, settings, ManifestRegistry(), MemoryPayloadStore()) + + with Session(database.runtime) as session: + selection, client_id = _valid_selection(session) + client = session.get(ServiceClient, client_id) + client.allowed_capabilities = ["rag.embedding@1", "creative.resource@1"] + session.commit() + service = serving(session) + deployment_id = selection.current_deployment_id + deployment = service.repo.deployment(deployment_id) + baseline = service._budget(deployment) + plan = service._placement_plan( + deployment, priority=client.workload_priority, dry_run=True, persist=False, commit=False + ) + assert plan.verdict == "ADMIT" + available, native_required = baseline.schedulable_free_vram_bytes, plan.required_vram_bytes + assert 0 < native_required < available + external_required = available - native_required // 2 + assert external_required <= available < external_required + native_required + external = ExternalCreativeService(session, service) + external.upsert_resource( + ExternalResourceApproval( + project_id=external._project(client), + resource_ref="mixed-race", + workload="image", + provider_resource_id="offline-provider", + configured_vram_bytes=external_required, + ), + "qualification", + ) + gateway = GatewayRequest( + request_id=uuid4(), + service_client_id=client.id, + capability_key="rag.embedding", + capability_version=1, + capability_deployment_id=deployment.id, + compute_node_id=deployment.compute_node_id, + status="queued", + priority=client.workload_priority, + input_sha256="a" * 64, + input_count=1, + ) + session.add(gateway) + session.commit() + gateway_id = gateway.id + + barrier = threading.Barrier(2) + + def contender(native): + with Session(database.runtime) as session: + service = serving(session) + client = session.get(ServiceClient, client_id) + deployment = service.repo.deployment(deployment_id) + gateway = session.get(GatewayRequest, gateway_id) + context = DispatchContext.for_service_client( + client.id, client.name, client.workload_priority + ) + session.commit() + barrier.wait(timeout=30) + if native: + try: + service._acquire_request_lease(deployment, gateway, context) + return "native_granted" + except ServingError as error: + assert error.code in { + "REQUEST_TIMEOUT", + "INSUFFICIENT_SCHEDULABLE_VRAM", + "INSUFFICIENT_CAPACITY", + }, error.code + return "native_blocked" + result = ExternalCreativeService(session, service).acquire( + ExternalAllocationCreate( + request_id="mixed-race", + idempotency_key="mixed-race", + installation_id="fixture", + resource_ref="mixed-race", + workload="image", + provider_resource_id="offline-provider", + required_vram_bytes=external_required, + ttl_seconds=60, + ), + client, + ) + return "external_" + result.state + + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(contender, [True, False])) + assert outcomes in ( + ["native_granted", "external_queued"], + ["native_blocked", "external_granted"], + ) + with Session(database.runtime) as session: + native = list( + session.scalars( + select(ServingGpuLease).where(ServingGpuLease.state.in_(["active", "granted"])) + ) + ) + external = list( + session.scalars( + select(ExternalCreativeAllocation).where( + ExternalCreativeAllocation.state.in_(["granted", "uncertain"]) + ) + ) + ) + assert len(native) + len(external) == 1 + assert ( + sum(row.reserved_vram_bytes for row in native) + + sum(row.required_vram_bytes for row in external) + <= available + ) + + +def test_expired_external_listing_keeps_postgres_count_and_rows_in_sync( + identity_database: IdentityDatabase, monkeypatch +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + with Session(database.runtime) as session: + _request, client_id = _valid_selection(session) + client = session.get(ServiceClient, client_id) + client.allowed_capabilities = ["creative.resource@1"] + session.commit() + + accelerator_id = session.scalar(select(Accelerator.id)) + serving = Mock() + serving.scheduler_overview.return_value = [ + SimpleNamespace( + accelerator_id=accelerator_id, + attribution_confidence="HIGH", + pressure_state="LOW", + schedulable_free_vram_bytes=100, + policy_revision="postgres-boundary-fixture", + observed_at=None, + ) + ] + service = ExternalCreativeService(session, serving) + project_id = service._project(client) + service.upsert_resource( + ExternalResourceApproval( + project_id=project_id, + resource_ref="expiry-boundary-fixture", + workload="image", + provider_resource_id="offline-provider", + configured_vram_bytes=60, + ), + "qualification", + ) + acquired = service.acquire( + ExternalAllocationCreate( + request_id="expiry-boundary", + idempotency_key="expiry-boundary", + installation_id="fixture", + workload="image", + resource_ref="expiry-boundary-fixture", + provider_resource_id="offline-provider", + required_vram_bytes=60, + ), + client, + ) + row = session.get(ExternalCreativeAllocation, acquired.allocation_id) + boundary = datetime(2030, 1, 1, tzinfo=UTC) + row.expires_at = boundary + session.commit() + monkeypatch.setattr(external_creative_module, "_now", lambda: boundary) + + uncertain = service.list_allocations("uncertain", 20, 0) + granted = service.list_allocations("granted", 20, 0) + + assert uncertain.total == len(uncertain.items) == 1 + assert uncertain.items[0].allocation_id == acquired.allocation_id + assert uncertain.items[0].state == "uncertain" + assert uncertain.items[0].decision_code == "ALLOCATION_EXPIRED_UNCERTAIN" + assert uncertain.items[0].uncertain_at is None + assert granted.total == len(granted.items) == 0 + session.refresh(row) + assert row.state == "granted" and row.expires_at == boundary diff --git a/backend/tests/test_hardware_api.py b/backend/tests/test_hardware_api.py index ad1946b..9604366 100644 --- a/backend/tests/test_hardware_api.py +++ b/backend/tests/test_hardware_api.py @@ -1,5 +1,7 @@ +import pytest from fastapi.testclient import TestClient from hardware_fakes import FakeAcceleratorCollector, FakeHostCollector, accelerator +from http_contract_fakes import isolated_http_contract # noqa: F401 - opt-in pytest fixture from pydantic import SecretStr from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -11,6 +13,8 @@ from modelforge_api.persistence.models import Base from modelforge_api.services.hardware_inventory import HardwareInventoryService, HardwareRefreshBusy from modelforge_api.settings import Settings, get_settings +pytestmark = pytest.mark.usefixtures("isolated_http_contract") + def _test_operator_credential() -> str: return "hardware-test-operator" @@ -86,6 +90,7 @@ def test_hardware_refresh_and_resource_endpoints_serialize_real_and_unknown_valu finally: app.dependency_overrides.clear() session.close() + engine.dispose() def test_concurrent_refresh_returns_normalized_conflict() -> None: @@ -119,3 +124,16 @@ def test_refresh_failure_returns_normalized_service_unavailable() -> None: assert response.json()["error"]["message"] == "hardware inventory failed: RuntimeError" finally: app.dependency_overrides.clear() + + +def test_hardware_contract_preserves_operator_authentication_and_correlation() -> None: + _configure_operator_auth() + with TestClient(app) as client: + response = client.post( + "/api/v1/hardware/refresh", headers={"X-Correlation-ID": "hardware-auth-test"} + ) + assert response.status_code == 401 + assert response.json()["error"]["code"] == "http_401" + assert response.json()["error"]["correlation_id"] == "hardware-auth-test" + assert response.headers["x-correlation-id"] == "hardware-auth-test" + assert response.headers["cache-control"] == "no-store" diff --git a/backend/tests/test_huggingface_provider.py b/backend/tests/test_huggingface_provider.py index daeae26..2d6a740 100644 --- a/backend/tests/test_huggingface_provider.py +++ b/backend/tests/test_huggingface_provider.py @@ -55,6 +55,19 @@ def test_search_uses_official_client_and_preserves_unknowns() -> None: assert calls["search"] == "model" and calls["full"] is True +def test_provider_uses_only_explicit_credential_and_official_origin(monkeypatch) -> None: + from huggingface_hub import constants + from huggingface_hub.utils import build_hf_headers + + monkeypatch.setenv("HF_TOKEN", "hf_ambient_test_only") + monkeypatch.setattr(constants, "ENDPOINT", "https://untrusted.invalid") + anonymous = OfficialHuggingFaceProvider() + assert anonymous.api.endpoint == "https://huggingface.co" + assert "authorization" not in build_hf_headers(token=anonymous.token) + explicit = OfficialHuggingFaceProvider(token="hf_explicit_test_only") # noqa: S106 + assert build_hf_headers(token=explicit.token)["authorization"] == "Bearer hf_explicit_test_only" + + def test_snapshot_resolves_sha_inventory_card_and_scanner_evidence() -> None: provider = OfficialHuggingFaceProvider() provider.api.model_info = lambda *args, **kwargs: SimpleNamespace( # type: ignore[method-assign] diff --git a/backend/tests/test_lab_execution_api.py b/backend/tests/test_lab_execution_api.py new file mode 100644 index 0000000..cb9f38a --- /dev/null +++ b/backend/tests/test_lab_execution_api.py @@ -0,0 +1,106 @@ +from datetime import UTC, datetime +from unittest.mock import Mock +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from modelforge_api.api.authorization import ( + Principal, + PrincipalRole, + authenticate_operator_principal, +) +from modelforge_api.api.routes.lab import get_lab_run_service, router +from modelforge_api.domain.lab import LabExecutionRunResponse, LabInputSummary + + +def start_body() -> dict[str, object]: + return { + "target": { + name: str(uuid4()) + for name in ( + "deployment_candidate_id", + "artifact_set_id", + "runtime_profile_id", + "compute_node_id", + "execution_approval_id", + ) + }, + "input": {"capability": "rag.embedding", "payload": {"input": "private-lab-canary"}}, + "deployment_id": str(uuid4()), + "experiment_route_id": str(uuid4()), + } + + +def harness(role: PrincipalRole) -> tuple[TestClient, Mock]: + app = FastAPI() + app.include_router(router) + service = Mock() + service.start.return_value = LabExecutionRunResponse( + id=uuid4(), + status="accepted", + target_snapshot={"deployment_id": str(uuid4())}, + input_summary=LabInputSummary(sha256="a" * 64, item_count=1, encoded_bytes=45), + input_payload_expires_at=datetime.now(UTC), + retention_seconds=300, + created_at=datetime.now(UTC), + ) + app.dependency_overrides[authenticate_operator_principal] = lambda: Principal( + "operator-one", role, "test" + ) + app.dependency_overrides[get_lab_run_service] = lambda: service + return TestClient(app), service + + +def test_operator_lab_start_route_is_registered_and_returns_accepted_run() -> None: + client, service = harness(PrincipalRole.OPERATOR) + + response = client.post( + "/api/v1/lab/runs", + headers={"Idempotency-Key": "start-once"}, + json=start_body(), + ) + + assert response.status_code == 202 + payload = response.json() + assert payload["status"] == "accepted" + assert payload["capability"] == "rag.embedding@1" + assert "private-lab-canary" not in response.text + assert response.headers["cache-control"] == "no-store" + service.start.assert_called_once() + assert service.start.call_args.kwargs["actor_subject"] == "operator-one" + assert service.start.call_args.kwargs["idempotency_key"] == "start-once" + + +def test_lab_run_start_requires_operator_role() -> None: + client, service = harness(PrincipalRole.VIEWER) + + response = client.post( + "/api/v1/lab/runs", + headers={"Idempotency-Key": "start-viewer"}, + json=start_body(), + ) + + assert response.status_code == 403 + service.start.assert_not_called() + + +def test_lab_run_status_and_cancel_are_no_store_and_actor_scoped() -> None: + client, service = harness(PrincipalRole.OPERATOR) + run_id = service.start.return_value.id + service.get.return_value = service.start.return_value + service.cancel.return_value = service.start.return_value + + status_response = client.get(f"/api/v1/lab/runs/{run_id}") + cancel_response = client.post(f"/api/v1/lab/runs/{run_id}/cancel") + + assert status_response.status_code == 200 + assert cancel_response.status_code == 200 + assert status_response.headers["cache-control"] == "no-store" + assert cancel_response.headers["cache-control"] == "no-store" + assert "payload_reference" not in status_response.text + assert "private-lab-canary" not in status_response.text + assert service.get.call_args.args == (run_id,) + assert service.get.call_args.kwargs == {"actor_subject": "operator-one"} + assert service.cancel.call_args.args == (run_id,) + assert service.cancel.call_args.kwargs == {"actor_subject": "operator-one"} diff --git a/backend/tests/test_lab_execution_postgres.py b/backend/tests/test_lab_execution_postgres.py new file mode 100644 index 0000000..d4239fb --- /dev/null +++ b/backend/tests/test_lab_execution_postgres.py @@ -0,0 +1,167 @@ +"""Managed PostgreSQL migration and actor-scoped LAB idempotency qualification.""" + +from __future__ import annotations + +import importlib +import os +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import SimpleNamespace + +import pytest +from alembic.config import Config +from sqlalchemy import create_engine, func, select, text +from sqlalchemy.engine import make_url +from sqlalchemy.orm import Session + +from modelforge_api.domain.lab import LabExecutionStartRequest +from modelforge_api.persistence.models import GatewayRequest, LabExecutionRun +from modelforge_api.services.lab_runs import LabRunService +from modelforge_api.services.serving import ServingService +from modelforge_api.services.transient_payloads import MemoryPayloadStore +from tests.test_dispatch_context import operator_fixture + +_PRIOR = "20260923_0037" +_CURRENT = "20260924_0039" + + +def test_managed_postgresql_lab_migration_and_concurrent_idempotent_start( +) -> None: + """The database constraint must linearize simultaneous starts for one actor/key.""" + admin_url = os.getenv("MODELFORGE_TEST_POSTGRES_URL") + if not admin_url: + pytest.skip("requires managed MODELFORGE_TEST_POSTGRES_URL") + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + assert owner_url and runtime_url, "managed qualification requires owner and runtime URLs" + + if admin_url.startswith("postgresql://"): + admin_url = admin_url.replace("postgresql://", "postgresql+psycopg://", 1) + owner = make_url(owner_url) + runtime = make_url(runtime_url) + assert owner.get_backend_name() == runtime.get_backend_name() == "postgresql" + assert owner.username == "modelforge" + assert runtime.username == "modelforge_runtime" + + database_name = f"test_lab_runs_{uuid.uuid4().hex}" + admin = create_engine(admin_url, isolation_level="AUTOCOMMIT") + owner_engine = create_engine(owner.set(database=database_name)) + runtime_engine = create_engine( + runtime.set(database=database_name), + connect_args={"connect_timeout": 15, "options": "-c statement_timeout=30000 -c lock_timeout=15000"}, + ) + migration = Config() + migration.set_main_option( + "script_location", str(Path(__file__).resolve().parents[1] / "alembic") + ) + migration.set_main_option( + "sqlalchemy.url", + owner.set(database=database_name) + .render_as_string(hide_password=False) + .replace("%", "%%"), + ) + alembic = importlib.import_module("alembic.command") + barrier = threading.Barrier(2) + + with admin.connect() as connection: + connection.execute(text(f'CREATE DATABASE "{database_name}" OWNER modelforge')) + try: + alembic.upgrade(migration, _PRIOR) + with runtime_engine.connect() as connection: + assert connection.scalar(text("SELECT to_regclass('public.lab_execution_runs')")) is None + + alembic.upgrade(migration, "head") + with runtime_engine.connect() as connection: + assert connection.scalar(text("SELECT version_num FROM alembic_version")) == _CURRENT + assert connection.scalar( + text("SELECT has_table_privilege(current_user, 'lab_execution_runs', 'SELECT')") + ) + for privilege in ("INSERT", "UPDATE"): + assert connection.scalar( + text( + "SELECT has_table_privilege(current_user, 'lab_execution_runs', :privilege)" + ), + {"privilege": privilege}, + ) + for privilege in ("DELETE", "TRUNCATE"): + assert not connection.scalar( + text( + "SELECT has_table_privilege(current_user, 'lab_execution_runs', :privilege)" + ), + {"privilege": privilege}, + ) + + with Session(owner_engine) as setup_session: + setup_serving, preflight, selected = operator_fixture(setup_session) + request = LabExecutionStartRequest( + target=preflight.target, + input=preflight.input, + limits=preflight.limits, + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + deployment_settings = setup_serving._deployment_settings + manifests = setup_serving.manifests + + def start_once() -> uuid.UUID: + with Session(runtime_engine) as session: + serving = ServingService( + session, + deployment_settings, + manifests, + MemoryPayloadStore(), + ) + service = LabRunService(serving) + select_target = service._selected_target + + def select_same_target(_request: LabExecutionStartRequest): + selection = select_target(_request) + barrier.wait(timeout=30) + return selection + + service._selected_target = select_same_target # type: ignore[method-assign] + result = service.start( + request, + actor_subject="postgres-race-operator", + idempotency_key="same-selected-run", + ) + return result.id + + with ThreadPoolExecutor(max_workers=2) as pool: + run_ids = list(pool.map(lambda _index: start_once(), range(2))) + assert run_ids[0] == run_ids[1] + + cancel_barrier = threading.Barrier(2) + + def cancel_once() -> str: + with Session(runtime_engine) as session: + serving = SimpleNamespace(session=session, payload_store=MemoryPayloadStore()) + service = LabRunService(serving) + cancel_barrier.wait(timeout=30) + return service.cancel( + run_ids[0], actor_subject="postgres-race-operator" + ).status + + with ThreadPoolExecutor(max_workers=2) as pool: + cancel_statuses = list(pool.map(lambda _index: cancel_once(), range(2))) + assert cancel_statuses == ["cancelled", "cancelled"] + + with Session(runtime_engine) as session: + assert session.scalar(select(func.count()).select_from(LabExecutionRun)) == 1 + assert session.scalar(select(func.count()).select_from(GatewayRequest)) == 1 + run = session.scalar(select(LabExecutionRun)) + assert run is not None + assert run.actor_subject == "postgres-race-operator" + assert run.idempotency_key == "same-selected-run" + assert run.status == "cancelled" + assert run.cancellation_requested_at is not None + assert run.cancellation_acknowledged_at is None + assert run.input_sha256 and "private postgres race input" not in str(run.target_snapshot) + finally: + runtime_engine.dispose() + owner_engine.dispose() + with admin.connect() as connection: + connection.execute(text(f'DROP DATABASE IF EXISTS "{database_name}" WITH (FORCE)')) + admin.dispose() diff --git a/backend/tests/test_lab_execution_races_postgres.py b/backend/tests/test_lab_execution_races_postgres.py new file mode 100644 index 0000000..56b6953 --- /dev/null +++ b/backend/tests/test_lab_execution_races_postgres.py @@ -0,0 +1,198 @@ +"""PostgreSQL qualification for durable LAB cancellation versus worker completion.""" + +from __future__ import annotations + +import hashlib +import importlib +import threading +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import event, select +from sqlalchemy.orm import Session + +pytest_plugins = ("tests.test_managed_identities_postgres",) + +from modelforge_api.domain.lab import LabExecutionStartRequest +from modelforge_api.domain.serving import AgentServingJobComplete +from modelforge_api.persistence.models import GatewayRequest, LabExecutionRun, ServingJob +from modelforge_api.services.lab_runs import LabRunService +from modelforge_api.services.serving import ServingService +from tests.test_dispatch_context import operator_fixture +from tests.test_managed_identities_postgres import IdentityDatabase + + +@pytest.mark.parametrize("first_writer", ["cancel", "complete"]) +def test_lab_cancel_and_worker_completion_linearize_on_job_then_run_locks( + identity_database: IdentityDatabase, first_writer: str +) -> None: + """Hold the winning transaction open and prove the other writer observes its result.""" + importlib.import_module("alembic.command").upgrade(identity_database.migration(), "head") + engine = identity_database.runtime + + with Session(engine) as session: + serving, preflight, selected = operator_fixture(session) + request = LabExecutionStartRequest( + target=preflight.target, + input=preflight.input, + limits=preflight.limits, + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + payload_store = serving.payload_store + run = LabRunService(serving).start( + request, + actor_subject=f"postgres-lab-race-{first_writer}", + idempotency_key=f"worker-completion-{first_writer}", + ) + persisted_run = session.get(LabExecutionRun, run.id) + assert persisted_run is not None + gateway = session.get(GatewayRequest, persisted_run.gateway_request_id) + assert gateway is not None + token = f"lab-race-token-{first_writer}-" + "t" * 32 + now = datetime.now(UTC) + job = ServingJob( + capability_deployment_id=selected.capability_deployment_id, + compute_node_id=selected.deployment.compute_node_id, + gateway_request_id=gateway.id, + lab_execution_run_id=persisted_run.id, + operation="invoke", + status="running", + priority="lab", + idempotency_key=f"worker-job-{first_writer}", + payload_reference=persisted_run.payload_reference, + attempt_count=1, + lease_token_hash=hashlib.sha256(token.encode()).hexdigest(), + lease_expires_at=now + timedelta(minutes=2), + result_summary={}, + started_at=now, + ) + persisted_run.status = "running" + persisted_run.started_at = now + persisted_run.heartbeat_at = now + gateway.status = "running" + session.add(job) + session.commit() + + run_id = run.id + job_id = job.id + deployment_id = selected.capability_deployment_id + node_id = selected.deployment.compute_node_id + embedding_space = selected.deployment.embedding_space + assert embedding_space is not None + dimension = embedding_space.dimension + deployment_settings = serving._deployment_settings + manifests = serving.manifests + + actor_subject = f"postgres-lab-race-{first_writer}" + first_writer_has_locks = threading.Event() + allow_first_writer_to_commit = threading.Event() + second_writer_requested_job_lock = threading.Event() + + def make_serving(session: Session) -> ServingService: + return ServingService(session, deployment_settings, manifests, payload_store) + + def complete(service: ServingService) -> str: + deployment = service.repo.deployment(deployment_id) + node = service.repo.node(node_id) + assert deployment is not None and node is not None + vector = [1.0] + [0.0] * (dimension - 1) + service.complete_job( + job_id, + node, + AgentServingJobComplete( + lease_token=token, + worker_instance_id="postgres-race-worker", + state="warm", + result={"vectors": [vector], "count": 1, "normalized": True}, + metrics={"inference_time_ms": 8.0}, + health={}, + runtime_facts={}, + ), + ) + return "completed" + + def write(kind: str, *, hold_commit: bool) -> str: + with Session(engine) as session: + service = make_serving(session) + original_commit = session.commit + + def commit_after_gate() -> None: + if hold_commit: + first_writer_has_locks.set() + if not allow_first_writer_to_commit.wait(timeout=20): + raise RuntimeError("timed out holding the first LAB writer locks") + original_commit() + + session.commit = commit_after_gate # type: ignore[method-assign] + if kind == "cancel": + return LabRunService(service).cancel( + run_id, actor_subject=actor_subject + ).status + return complete(service) + + @event.listens_for(engine, "before_cursor_execute") + def observe_second_job_lock( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + normalized = statement.lower() + if ( + threading.current_thread().name.startswith("lab-race-second") + and "serving_jobs" in normalized + and "for update" in normalized + ): + second_writer_requested_job_lock.set() + + second_writer = "complete" if first_writer == "cancel" else "cancel" + try: + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="lab-race-first") as first_pool: + first_future = first_pool.submit(write, first_writer, hold_commit=True) + if not first_writer_has_locks.wait(timeout=10): + first_future.result(timeout=1) + pytest.fail("first writer never reached commit") + with ThreadPoolExecutor( + max_workers=1, thread_name_prefix="lab-race-second" + ) as second_pool: + second_future = second_pool.submit(write, second_writer, hold_commit=False) + if not second_writer_requested_job_lock.wait(timeout=10): + second_future.result(timeout=1) + pytest.fail("second writer never requested the serving-job lock") + with pytest.raises(FutureTimeoutError): + second_future.result(timeout=0.25) + allow_first_writer_to_commit.set() + first_future.result(timeout=30) + second_future.result(timeout=30) + finally: + allow_first_writer_to_commit.set() + event.remove(engine, "before_cursor_execute", observe_second_job_lock) + + expected_status = "cancelled" if first_writer == "cancel" else "completed" + with Session(engine) as session: + persisted_run = session.get(LabExecutionRun, run_id) + persisted_job = session.get(ServingJob, job_id) + gateway = session.scalar( + select(GatewayRequest).where(GatewayRequest.id == persisted_run.gateway_request_id) + ) if persisted_run else None + assert persisted_run is not None and persisted_job is not None and gateway is not None + assert persisted_run.status == expected_status + assert persisted_job.status == expected_status + assert gateway.status == expected_status + assert persisted_job.lease_token_hash is None + if first_writer == "cancel": + assert persisted_run.cancellation_requested_at is not None + assert persisted_run.cancellation_acknowledged_at is not None + assert persisted_run.output_metadata is None + assert persisted_job.result_reference is None + assert persisted_job.result_summary["cancelled"] is True + else: + assert persisted_run.cancellation_requested_at is None + assert persisted_run.cancellation_acknowledged_at is None + assert persisted_run.output_metadata is not None + assert persisted_job.result_reference is None diff --git a/backend/tests/test_lab_execution_recovery_compatibility.py b/backend/tests/test_lab_execution_recovery_compatibility.py new file mode 100644 index 0000000..0ebbf48 --- /dev/null +++ b/backend/tests/test_lab_execution_recovery_compatibility.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import copy + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import Base +from modelforge_api.services.recovery_fingerprint import ( + ALERT_NOTIFICATION_SCHEMA, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + fingerprint_session, +) +from tests.recovery_fixtures import recreate_0038_benchmark_runs + +LAB_EXECUTION_SCHEMA = "20260924_0038" +LAB_EXECUTION_TABLE = "lab_execution_runs" + + +def _as_schema_0037(fingerprint: dict[str, object]) -> dict[str, object]: + source = copy.deepcopy(fingerprint) + tables = source["tables"] + assert isinstance(tables, dict) + tables.pop(LAB_EXECUTION_TABLE, None) + serving_jobs = tables.get("serving_jobs") + if isinstance(serving_jobs, dict): + projection = serving_jobs.pop("lab_execution_projection", None) + if isinstance(projection, dict): + base = projection.get("base") + assert isinstance(base, dict) + tables["serving_jobs"] = base + physical_tables = source.get("physical_tables") + assert isinstance(physical_tables, list) + source["physical_tables"] = [ + name for name in physical_tables if name != LAB_EXECUTION_TABLE + ] + return source + + +def _database_fingerprint() -> dict[str, object]: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with engine.begin() as connection: + recreate_0038_benchmark_runs(connection) + connection.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32))")) + connection.execute(text("INSERT INTO alembic_version VALUES (:revision)"), + {"revision": LAB_EXECUTION_SCHEMA}) + with Session(engine) as session: + result = fingerprint_session(session) + engine.dispose() + return result + + +def _schema_0037_fingerprint_with_future_job_link() -> dict[str, object]: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with engine.begin() as connection: + recreate_0038_benchmark_runs(connection) + connection.execute(text("DROP TABLE lab_execution_runs")) + connection.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32))")) + connection.execute(text("INSERT INTO alembic_version VALUES ('20260923_0037')")) + with Session(engine) as session: + result = fingerprint_session(session) + engine.dispose() + return result + + +def test_0037_backup_upgrades_only_with_empty_lab_history_and_unlinked_jobs() -> None: + restored = _database_fingerprint() + source = _as_schema_0037(restored) + + contract = fingerprint_compatibility( + source, restored, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + + assert contract.compatible + assert contract.mode.startswith("LAB_EXECUTION_UPGRADE|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + + populated = copy.deepcopy(restored) + populated["tables"][LAB_EXECUTION_TABLE]["row_count"] = 1 + populated["tables"][LAB_EXECUTION_TABLE]["digest"] = "f" * 64 + rejected_history = fingerprint_compatibility( + source, populated, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + assert not rejected_history.compatible + + linked = copy.deepcopy(restored) + linked["tables"]["serving_jobs"]["lab_execution_projection"]["run_link_count"] = 1 + rejected_link = fingerprint_compatibility( + source, linked, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + assert not rejected_link.compatible + + wrong_empty_link_digest = copy.deepcopy(restored) + wrong_empty_link_digest["tables"]["serving_jobs"]["lab_execution_projection"][ + "run_link_digest" + ] = "f" * 64 + rejected_digest = fingerprint_compatibility( + source, wrong_empty_link_digest, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + assert not rejected_digest.compatible + + +def test_0037_upgrade_preserves_preexisting_jobs_with_null_lab_links() -> None: + restored = _database_fingerprint() + serving_jobs = restored["tables"]["serving_jobs"] + projection = serving_jobs["lab_execution_projection"] + serving_jobs["row_count"] = 1 + serving_jobs["digest"] = "a" * 64 + projection["base"]["row_count"] = 1 + projection["base"]["digest"] = "b" * 64 + source = _as_schema_0037(restored) + + contract = fingerprint_compatibility( + source, restored, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + + assert contract.compatible + assert contract.mode.startswith("LAB_EXECUTION_UPGRADE|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + + +def test_pre_0038_source_with_future_lab_table_fails_closed() -> None: + fingerprint = _database_fingerprint() + + contract = fingerprint_compatibility( + fingerprint, fingerprint, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + + assert not contract.compatible + assert "future LAB" in contract.reason + + +def test_pre_0038_source_with_future_job_link_column_fails_closed() -> None: + source = _schema_0037_fingerprint_with_future_job_link() + restored = _database_fingerprint() + + contract = fingerprint_compatibility( + source, restored, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + + assert source["tables"]["serving_jobs"]["lab_execution_schema_observed"] is True + assert not contract.compatible + assert "future LAB" in contract.reason + + +def test_0038_strict_restore_preserves_lab_run_history_and_job_links() -> None: + source = _database_fingerprint() + restored = copy.deepcopy(source) + + contract = fingerprint_compatibility( + source, restored, source_schema_revision=LAB_EXECUTION_SCHEMA + ) + + assert contract.compatible + assert contract.mode.startswith("LAB_EXECUTION_STRICT|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + + altered = copy.deepcopy(restored) + altered["tables"][LAB_EXECUTION_TABLE]["digest"] = "f" * 64 + rejected = fingerprint_compatibility( + source, altered, source_schema_revision=LAB_EXECUTION_SCHEMA + ) + assert not rejected.compatible + + +def test_pre_0038_fingerprint_rejects_lab_history_or_job_links() -> None: + current = _database_fingerprint() + populated = copy.deepcopy(current) + populated["tables"][LAB_EXECUTION_TABLE]["row_count"] = 1 + populated["tables"][LAB_EXECUTION_TABLE]["digest"] = "f" * 64 + + rejected_history = fingerprint_compatibility( + populated, populated, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + assert not rejected_history.compatible + + linked = copy.deepcopy(current) + linked["tables"]["serving_jobs"]["lab_execution_projection"]["run_link_count"] = 1 + rejected_link = fingerprint_compatibility( + linked, linked, source_schema_revision=ALERT_NOTIFICATION_SCHEMA + ) + assert not rejected_link.compatible + + malformed_projection = copy.deepcopy(current) + malformed_projection["tables"]["serving_jobs"]["lab_execution_projection"][ + "base" + ] = {"row_count": 0, "digest": "bad"} + rejected_shape = fingerprint_compatibility( + malformed_projection, + malformed_projection, + source_schema_revision=LAB_EXECUTION_SCHEMA, + ) + assert not rejected_shape.compatible diff --git a/backend/tests/test_lab_execution_runs.py b/backend/tests/test_lab_execution_runs.py new file mode 100644 index 0000000..9e6860c --- /dev/null +++ b/backend/tests/test_lab_execution_runs.py @@ -0,0 +1,585 @@ +import hashlib +import json +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session + +from modelforge_api.domain.lab import LabExecutionStartRequest +from modelforge_api.domain.serving import ( + AgentServingJobCancellationAck, + AgentServingJobCancellationCheck, + AgentServingJobComplete, +) +from modelforge_api.persistence.models import ( + Base, + GatewayRequest, + LabExecutionRun, + ServingJob, +) +from modelforge_api.services.lab_runs import LabRunService +from modelforge_api.services.serving import ServingError +from tests.test_dispatch_context import operator_fixture + + +@pytest.fixture +def session() -> Iterator[Session]: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + engine.dispose() + + +def make_start_request(request, selected) -> LabExecutionStartRequest: + return LabExecutionStartRequest( + target=request.target, + input=request.input, + limits=request.limits, + deployment_id=selected.capability_deployment_id, + experiment_route_id=selected.id, + ) + + +def test_same_actor_key_and_fingerprint_returns_one_durable_run(session: Session) -> None: + serving, preflight, selected = operator_fixture(session) + service = LabRunService(serving) + request = make_start_request(preflight, selected) + + first = service.start(request, actor_subject="operator-test", idempotency_key="run-001") + retry = service.start(request, actor_subject="operator-test", idempotency_key="run-001") + + assert retry.id == first.id + assert retry.status == "accepted" + assert session.scalar(select(func.count()).select_from(LabExecutionRun)) == 1 + assert "private input content" not in first.model_dump_json() + assert "payload_reference" not in first.model_dump() + + +def test_reused_actor_key_with_different_input_is_a_conflict(session: Session) -> None: + serving, preflight, selected = operator_fixture(session) + service = LabRunService(serving) + request = make_start_request(preflight, selected) + changed = LabExecutionStartRequest.model_validate( + { + **request.model_dump(mode="json"), + "input": {"capability": "rag.embedding", "payload": {"input": "different"}}, + } + ) + service.start(request, actor_subject="operator-test", idempotency_key="run-002") + + with pytest.raises(ServingError) as raised: + service.start(changed, actor_subject="operator-test", idempotency_key="run-002") + + assert raised.value.status_code == 409 + assert raised.value.code == "LAB_IDEMPOTENCY_CONFLICT" + assert session.scalar(select(func.count()).select_from(LabExecutionRun)) == 1 + + +def test_run_status_and_cancel_are_scoped_to_authenticated_actor(session: Session) -> None: + serving, preflight, selected = operator_fixture(session) + service = LabRunService(serving) + run = service.start( + make_start_request(preflight, selected), + actor_subject="operator-owner", + idempotency_key="run-003", + ) + + for action in ( + lambda: service.get(run.id, actor_subject="operator-other"), + lambda: service.cancel(run.id, actor_subject="operator-other"), + ): + with pytest.raises(ServingError) as raised: + action() + assert raised.value.status_code == 404 + assert raised.value.code == "LAB_RUN_NOT_FOUND" + + assert service.get(run.id, actor_subject="operator-owner").id == run.id + cancelled = service.cancel(run.id, actor_subject="operator-owner") + assert cancelled.status == "cancelled" + assert service.cancel(run.id, actor_subject="operator-owner").status == "cancelled" + + +def _claimed_run(session: Session, actor: str, key: str): + serving, preflight, selected = operator_fixture(session) + run_service = LabRunService(serving) + run_response = run_service.start( + make_start_request(preflight, selected), actor_subject=actor, idempotency_key=key + ) + run = session.get(LabExecutionRun, run_response.id) + assert run is not None + gateway = session.get(GatewayRequest, run.gateway_request_id) + assert gateway is not None + token = "t" * 32 + job = ServingJob( + capability_deployment_id=selected.capability_deployment_id, + compute_node_id=selected.deployment.compute_node_id, + gateway_request_id=gateway.id, + lab_execution_run_id=run.id, + operation="invoke", + status="running", + priority="lab", + idempotency_key=f"test-{key}", + payload_reference=run.payload_reference, + attempt_count=1, + lease_token_hash=hashlib.sha256(token.encode()).hexdigest(), + lease_expires_at=datetime.now(UTC) + timedelta(minutes=2), + result_summary={}, + started_at=datetime.now(UTC), + ) + session.add(job) + run.status = "running" + run.started_at = datetime.now(UTC) + run.heartbeat_at = datetime.now(UTC) + gateway.status = "running" + session.commit() + return serving, run_service, run, job, selected, token + + +def _queued_run_with_job(session: Session, actor: str, key: str): + serving, preflight, selected = operator_fixture(session) + run_service = LabRunService(serving) + response = run_service.start( + make_start_request(preflight, selected), actor_subject=actor, idempotency_key=key + ) + run = session.get(LabExecutionRun, response.id) + assert run is not None + gateway = session.get(GatewayRequest, run.gateway_request_id) + assert gateway is not None + job = ServingJob( + capability_deployment_id=selected.capability_deployment_id, + compute_node_id=selected.deployment.compute_node_id, + gateway_request_id=gateway.id, + lab_execution_run_id=run.id, + operation="invoke", + status="queued", + priority="lab", + idempotency_key=f"queued-{key}", + payload_reference=run.payload_reference, + attempt_count=0, + result_summary={}, + ) + run.status = "queued" + gateway.status = "queued" + session.add(job) + session.commit() + return serving, run_service, run, job, selected, preflight + + +def test_cancel_racing_with_worker_completion_discards_vectors_and_acknowledges( + session: Session, +) -> None: + serving, run_service, run, job, selected, token = _claimed_run( + session, "operator-race", "run-race" + ) + requested = run_service.cancel(run.id, actor_subject="operator-race") + assert requested.status == "cancel_requested" + + deployment = serving.repo.deployment(selected.capability_deployment_id) + assert deployment is not None + node = serving.repo.node(deployment.compute_node_id) + space = serving.repo.embedding_space(deployment.embedding_space_id) + assert node is not None and space is not None + vector = [1.0] + [0.0] * (space.dimension - 1) + serving.complete_job( + job.id, + node, + AgentServingJobComplete( + lease_token=token, + worker_instance_id="worker-cancel-race", + state="warm", + result={"vectors": [vector], "count": 1, "normalized": True}, + metrics={"inference_time_ms": 12.0, "private_metric": "must-not-persist"}, + health={}, + runtime_facts={"private_runtime_fact": "must-not-persist"}, + ), + ) + + session.refresh(run) + session.refresh(job) + assert run.status == "cancelled" + assert run.cancellation_acknowledged_at is not None + assert run.output_metadata is None + assert job.status == "cancelled" + assert job.result_reference is None + persisted = f"{run.worker_observations!r} {job.result_summary!r}" + assert "private_metric" not in persisted + assert "private_runtime_fact" not in persisted + assert "must-not-persist" not in persisted + assert serving.payload_store.get(run.payload_reference) is None + + +def test_successful_lab_completion_persists_hash_and_allowlisted_metadata_only( + session: Session, +) -> None: + serving, _run_service, run, job, selected, token = _claimed_run( + session, "operator-success", "run-success" + ) + deployment = serving.repo.deployment(selected.capability_deployment_id) + assert deployment is not None + node = serving.repo.node(deployment.compute_node_id) + space = serving.repo.embedding_space(deployment.embedding_space_id) + assert node is not None and space is not None + vector = [1.0] + [0.0] * (space.dimension - 1) + serving.complete_job( + job.id, + node, + AgentServingJobComplete( + lease_token=token, + worker_instance_id="worker-success", + state="warm", + result={"vectors": [vector], "count": 1, "normalized": True}, + metrics={ + "inference_time_ms": 12.0, + "gpu_uuid": "GPU-test", + "private_metric": "must-not-persist", + }, + health={}, + runtime_facts={"private_runtime_fact": "must-not-persist"}, + ), + ) + + session.refresh(run) + session.refresh(job) + assert run.status == "completed" + assert run.output_metadata is not None + assert run.output_metadata["dimension"] == space.dimension + assert run.output_metadata["item_count"] == 1 + assert len(run.output_metadata["sha256"]) == 64 + assert ( + run.output_metadata["sha256"] + == hashlib.sha256( + json.dumps([vector], sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + ) + assert run.output_metadata["content_logged"] is False + assert run.worker_observations is not None + assert run.worker_observations["source"] == "runtime-worker" + assert "private_metric" not in str(run.worker_observations) + assert "private_runtime_fact" not in str(run.worker_observations) + assert job.status == "completed" + assert job.result_reference is None + assert "vectors" not in str(job.result_summary) + gateway = session.get(GatewayRequest, run.gateway_request_id) + assert gateway is not None + persisted_evidence = gateway.decision_evidence["lab_run_evidence"] + assert persisted_evidence["status"] == "completed" + assert persisted_evidence["output"] == run.output_metadata + assert "private_metric" not in str(gateway.decision_evidence) + assert "private_runtime_fact" not in str(gateway.decision_evidence) + assert "vectors" not in str(gateway.decision_evidence) + assert "private input content" not in str(gateway.decision_evidence) + assert serving.payload_store.get(run.payload_reference) is None + + +def test_reconciler_dispatches_exact_cold_target_through_load_and_invoke_jobs( + session: Session, +) -> None: + serving, preflight, selected = operator_fixture(session) + run_service = LabRunService(serving) + created = run_service.start( + make_start_request(preflight, selected), + actor_subject="operator-reconcile", + idempotency_key="run-reconcile", + ) + + serving.reconcile() + run = session.get(LabExecutionRun, created.id) + assert run is not None + assert run.status == "preparing" + load_job = session.scalar( + select(ServingJob).where( + ServingJob.lab_execution_run_id == run.id, + ServingJob.operation == "load", + ) + ) + assert load_job is not None and load_job.status == "queued" + node = serving.repo.node(selected.deployment.compute_node_id) + assert node is not None + load_lease = serving.claim_next(node) + assert load_lease is not None and load_lease.job_id == load_job.id + assert load_lease.lab_run_id == run.id + serving.complete_job( + load_job.id, + node, + AgentServingJobComplete( + lease_token=load_lease.lease_token, + worker_instance_id="synthetic-worker", + state="warm", + result={}, + metrics={ + "baseline_vram_bytes": 1_000, + "loaded_vram_bytes": 3_000, + }, + health={"model": "healthy"}, + runtime_facts={"offline_local_only": True}, + ), + ) + + serving.reconcile() + session.refresh(run) + assert run.status == "queued" + invoke_job = session.scalar( + select(ServingJob).where( + ServingJob.lab_execution_run_id == run.id, + ServingJob.operation == "invoke", + ) + ) + assert invoke_job is not None and invoke_job.status == "queued" + invoke_lease = serving.claim_next(node) + assert invoke_lease is not None and invoke_lease.job_id == invoke_job.id + assert invoke_lease.deployment_id == selected.capability_deployment_id + assert invoke_lease.lab_run_id == run.id + assert invoke_lease.input == ["private input content"] + assert invoke_lease.expected_dimension is not None + deployment = serving.repo.deployment(selected.capability_deployment_id) + assert deployment is not None + accelerator = serving.repo.accelerator(deployment.accelerator_id) + assert accelerator is not None + vector = [1.0] + [0.0] * (invoke_lease.expected_dimension - 1) + serving.complete_job( + invoke_job.id, + node, + AgentServingJobComplete( + lease_token=invoke_lease.lease_token, + worker_instance_id="synthetic-worker", + state="warm", + result={"vectors": [vector], "count": 1, "normalized": True}, + metrics={ + "inference_time_ms": 12.0, + "gpu_uuid": accelerator.device_uuid, + "used_vram_bytes": 3_000, + "process_used_vram_bytes": 2_000, + "free_vram_bytes": 7_000, + "gpu_utilization_percent": 35, + "temperature_c": 60, + "power_draw_w": 90, + "private_metric": "must-not-persist", + }, + health={"model": "healthy"}, + runtime_facts={"private_runtime_fact": "must-not-persist"}, + ), + ) + + session.refresh(run) + session.refresh(invoke_job) + assert run.status == "completed" + assert run.output_metadata is not None + assert run.output_metadata["item_count"] == 1 + assert len(run.output_metadata["sha256"]) == 64 + assert run.worker_observations is not None + assert run.worker_observations["device_matches_target"] is True + assert "private_metric" not in str(run.worker_observations) + assert "private_runtime_fact" not in str(run.worker_observations) + assert invoke_job.result_reference is None + assert "vectors" not in str(invoke_job.result_summary) + assert ( + "private input content" + not in run_service.get(run.id, actor_subject="operator-reconcile").model_dump_json() + ) + + +def test_expired_claim_is_interrupted_and_never_reclaimed(session: Session) -> None: + serving, _run_service, run, job, selected, _token = _claimed_run( + session, "operator-stale", "run-stale" + ) + job.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + + serving.reconcile() + + session.refresh(run) + session.refresh(job) + assert run.status == "interrupted" + assert run.failure_code == "LAB_WORKER_LEASE_STALE" + assert job.status == "failed" + assert job.failure_code == "LAB_WORKER_LEASE_STALE" + node = serving.repo.node(selected.deployment.compute_node_id) + assert node is not None + assert serving.claim_next(node) is None + + +@pytest.mark.parametrize("expiry_mode", ["redis_key_missing", "durable_expiry_elapsed"]) +def test_expired_or_missing_redis_input_fails_closed_without_dispatch( + session: Session, expiry_mode: str +) -> None: + serving, preflight, selected = operator_fixture(session) + run_service = LabRunService(serving) + created = run_service.start( + make_start_request(preflight, selected), + actor_subject=f"operator-expired-{expiry_mode}", + idempotency_key=f"expired-{expiry_mode}", + ) + run = session.get(LabExecutionRun, created.id) + assert run is not None and run.payload_reference is not None + if expiry_mode == "redis_key_missing": + # Redis TTL expiry is represented by the payload store returning no key. + serving.payload_store.delete(run.payload_reference) + else: + run.input_payload_expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + + serving.reconcile() + + session.refresh(run) + gateway = session.get(GatewayRequest, run.gateway_request_id) + assert gateway is not None + assert run.status == "failed" + assert run.failure_code == "REQUEST_PAYLOAD_EXPIRED" + assert run.payload_reference is None + assert gateway.status == "failed" + assert session.scalar( + select(func.count()).select_from(ServingJob).where( + ServingJob.lab_execution_run_id == run.id + ) + ) == 0 + + +def test_claim_with_missing_redis_key_fails_run_instead_of_leaving_it_queued( + session: Session, +) -> None: + serving, _run_service, run, job, selected, _preflight = _queued_run_with_job( + session, "operator-claim-expired", "claim-expired" + ) + assert run.payload_reference is not None + serving.payload_store.delete(run.payload_reference) + node = serving.repo.node(selected.deployment.compute_node_id) + assert node is not None + + lease = serving.claim_next(node) + + session.refresh(run) + session.refresh(job) + assert lease is None + assert run.status == "failed" + assert run.failure_code == "REQUEST_PAYLOAD_EXPIRED" + assert job.status == "failed" + assert job.failure_code == "REQUEST_PAYLOAD_EXPIRED" + + +def test_lab_claim_rejects_another_runs_live_redis_payload(session: Session) -> None: + serving, run_service, run, job, selected, preflight = _queued_run_with_job( + session, "operator-payload-isolation", "payload-isolation" + ) + changed_request = LabExecutionStartRequest.model_validate( + { + **make_start_request(preflight, selected).model_dump(mode="json"), + "input": { + "capability": "rag.embedding", + "payload": {"input": "another run's private input"}, + }, + } + ) + other = run_service.start( + changed_request, + actor_subject="operator-payload-isolation", + idempotency_key="payload-isolation-other", + ) + assert other.id != run.id + assert other.input_summary.sha256 != run.input_sha256 + other_run_payload = session.get(LabExecutionRun, other.id) + assert other_run_payload is not None and other_run_payload.payload_reference is not None + job.payload_reference = other_run_payload.payload_reference + session.commit() + node = serving.repo.node(selected.deployment.compute_node_id) + assert node is not None + + lease = serving.claim_next(node) + + session.refresh(run) + session.refresh(job) + session.refresh(other_run_payload) + assert lease is None + assert run.status == "failed" + assert run.failure_code == "LAB_PAYLOAD_REFERENCE_MISMATCH" + assert job.failure_code == "LAB_PAYLOAD_REFERENCE_MISMATCH" + assert other_run_payload.status == "accepted" + + +def test_worker_poll_renews_lease_and_ack_completes_cancel_transition(session: Session) -> None: + serving, run_service, run, job, selected, token = _claimed_run( + session, "operator-ack", "run-ack" + ) + run.timeout_seconds = 300 + job.lease_expires_at = datetime.now(UTC) + timedelta(seconds=30) + session.commit() + node = serving.repo.node(selected.deployment.compute_node_id) + assert node is not None + before = job.lease_expires_at + requested = run_service.cancel(run.id, actor_subject="operator-ack") + assert requested.status == "cancel_requested" + + state = serving.check_lab_cancellation( + job.id, node, AgentServingJobCancellationCheck(lease_token=token) + ) + assert state.cancel_requested is True + session.refresh(job) + assert job.lease_expires_at is not None and job.lease_expires_at >= before + serving.acknowledge_lab_cancellation( + job.id, + node, + AgentServingJobCancellationAck( + lease_token=token, + worker_instance_id="worker-ack", + state="warm", + ), + ) + + session.refresh(run) + session.refresh(job) + assert run.status == "cancelled" + assert run.cancellation_acknowledged_at is not None + assert run.worker_observations == { + "source": "runtime-worker", + "phase": "after_inference", + "availability": "cancelled", + "safe_boundary_reached": True, + "worker_instance_id": "worker-ack", + "worker_state": "warm", + } + assert job.status == "cancelled" + assert job.result_reference is None + + +def test_queued_run_cancel_prevents_worker_claim_and_deletes_ttl_input(session: Session) -> None: + serving, preflight, selected = operator_fixture(session) + run_service = LabRunService(serving) + response = run_service.start( + make_start_request(preflight, selected), + actor_subject="operator-queued-cancel", + idempotency_key="queued-cancel", + ) + run = session.get(LabExecutionRun, response.id) + assert run is not None + payload_reference = run.payload_reference + gateway = session.get(GatewayRequest, run.gateway_request_id) + assert gateway is not None + job = ServingJob( + capability_deployment_id=selected.capability_deployment_id, + compute_node_id=selected.deployment.compute_node_id, + gateway_request_id=gateway.id, + lab_execution_run_id=run.id, + operation="invoke", + status="queued", + priority="lab", + idempotency_key="queued-cancel-job", + payload_reference=run.payload_reference, + attempt_count=0, + result_summary={}, + ) + session.add(job) + run.status = "queued" + gateway.status = "queued" + session.commit() + + cancelled = run_service.cancel(run.id, actor_subject="operator-queued-cancel") + + session.refresh(job) + assert cancelled.status == "cancelled" + assert job.status == "cancelled" + assert job.lease_token_hash is None + assert "input_payload_expires_at" in cancelled.model_dump() + assert payload_reference is not None + assert serving.payload_store.get(payload_reference) is None diff --git a/backend/tests/test_lab_preflight.py b/backend/tests/test_lab_preflight.py new file mode 100644 index 0000000..5e29eaf --- /dev/null +++ b/backend/tests/test_lab_preflight.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError +from sqlalchemy import create_engine, func, select, update +from sqlalchemy.orm import Session + +from modelforge_api.domain.lab import LabPreflightRequest +from modelforge_api.persistence.models import Base, DeploymentCandidate, RuntimeProfile +from modelforge_api.services.lab import FAMILY_MODALITIES, LabPreflightService +from tests.test_serving import setup_m5 + + +@pytest.fixture +def session() -> Iterator[Session]: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as db: + yield db + engine.dispose() + + +INPUTS: dict[str, dict[str, Any]] = { + "rag.embedding": {"input": "private input content"}, + "rag.reranking": { + "query": "private query", + "documents": [{"id": "a", "text": "private document"}], + "top_n": 1, + }, + "document.ocr": {"content_base64": "cHJpdmF0ZQ==", "media_type": "image/png"}, + "vision.embedding": {"items": [{"text": "private visual text"}]}, + "speech.transcription": {"audio_base64": "cHJpdmF0ZQ=="}, +} + + +def setup_lab( + db: Session, capability: str = "rag.embedding" +) -> tuple[LabPreflightService, LabPreflightRequest]: + serving, candidate_response, _ = setup_m5(db) + svc = LabPreflightService(db, serving.settings) + candidate = db.get(DeploymentCandidate, candidate_response.id) + assert candidate is not None + profile = svc.runtime.repo.profile(candidate.runtime_profile_id) + probe = svc.runtime.repo.probe(candidate.runtime_probe_id) + assert profile is not None and probe is not None + # Synthetic completed family evidence, not a hardware qualification. + # Construct synthetic family fixture below ORM sealing; production immutability remains tested elsewhere. + db.execute( + update(RuntimeProfile) + .where(RuntimeProfile.id == profile.id) + .values(modality=FAMILY_MODALITIES[capability]) + ) + if capability == "rag.reranking": + probe.inference_result = {"output_type": "ranked_scores", "finite": True} + probe.runtime_facts = {**probe.runtime_facts, "adapter": "qwen3_reranker"} + elif capability in {"document.ocr", "speech.transcription"}: + probe.inference_result = { + "output_type": "ocr_text" if capability == "document.ocr" else "transcription", + "output_valid": True, + } + db.commit() + request = LabPreflightRequest.model_validate( + { + "target": { + "deployment_candidate_id": candidate.id, + "artifact_set_id": candidate.artifact_set_id, + "runtime_profile_id": candidate.runtime_profile_id, + "compute_node_id": candidate.compute_node_id, + "execution_approval_id": probe.execution_approval_id, + }, + "input": {"capability": capability, "payload": INPUTS[capability]}, + } + ) + return svc, request + + +def table_counts(db: Session) -> dict[str, int]: + return { + name: db.scalar(select(func.count()).select_from(table)) or 0 + for name, table in Base.metadata.tables.items() + } + + +@pytest.mark.parametrize("capability", INPUTS) +def test_all_families_return_redacted_read_only_preflight( + session: Session, capability: str +) -> None: + svc, request = setup_lab(session, capability) + before = table_counts(session) + response = svc.preflight(request) + assert response.checks_passed, response.blockers + assert response.binding is not None + assert response.binding.deployment_candidate_id == request.target.deployment_candidate_id + assert response.binding.capability == capability + assert response.permission_granted is False and response.execution_started is False + assert len(response.input_summary.sha256) == 64 + assert response.input_summary.item_count == 1 + assert response.input_summary.encoded_bytes > 0 + encoded = response.model_dump_json() + assert "private" not in encoded and "cHJpdmF0ZQ==" not in encoded + assert "Reviewed exact" not in encoded + assert table_counts(session) == before + assert not session.new and not session.dirty and not session.deleted + + +@pytest.mark.parametrize( + "field", + [ + "deployment_candidate_id", + "artifact_set_id", + "runtime_profile_id", + "compute_node_id", + "execution_approval_id", + ], +) +def test_target_ids_never_select_another_entity(session: Session, field: str) -> None: + svc, request = setup_lab(session) + data = request.model_dump() + data["target"][field] = uuid.uuid4() + response = svc.preflight(LabPreflightRequest.model_validate(data)) + assert not response.checks_passed and response.binding is None + + +@pytest.mark.parametrize( + "change,code", + [ + ("revoked", "LAB_APPROVAL_STALE"), + ("expired", "LAB_APPROVAL_STALE"), + ("approval_fingerprint", "LAB_APPROVAL_STALE"), + ("assessment", "ASSESSMENT_STALE_OR_BLOCKED"), + ("profile_fingerprint", "ASSESSMENT_STALE_OR_BLOCKED"), + ("production", "CANDIDATE_NOT_LAB_READY"), + ("channel", "CANDIDATE_NOT_LAB_READY"), + ("offline", "NODE_NOT_READY"), + ("lab_ineligible", "NODE_NOT_READY"), + ("assessment_blocker", "ASSESSMENT_STALE_OR_BLOCKED"), + ("assessment_status", "ASSESSMENT_STALE_OR_BLOCKED"), + ("node_protocol", "NODE_NOT_READY"), + ("modality", "CAPABILITY_PROFILE_MISMATCH"), + ("probe", "PROBE_NOT_COMPLETED"), + ("probe_identity", "EVIDENCE_IDENTITY_MISMATCH"), + ("invalid_output", "PROBE_OUTPUT_INVALID"), + ("wrong_dimension", "PROBE_OUTPUT_INVALID"), + ("gpu", "PROBED_ACCELERATOR_MISSING"), + ("remote_code", "RUNTIME_POLICY_UNSUPPORTED"), + ], +) +def test_stale_and_incoherent_evidence_blocks(session: Session, change: str, code: str) -> None: + svc, request = setup_lab(session) + candidate = session.get(DeploymentCandidate, request.target.deployment_candidate_id) + assert candidate is not None + repo = svc.runtime.repo + approval = repo.approval(request.target.execution_approval_id) + assessment = repo.assessment(candidate.compatibility_assessment_id) + profile = repo.profile(candidate.runtime_profile_id) + node = repo.node(candidate.compute_node_id) + probe = repo.probe(candidate.runtime_probe_id) + assert approval and assessment and profile and node and probe + if change == "revoked": + approval.revoked_at = datetime.now(UTC) + elif change == "expired": + approval.expires_at = datetime.now(UTC) - timedelta(seconds=1) + elif change == "approval_fingerprint": + approval.evidence_fingerprint = "0" * 64 + elif change == "assessment": + assessment.stale = True + elif change == "profile_fingerprint": + session.execute( + update(RuntimeProfile) + .where(RuntimeProfile.id == profile.id) + .values(fingerprint="0" * 64) + ) + elif change == "production": + candidate.production = True + elif change == "channel": + candidate.channel = "stable" + elif change == "offline": + node.liveness_state = "offline" + elif change == "lab_ineligible": + node.lab_eligible = False + elif change == "assessment_blocker": + assessment.blockers = ["private reason"] + elif change == "assessment_status": + assessment.status = "future_unknown" + elif change == "node_protocol": + node.agent_capabilities = [] + elif change == "modality": + session.execute( + update(RuntimeProfile).where(RuntimeProfile.id == profile.id).values(modality="audio") + ) + elif change == "probe": + probe.status = "running" + elif change == "probe_identity": + probe.execution_approval_id = uuid.uuid4() + elif change == "invalid_output": + probe.inference_result = {"dimension": True, "finite": True} + elif change == "wrong_dimension": + probe.inference_result = {"dimension": 768, "finite": True} + elif change == "gpu": + probe.runtime_facts = {"gpu_uuid": "missing"} + elif change == "remote_code": + session.execute( + update(RuntimeProfile) + .where(RuntimeProfile.id == profile.id) + .values(trust_remote_code=True) + ) + session.commit() + response = svc.preflight(request) + assert not response.checks_passed and code in response.blockers + assert not response.execution_started and not response.permission_granted + + +@pytest.mark.parametrize( + "capability,payload", + [ + ("rag.embedding", {"input": []}), + ("rag.reranking", {"query": "x", "documents": [], "top_n": 1}), + ("document.ocr", {"content_base64": "!!!", "media_type": "image/png"}), + ( + "vision.embedding", + {"items": [{"text": "x", "image_base64": "eA==", "media_type": "image/png"}]}, + ), + ("speech.transcription", {"audio_base64": "!!!"}), + ], +) +def test_malformed_family_input_rejected( + session: Session, capability: str, payload: dict[str, Any] +) -> None: + _, request = setup_lab(session) + data = request.model_dump() + data["input"] = {"capability": capability, "payload": payload} + with pytest.raises(ValidationError): + LabPreflightRequest.model_validate(data) + + +@pytest.mark.parametrize( + "limits", + [ + {"timeout_seconds": 301}, + {"timeout_seconds": 4}, + {"timeout_seconds": 5, "queue_timeout_seconds": 6}, + {"queue_timeout_seconds": 0}, + {"retention_seconds": 3601}, + {"max_output_bytes": 1048577}, + {"concurrency": 2}, + {"timeout_seconds": True}, + ], +) +def test_budget_limits_are_bounded(session: Session, limits: dict[str, Any]) -> None: + _, request = setup_lab(session) + data = request.model_dump() + data["limits"].update(limits) + with pytest.raises(ValidationError): + LabPreflightRequest.model_validate(data) + + +def test_embedding_server_limits_and_payload_limit(session: Session) -> None: + svc, request = setup_lab(session) + data = request.model_dump() + data["input"]["payload"]["input"] = ["x" * 9000] * 9 + response = svc.preflight(LabPreflightRequest.model_validate(data)) + assert {"INPUT_LIMIT_EXCEEDED", "PAYLOAD_LIMIT_EXCEEDED"}.issubset(response.blockers) + + +def test_preflight_rechecks_evidence_and_does_not_accept_reason(session: Session) -> None: + svc, request = setup_lab(session) + assert svc.preflight(request).checks_passed + approval = svc.runtime.repo.approval(request.target.execution_approval_id) + assert approval is not None + approval.revoked_at = datetime.now(UTC) + session.commit() + assert "LAB_APPROVAL_STALE" in svc.preflight(request).blockers + with pytest.raises(ValidationError): + LabPreflightRequest.model_validate({**request.model_dump(), "reason": "secret reason"}) + + +def test_preflight_uses_injected_manifest_root(session: Session, tmp_path: Path) -> None: + svc, request = setup_lab(session) + isolated_settings = svc.settings.model_copy(update={"config_root": tmp_path}) + response = LabPreflightService(session, isolated_settings).preflight(request) + assert "CAPABILITY_CONTRACT_UNAVAILABLE" in response.blockers + assert not response.checks_passed diff --git a/backend/tests/test_lab_preflight_api.py b/backend/tests/test_lab_preflight_api.py new file mode 100644 index 0000000..4c4cb91 --- /dev/null +++ b/backend/tests/test_lab_preflight_api.py @@ -0,0 +1,80 @@ +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from modelforge_api.api.authorization import ( + Principal, + PrincipalRole, + authenticate_operator_principal, +) +from modelforge_api.api.routes.lab import get_lab_service, router +from modelforge_api.domain.lab import LabInputSummary, LabLimits, LabPreflightResponse +from modelforge_api.main import validation_error + + +def request_body() -> dict: + return { + "target": {name: str(uuid4()) for name in ( + "deployment_candidate_id", "artifact_set_id", "runtime_profile_id", + "compute_node_id", "execution_approval_id", + )}, + "input": {"capability": "rag.embedding", "payload": {"input": "private-input-canary"}}, + } + + +def harness(role: PrincipalRole | None) -> tuple[TestClient, Mock]: + app = FastAPI() + app.include_router(router) + app.add_exception_handler(RequestValidationError, validation_error) # type: ignore[arg-type] + + def principal() -> Principal: + if role is None: + raise HTTPException(status_code=401, detail="Authentication required") + return Principal("test-operator", role, "test") + + service = Mock() + service.preflight.return_value = LabPreflightResponse( + input_summary=LabInputSummary(sha256="a" * 64, item_count=1, encoded_bytes=40), + limits=LabLimits(), blockers=["CANDIDATE_NOT_FOUND"], checks_passed=False, + ) + app.dependency_overrides[authenticate_operator_principal] = principal + app.dependency_overrides[get_lab_service] = lambda: service + return TestClient(app), service + + +@pytest.mark.parametrize("role,status", [ + (None, 401), (PrincipalRole.VIEWER, 403), + (PrincipalRole.OPERATOR, 200), (PrincipalRole.ADMIN, 200), +]) +def test_preflight_requires_operator_without_execution(role: PrincipalRole | None, status: int) -> None: + client, service = harness(role) + response = client.post("/api/v1/lab/preflight", json=request_body()) + assert response.status_code == status + assert service.preflight.call_count == int(status == 200) + if status == 200: + assert response.json()["execution_started"] is False + assert response.json()["permission_granted"] is False + assert "private-input-canary" not in response.text + + +@pytest.mark.parametrize("location", ["discriminator", "payload", "extra"]) +def test_validation_never_reflects_payload_or_discriminator(location: str) -> None: + client, service = harness(PrincipalRole.OPERATOR) + body = request_body() + canary = "sensitive-lab-validation-canary" + if location == "discriminator": + body["input"]["capability"] = canary + elif location == "payload": + body["input"]["payload"]["input"] = {canary: canary} + else: + body[canary] = canary + response = client.post("/api/v1/lab/preflight", json=body) + assert response.status_code == 422 + assert canary not in response.text + assert "private-input-canary" not in response.text + assert response.json()["error"]["code"] == "request_validation_failed" + service.preflight.assert_not_called() diff --git a/backend/tests/test_lifecycle_m12.py b/backend/tests/test_lifecycle_m12.py index 0ec80aa..1c1968f 100644 --- a/backend/tests/test_lifecycle_m12.py +++ b/backend/tests/test_lifecycle_m12.py @@ -2,13 +2,18 @@ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta +from types import SimpleNamespace import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine, delete, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from starlette.requests import Request +from modelforge_api.api.authorization import Principal, PrincipalRole +from modelforge_api.api.routes.lifecycle import get_lifecycle_service +from modelforge_api.api.routes.lifecycle import observe_canary as observe_canary_route from modelforge_api.domain.lifecycle_contracts import ( ApprovalDecision, ApprovalPolicyCreate, @@ -31,12 +36,14 @@ from modelforge_api.persistence.models import ( ArtifactLocationRemovalRecord, ArtifactSet, ArtifactSetMember, + AuditEvent, Base, CapabilityDeployment, CapabilityResourceEnvelope, ComputeNode, DeploymentCandidate, LifecycleApprovalRequest, + LifecycleCanaryRun, LifecycleEvent, LifecyclePolicyRevision, LifecyclePromotionPlan, @@ -48,6 +55,7 @@ from modelforge_api.persistence.models import ( RuntimeProfile, StorageRoot, ) +from modelforge_api.services import audit as audit_service from modelforge_api.services.lifecycle import LifecycleError, LifecycleService from modelforge_api.settings import Settings, get_settings from tests.test_runtime import setup_runtime @@ -402,6 +410,128 @@ def test_canary_auto_abort_restores_snapshot(session: Session) -> None: ) +@pytest.mark.parametrize("scope_change", [ + "subject_environment", "subject_target", "approval_environment", "approval_subject", + "approval_target", "approval_ref", "approval_transition", "policy_scope", "plan_desired", +]) +@pytest.mark.parametrize("legacy_ready", [False, True]) +def test_manual_canary_rejects_persisted_production_scope( + session: Session, scope_change: str, legacy_ready: bool, +) -> None: + service, subject, plan = approved_lab_plan(session) + operation = service.execute_plan( + plan.id, + PlanExecutionCreate( + executor="executor-a", idempotency_key="scope-test", + expected_subject_version=subject.version, + ), + ) + stored_plan = session.get(LifecyclePromotionPlan, plan.id) + assert stored_plan is not None + approval = session.get(LifecycleApprovalRequest, stored_plan.approval_request_id) + assert approval is not None + changes = { + "subject_environment": (LifecycleSubject, subject.id, {"environment": "PRODUCTION"}), + "subject_target": (LifecycleSubject, subject.id, {"target_type": "CAPABILITY"}), + "approval_environment": ( + LifecycleApprovalRequest, approval.id, {"environment": "PRODUCTION"}, + ), + "approval_subject": (LifecycleApprovalRequest, approval.id, {"subject_id": None}), + "approval_target": ( + LifecycleApprovalRequest, approval.id, {"target_type": "CAPABILITY"}, + ), + "approval_ref": (LifecycleApprovalRequest, approval.id, {"target_ref": "another"}), + "approval_transition": ( + LifecycleApprovalRequest, approval.id, {"requested_transition": "STABLE"}, + ), + "policy_scope": ( + LifecyclePolicyRevision, approval.policy_revision_id, {"scope": "PRODUCTION"}, + ), + "plan_desired": (LifecyclePromotionPlan, plan.id, {"desired_state": "STABLE"}), + } + model, identity, values = changes[scope_change] + session.execute(update(model).where(model.id == identity).values(**values)) + if legacy_ready: + session.execute(update(LifecycleCanaryRun).where( + LifecycleCanaryRun.operation_id == operation.id, + ).values(status="READY_FOR_PROMOTION_REVIEW", request_count=2, result={})) + session.commit() + before = service.operation(operation.id).model_dump(mode="json") + before_events = list(session.scalars(select(LifecycleEvent.id))) + before_audit = list(session.scalars(select(AuditEvent.id))) + with pytest.raises(LifecycleError) as denied: + if legacy_ready: + service.commit_operation(operation.id, ApprovalDecision( + actor="executor-a", reason="Legacy ready observation is not authoritative", + )) + else: + service.observe_canary(operation.id, CanaryObservation( + request_count=2, error_count=0, latency_p95_ms=10, + capability_health="HEALTHY", scheduler_ready=True, + )) + assert denied.value.status_code == 409 + assert denied.value.code == "MANUAL_CANARY_REHEARSAL_ONLY" + assert service.operation(operation.id).model_dump(mode="json") == before + assert list(session.scalars(select(LifecycleEvent.id))) == before_events + assert list(session.scalars(select(AuditEvent.id))) == before_audit + + +@pytest.mark.parametrize("abort", [False, True]) +@pytest.mark.parametrize("audit_failure", [False, True]) +def test_manual_lab_canary_route_attribution_and_atomic_audit( + session: Session, monkeypatch: pytest.MonkeyPatch, abort: bool, audit_failure: bool, +) -> None: + service, subject, plan = approved_lab_plan(session) + operation = service.execute_plan(plan.id, PlanExecutionCreate( + executor="executor-a", idempotency_key="attribution-test", + expected_subject_version=subject.version, + )) + before = service.operation(operation.id).model_dump(mode="json") + before_events = list(session.scalars(select(LifecycleEvent.id))) + http_request = Request({"type": "http"}) + http_request.state.correlation_id = "manual-lab-request-42" + principal = Principal(subject="named-admin", role=PrincipalRole.ADMIN, + authentication_method="named_operator_key") + + def invoke() -> object: + return observe_canary_route(operation.id, CanaryObservation( + request_count=2, error_count=1 if abort else 0, latency_p95_ms=10, + capability_health="HEALTHY", scheduler_ready=True, + ), service, principal, http_request) + + if audit_failure: + def fail_append(*args: object, **kwargs: object) -> None: + raise RuntimeError("audit unavailable") + monkeypatch.setattr(audit_service, "_append_canonical_audit_event", fail_append) + with pytest.raises(RuntimeError, match="audit unavailable"): + invoke() + assert service.operation(operation.id).model_dump(mode="json") == before + assert list(session.scalars(select(LifecycleEvent.id))) == before_events + assert session.get(LifecycleSubject, subject.id).state == "CANARY" + else: + invoke() + audit = session.scalar(select(AuditEvent).where( + AuditEvent.action == "lifecycle.manual_lab_canary_observed", + )) + assert audit is not None + assert audit.actor_id == "named-admin" + assert audit.correlation_id == "manual-lab-request-42" + canary = session.scalar(select(LifecycleCanaryRun).where( + LifecycleCanaryRun.operation_id == operation.id, + )) + assert canary is not None + assert canary.result["provenance"] == { + "source": "MANUAL_LAB_REHEARSAL", "authoritative": False, + "actor": "named-admin", "correlation_id": "manual-lab-request-42", + } + event = session.scalar(select(LifecycleEvent).where( + LifecycleEvent.event_type == ( + "CANARY_ABORTED" if abort else "CANARY_READY_FOR_PROMOTION_REVIEW" + ), + )) + assert event is not None and event.actor == "named-admin" + + def test_restart_reconciliation_and_cas_are_conservative(session: Session) -> None: service, subject_value, plan = approved_lab_plan(session, target="restart-lab") with pytest.raises(LifecycleError, match="changed concurrently"): @@ -806,6 +936,40 @@ def test_lifecycle_routes_deny_capability_credentials() -> None: app.dependency_overrides.clear() +def test_manual_canary_http_denial_is_typed_and_preserves_authentication() -> None: + contexts = [] + + def deny_observation(operation_id: object, observation: object, *, audit_context: object) -> None: + contexts.append(audit_context) + raise LifecycleError(409, "MANUAL_CANARY_REHEARSAL_ONLY", "LAB rehearsal required") + + app.dependency_overrides[get_settings] = lambda: Settings(operator_api_key="operator-secret") + app.dependency_overrides[get_lifecycle_service] = lambda: SimpleNamespace( + observe_canary=deny_observation, + ) + try: + client = TestClient(app) + url = f"/api/v1/admin/lifecycle/operations/{uuid.uuid4()}/canary" + body = {"request_count": 2, "error_count": 0, "latency_p95_ms": 10, + "capability_health": "HEALTHY", "scheduler_ready": True} + assert client.post(url, json=body).status_code == 401 + assert client.post(url, json=body, headers={ + "X-ModelForge-Admin-Token": "mfsvc_project-key", + }).status_code == 401 + assert contexts == [] + result = client.post(url, json=body, headers={ + "X-ModelForge-Admin-Token": "operator-secret", + "X-Correlation-ID": "canary-denial-42", + }) + assert result.status_code == 409 + assert result.json()["error"]["code"] == "MANUAL_CANARY_REHEARSAL_ONLY" + assert result.json()["error"]["correlation_id"] == "canary-denial-42" + assert contexts[0].actor_id == "legacy-operator" + assert contexts[0].correlation_id == "canary-denial-42" + finally: + app.dependency_overrides.clear() + + def test_blocked_approval_is_audited_without_payload_content(session: Session) -> None: service = LifecycleService(session) result = service.request_approval( diff --git a/backend/tests/test_lifecycle_promotion_races_postgres.py b/backend/tests/test_lifecycle_promotion_races_postgres.py new file mode 100644 index 0000000..38a79c0 --- /dev/null +++ b/backend/tests/test_lifecycle_promotion_races_postgres.py @@ -0,0 +1,131 @@ +"""Real PostgreSQL qualification for approval revocation versus serving promotion.""" + +from __future__ import annotations + +import importlib +import threading +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError + +import pytest +from sqlalchemy import event, select +from sqlalchemy.orm import Session + +pytest_plugins = ("tests.test_managed_identities_postgres",) + +from modelforge_api.domain.lifecycle_contracts import ApprovalDecision +from modelforge_api.persistence.models import ( + CapabilityContract, + LifecycleApprovalRequest, + LifecycleEvent, + LifecycleOperation, +) +from modelforge_api.persistence.serving_repository import ServingRepository +from modelforge_api.services.lifecycle import LifecycleError, LifecycleService +from tests.test_lifecycle_serving import approved_plan, build_pair +from tests.test_managed_identities_postgres import IdentityDatabase + + +def test_revoked_approval_fences_inflight_serving_promotion( + identity_database: IdentityDatabase, +) -> None: + """A commit waiting on the approval row must see the revocation, not switch the route.""" + importlib.import_module("alembic.command").upgrade(identity_database.migration(), "head") + engine = identity_database.runtime + with Session(engine) as session: + pair = build_pair(session) + _, _, current, candidate, selection, _ = pair + _, plan, operation, _ = approved_plan(pair) + approval_id = plan.approval_request_id + operation_id = operation.id + contract_id = current.capability_contract_id + current_id, candidate_id = current.id, candidate.id + original_generation = selection.expected_route_generation + baseline_events = set(session.scalars(select(LifecycleEvent.id))) + + revoker_holds_lock = threading.Event() + release_revoker = threading.Event() + promoter_requested_lock = threading.Event() + + def revoke() -> None: + with Session(engine) as session: + original_commit = session.commit + + def commit_after_gate() -> None: + revoker_holds_lock.set() + if not release_revoker.wait(timeout=20): + raise RuntimeError("timed out holding the approval revocation") + original_commit() + + session.commit = commit_after_gate # type: ignore[method-assign] + LifecycleService(session).decide_approval( + approval_id, + ApprovalDecision(actor="qualification", reason="Revoke before serving promotion"), + "revoke", + ) + + def promote() -> str: + with Session(engine) as session: + try: + LifecycleService(session).commit_operation( + operation_id, + ApprovalDecision(actor="qualification", reason="Attempt reviewed route change"), + ) + except LifecycleError as error: + session.rollback() + return error.code + return "COMMITTED" + + @event.listens_for(engine, "before_cursor_execute") + def observe_approval_lock( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + if ( + threading.current_thread().name.startswith("promotion-race-promoter") + and "lifecycle_approval_requests" in statement.lower() + and "for update" in statement.lower() + ): + promoter_requested_lock.set() + + try: + with ThreadPoolExecutor( + max_workers=1, thread_name_prefix="promotion-race-revoker" + ) as first: + revoked = first.submit(revoke) + if not revoker_holds_lock.wait(timeout=10): + revoked.result(timeout=1) + pytest.fail("revoker did not reach the held commit") + with ThreadPoolExecutor( + max_workers=1, thread_name_prefix="promotion-race-promoter" + ) as second: + promoted = second.submit(promote) + if not promoter_requested_lock.wait(timeout=10): + promoted.result(timeout=1) + pytest.fail("promoter did not request the approval lock") + with pytest.raises(FutureTimeoutError): + promoted.result(timeout=0.25) + release_revoker.set() + revoked.result(timeout=30) + assert promoted.result(timeout=30) == "APPROVAL_STALE" + finally: + release_revoker.set() + event.remove(engine, "before_cursor_execute", observe_approval_lock) + + with Session(engine) as session: + approval = session.get(LifecycleApprovalRequest, approval_id) + operation = session.get(LifecycleOperation, operation_id) + contract = session.get(CapabilityContract, contract_id) + assert approval is not None and approval.status == "REVOKED" + assert operation is not None and operation.stage == "VERIFYING" + assert contract is not None and contract.serving_generation == original_generation + route = ServingRepository(session).stable_deployment(contract_id) + assert route is not None and route.id == current_id and route.id != candidate_id + events = list( + session.scalars(select(LifecycleEvent).where(LifecycleEvent.id.not_in(baseline_events))) + ) + assert [item.event_type for item in events] == ["APPROVAL_REVOKED"] diff --git a/backend/tests/test_lifecycle_serving.py b/backend/tests/test_lifecycle_serving.py new file mode 100644 index 0000000..0d075b2 --- /dev/null +++ b/backend/tests/test_lifecycle_serving.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select, update +from sqlalchemy.orm import Session + +from modelforge_api.domain.evaluation import ( + EvaluationCaseCreate, + EvaluationRevisionCreate, + EvaluationSuiteCreate, +) +from modelforge_api.domain.lifecycle_contracts import ( + ApprovalDecision, + ApprovalRequestCreate, + LifecycleEvidenceBundle, + LifecycleSubjectCreate, + PlanExecutionCreate, + PromotionPlanCreate, + ServingTransitionSelection, +) +from modelforge_api.domain.selected_model_comparison import ComparisonCreate +from modelforge_api.domain.serving import EmbeddingInvokeRequest, ServiceClientCreate +from modelforge_api.persistence.models import ( + ArtifactSet, + Base, + CapabilityContract, + CapabilityDeployment, + CapabilityExperimentRoute, + CapabilityResourceEnvelope, + DeploymentCandidate, + EmbeddingSpace, + GatewayRequest, + LifecycleApprovalRequest, + LifecycleEvent, + LifecycleOperation, + LifecycleRetentionRecord, + LifecycleSubject, + Project, + ProjectBinding, + ProjectFitEvidence, + ResidencyAllocation, + RuntimeProfile, + SelectedModelComparison, + ServiceClient, +) +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.lifecycle import LifecycleError, LifecycleService +from modelforge_api.services.lifecycle_serving import LifecycleServing, digest +from modelforge_api.services.selected_model_comparison import SelectedModelComparisonService +from modelforge_api.services.serving import ServingError +from tests.test_selected_model_comparison import _claimed +from tests.test_serving import InlineWorkerServingService, approve_and_promote, setup_m5 + + +def _clone(model, row, **changes): + return model(**{column.name: getattr(row, column.name) for column in model.__table__.columns + if column.name not in {"id", "created_at", "updated_at"}} | changes) + + +@pytest.fixture +def serving_pair(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + yield build_pair(session) + engine.dispose() + + +def build_pair(session): + serving, candidate_ref, _ = setup_m5(session) + production_approval, deployed = approve_and_promote(serving, candidate_ref.id) + current = session.get(CapabilityDeployment, deployed.id) + candidate = _clone(CapabilityDeployment, current, config_fingerprint="b" * 64, + channel="experiment", status="candidate", production=False, production_approval_id=None, + promoted_at=None) + session.add(candidate) + session.flush() + envelope = session.scalar(select(CapabilityResourceEnvelope).where( + CapabilityResourceEnvelope.capability_deployment_id == current.id)) + next_envelope = _clone(CapabilityResourceEnvelope, envelope, capability_deployment_id=candidate.id) + session.add(next_envelope) + residency = session.scalar(select(ResidencyAllocation).where( + ResidencyAllocation.capability_deployment_id == current.id)) + session.add(_clone(ResidencyAllocation, residency, capability_deployment_id=candidate.id)) + project = Project(key="selected-owner", name="Owner", description="test") + session.add(project) + session.flush() + binding = ProjectBinding(project_id=project.id, capability_contract_id=current.capability_contract_id, + channel="stable", priority="production") + session.add(binding) + session.flush() + client = ServiceClient(name="selected-consumer", allowed_capabilities=["rag.embedding@1"], + project_binding_id=binding.id) + route = CapabilityExperimentRoute(route_key="selected-test", capability_contract_id=current.capability_contract_id, + capability_deployment_id=candidate.id, purpose="Bounded comparison", evidence={}) + session.add_all((client, route)) + session.commit() + from modelforge_api.services.evaluation import EvaluationService + + relevant = uuid4() + suite = EvaluationService(session).create_suite(EvaluationSuiteCreate(project_id=project.id, + key="selected", name="Selected", description="One case", revision=EvaluationRevisionCreate( + revision="v1", dataset_revision="selected-v1", retrieval_settings={"top_k": 10}, cases=[ + EvaluationCaseCreate(case_key="one", query="Exact unchanged query", relevant_chunk_ids=[relevant], + critical=True, review_status="reviewed", label_provenance={"source": "review", "corpus_revision": "a" * 64})]))) + comparisons = SelectedModelComparisonService(session, AuditContext("operator", "test")) + request = ComparisonCreate(project_binding_id=binding.id, consumer_client_id=client.id, + suite_revision_id=suite.latest_revision_id, current_deployment_id=current.id, + candidate_deployment_id=candidate.id, candidate_route_id=route.id, corpus_sha256="a" * 64, + retrieval_config_sha256=digest({"top_k": 10}), current_index_ref="current-index", + candidate_index_ref="isolated-test-index", idempotency_key="selected-one") + claim, result, _ = _claimed((session, comparisons, request, client, relevant)) + completed = comparisons.result(claim.comparison.id, result, client) + contract = session.get(CapabilityContract, current.capability_contract_id) + selection = ServingTransitionSelection(current_deployment_id=current.id, candidate_deployment_id=candidate.id, + production_approval_id=production_approval.id, expected_route_generation=contract.serving_generation, + comparison_ids={binding.id: completed.id}) + artifact = session.get(ArtifactSet, candidate.artifact_set_id) + profile = session.get(RuntimeProfile, candidate.runtime_profile_id) + source = session.get(DeploymentCandidate, candidate.deployment_candidate_id) + evidence = LifecycleEvidenceBundle(serving_transition=selection, + artifact_set_id=artifact.id, model_revision_id=artifact.revision_id, + runtime_profile_id=profile.id, runtime_probe_id=source.runtime_probe_id, + runtime_image_digest=profile.runtime_image_digest, capability_deployment_id=candidate.id, + production_execution_approval_id=production_approval.id, resource_envelope_id=next_envelope.id, + embedding_space_id=candidate.embedding_space_id, rollback_target_ref=str(current.id), + evaluation_run_ids=[completed.baseline_run_id, completed.candidate_run_id], + integrity="VERIFIED", security="APPROVED", license="APPROVED", runtime="PROVEN", + evaluation="PROMOTION_ELIGIBLE", production_validation="SATISFIED", + scheduler_readiness="READY", evidence_revisions={"scheduler": "fixture"}) + return session, serving, current, candidate, selection, evidence + + +def approved_plan(pair): + session, _, current, candidate, _, evidence = pair + service = LifecycleService(session) + subject = service.create_subject(LifecycleSubjectCreate(target_type="CAPABILITY_DEPLOYMENT", + target_ref=str(candidate.id), environment="PRODUCTION", state="PROMOTION_ELIGIBLE")) + approval = service.request_approval(ApprovalRequestCreate(policy_key="capability-production", + subject_id=subject.id, target_type=subject.target_type, target_ref=subject.target_ref, + environment="PRODUCTION", requested_transition="STABLE", evidence=evidence, + requested_by="owner", reason="Review exact pair and all affected projects")) + assert approval.status == "PENDING", approval.blockers + service.decide_approval(approval.id, ApprovalDecision(actor="owner", reason="Evidence reviewed for every project"), "approve") + plan = service.create_plan(PromotionPlanCreate(approval_request_id=approval.id, subject_id=subject.id, + desired_state="STABLE", migration_class="transparent", candidate_deployment_id=candidate.id, + rollback_target_ref=str(current.id), created_by="owner")) + plan = service.approve_plan(plan.id, ApprovalDecision(actor="owner", reason="Exact immutable transition approved")) + operation = service.execute_plan(plan.id, PlanExecutionCreate(executor="owner", idempotency_key="execute-one", + expected_subject_version=subject.version)) + assert operation.stage == "VERIFYING" + return service, plan, operation, subject + + +def test_commit_and_rollback_switch_real_route_and_retain_exact_assets(serving_pair): + session, serving, current, candidate, selection, _ = serving_pair + before = (LifecycleServing.route_snapshot(current), LifecycleServing.route_snapshot(candidate)) + service, plan, operation, subject = approved_plan(serving_pair) + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + result = service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Explicit permanent replacement")) + assert result.stage == "COMMITTED" + assert service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Idempotent committed result retry")).stage == "COMMITTED" + assert serving.repo.stable_deployment(current.capability_contract_id).id == candidate.id + assert current.status == "draining" and candidate.production is True + assert session.get(LifecycleSubject, subject.id).state == "STABLE" + assert session.get(CapabilityContract, current.capability_contract_id).serving_generation == selection.expected_route_generation + 1 + retained = list(session.scalars(select(LifecycleRetentionRecord))) + assert any(item.target_type == "ARTIFACT_SET" and item.target_ref == str(current.artifact_set_id) for item in retained) + assert plan.project_consumers == [session.get(ProjectBinding, next(iter(selection.comparison_ids))).project_id.__str__()] + rolled = service.rollback_operation(operation.id, ApprovalDecision(actor="owner", reason="Restore exact previous serving route")) + assert rolled.stage == "ROLLED_BACK" + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + assert (LifecycleServing.route_snapshot(current), LifecycleServing.route_snapshot(candidate)) == before + assert session.get(CapabilityContract, current.capability_contract_id).serving_generation == selection.expected_route_generation + 2 + assert service.rollback_operation(operation.id, ApprovalDecision(actor="owner", reason="Idempotent rollback repeat")).stage == "ROLLED_BACK" + + +def test_commit_and_rollback_route_live_gateway_requests_to_exact_deployments(serving_pair): + session, serving, current, candidate, _, _ = serving_pair + gateway = InlineWorkerServingService( + session, + serving.settings, + serving.manifests, + serving.payload_store, + ) + created = gateway.create_client( + ServiceClientCreate( + name="lifecycle-route-proof", + allowed_capabilities=["rag.embedding@1"], + ) + ) + service, _, operation, _ = approved_plan(serving_pair) + + service.commit_operation( + operation.id, + ApprovalDecision(actor="owner", reason="Route proof commits the reviewed replacement"), + ) + candidate_client = gateway.authenticate( + f"Bearer {created.credential}", "rag.embedding@1" + ) + candidate_response = gateway.invoke( + EmbeddingInvokeRequest(input="request after replacement"), candidate_client + ) + candidate_request = session.scalar( + select(GatewayRequest).where(GatewayRequest.request_id == candidate_response.request_id) + ) + assert candidate_response.embedding_space_id == candidate.embedding_space_id + assert candidate_request is not None + assert candidate_request.capability_deployment_id == candidate.id + + service.rollback_operation( + operation.id, + ApprovalDecision(actor="owner", reason="Route proof restores the exact prior deployment"), + ) + current_client = gateway.authenticate(f"Bearer {created.credential}", "rag.embedding@1") + current_response = gateway.invoke( + EmbeddingInvokeRequest(input="request after rollback"), current_client + ) + current_request = session.scalar( + select(GatewayRequest).where(GatewayRequest.request_id == current_response.request_id) + ) + assert current_response.embedding_space_id == current.embedding_space_id + assert current_request is not None + assert current_request.capability_deployment_id == current.id + + +@pytest.mark.parametrize("mutation", ["generation", "bindings", "revoked", "comparison", "envelope"]) +def test_changed_authority_or_route_blocks_commit(serving_pair, mutation): + session, serving, current, candidate, selection, _ = serving_pair + service, _, operation, _ = approved_plan(serving_pair) + if mutation == "generation": + session.get(CapabilityContract, current.capability_contract_id).serving_generation += 1 + elif mutation == "bindings": + project = Project(key="second", name="Second", description="test") + session.add(project) + session.flush() + session.add(ProjectBinding(project_id=project.id, capability_contract_id=current.capability_contract_id, + channel="stable", priority="production")) + elif mutation == "revoked": + serving.repo.approval(selection.production_approval_id).revoked_at = datetime.now(UTC) + elif mutation == "comparison": + session.get(SelectedModelComparison, next(iter(selection.comparison_ids.values()))).status = "failed" + else: + session.scalar(select(CapabilityResourceEnvelope).where( + CapabilityResourceEnvelope.capability_deployment_id == candidate.id)).stale = True + session.commit() + with pytest.raises((LifecycleError, ServingError)): + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Must reject changed authority")) + session.rollback() + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + assert session.get(LifecycleOperation, operation.id).stage == "VERIFYING" + + +def test_equal_dimension_different_space_requires_consumer_cutover(serving_pair): + session, _, current, candidate, selection, _ = serving_pair + old_space = session.get(EmbeddingSpace, current.embedding_space_id) + space = _clone(EmbeddingSpace, old_space, identity_digest="d" * 64) + session.add(space) + session.flush() + candidate.embedding_space_id = space.id + session.commit() + with pytest.raises(LifecycleError, match="consumer reindex"): + LifecycleServing(session).prepare(selection) + + +@pytest.mark.parametrize("mutation", ["generation", "binding", "config"]) +def test_rollback_preserves_later_route_or_configuration(serving_pair, mutation): + session, serving, current, candidate, _, _ = serving_pair + service, _, operation, _ = approved_plan(serving_pair) + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Explicit serving replacement")) + if mutation == "generation": + # Even an A->B->A route cycle must fence the old operation. + session.get(CapabilityContract, current.capability_contract_id).serving_generation += 2 + elif mutation == "binding": + session.scalar(select(ProjectBinding).where(ProjectBinding.capability_contract_id == current.capability_contract_id)).priority = "background" + else: + candidate.max_queue_depth += 1 + session.commit() + with pytest.raises(LifecycleError, match="preserved|changed"): + service.rollback_operation(operation.id, ApprovalDecision(actor="owner", reason="Old operation cannot overwrite later state")) + session.rollback() + assert serving.repo.stable_deployment(current.capability_contract_id).id == candidate.id + assert session.get(LifecycleOperation, operation.id).stage == "COMMITTED" + + +def test_subject_conflict_rolls_back_both_route_and_generation(serving_pair): + session, serving, current, _, selection, _ = serving_pair + service, _, operation, subject = approved_plan(serving_pair) + session.execute(update(LifecycleSubject).where(LifecycleSubject.id == subject.id).values(version=100)) + session.commit() + with pytest.raises(LifecycleError, match="subject changed"): + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Reject concurrent subject transition")) + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + assert session.get(CapabilityContract, current.capability_contract_id).serving_generation == selection.expected_route_generation + + +def test_revoked_lifecycle_approval_cannot_commit(serving_pair): + session, serving, current, _, _, _ = serving_pair + service, plan, operation, _ = approved_plan(serving_pair) + session.get(LifecycleApprovalRequest, plan.approval_request_id).status = "REVOKED" + session.commit() + with pytest.raises(LifecycleError, match="no longer current"): + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Reject revoked lifecycle approval")) + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + + +@pytest.mark.parametrize("mutation", ["revoked", "quarantined", "offline"]) +def test_rollback_cannot_reactivate_invalid_prior_production_authority(serving_pair, mutation): + session, serving, current, candidate, _, _ = serving_pair + service, _, operation, _ = approved_plan(serving_pair) + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Explicit serving replacement")) + if mutation == "revoked": + serving.repo.approval(current.production_approval_id).revoked_at = datetime.now(UTC) + elif mutation == "quarantined": + serving.repo.set_artifacts(current.artifact_set_id)[0].quarantined = True + else: + serving.repo.node(current.compute_node_id).liveness_state = "offline" + session.commit() + with pytest.raises((LifecycleError, ServingError)): + service.rollback_operation(operation.id, ApprovalDecision(actor="owner", reason="Refuse a revoked rollback target")) + session.rollback() + assert serving.repo.stable_deployment(current.capability_contract_id).id == candidate.id + + +@pytest.mark.parametrize("validation", ["DEFERRED_EXTERNAL_VALIDATION", "REQUIRED", "NOT_REQUIRED"]) +def test_actual_serving_approval_requires_satisfied_validation(serving_pair, validation): + serving_pair[-1].production_validation = validation + with pytest.raises(LifecycleError, match="satisfied production validation"): + approved_plan(serving_pair) + + +def test_deferred_historical_approval_cannot_commit_actual_route(serving_pair): + session, serving, current, _, _, _ = serving_pair + service, plan, operation, _ = approved_plan(serving_pair) + approval = session.get(LifecycleApprovalRequest, plan.approval_request_id) + # Simulate a previously accepted capability-only snapshot; its declared deferred + # validation must not gain operational authority under the new serving adapter. + approval.evidence_snapshot = {**approval.evidence_snapshot, "declared": { + **approval.evidence_snapshot["declared"], "production_validation": "DEFERRED_EXTERNAL_VALIDATION"}} + session.commit() + with pytest.raises(LifecycleError, match="no longer current"): + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Reject explicitly deferred validation")) + session.rollback() + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + + +def _fit(pair, *, deployment_id=None, **changes): + session, _, _, candidate, selection, _ = pair + values = {"project_binding_id": next(iter(selection.comparison_ids)), + "capability_deployment_id": deployment_id or candidate.id, "environment": "production", + "recommendation": "PROMOTION_ELIGIBLE", "evidence_class": "OWNER_PHOTO", + "engineering_integration": "PASS", "production_validation": "SATISFIED", "production_action": "NONE", + "case_count": 1, "metric_values": {}, "critical_errors": 0, "blockers": [], "evidence_digest": uuid4().hex * 2, + "evidence": {"source": "offline test fixture"}, "created_at": datetime.now(UTC)} + row = ProjectFitEvidence(**(values | changes)) + session.add(row) + session.commit() + return row + + +@pytest.mark.parametrize("changes", [ + {"recommendation": "KEEP_LAB", "production_validation": "DEFERRED_EXTERNAL_VALIDATION"}, + {"recommendation": "BLOCKED", "production_validation": "REQUIRED"}, + {"recommendation": "REQUIRES_MORE_EVIDENCE"}, + {"engineering_integration": "BLOCKED"}, + {"blockers": ["EXTERNAL_VALIDATION_REQUIRED"]}, + {"critical_errors": 1}, +]) +def test_later_negative_exact_project_fit_blocks_commit(serving_pair, changes): + session, serving, current, _, _, _ = serving_pair + service, _, operation, _ = approved_plan(serving_pair) + _fit(serving_pair, **changes) + with pytest.raises(LifecycleError, match="no longer current"): + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Respect later project-specific blocker")) + session.rollback() + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + + +def test_project_fit_is_exactly_scoped_and_bound_into_plan(serving_pair): + _, _, current, candidate, selection, _ = serving_pair + _fit(serving_pair, deployment_id=current.id, recommendation="BLOCKED") + fit = _fit(serving_pair) + service, plan, operation, _ = approved_plan(serving_pair) + contract = plan.affected_identities["serving_transition"] + assert contract["production_validation"] == "SATISFIED" + assert contract["comparisons"][str(next(iter(selection.comparison_ids)))]["project_fit"]["id"] == str(fit.id) + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Exact candidate project validation reviewed")) + assert candidate.status == "stable" + + +def test_newer_positive_fit_still_requires_reviewed_plan_refresh(serving_pair): + session, serving, current, _, _, _ = serving_pair + _fit(serving_pair) + service, _, operation, _ = approved_plan(serving_pair) + _fit(serving_pair) + with pytest.raises(LifecycleError, match="no longer current"): + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="New evidence needs a new review")) + session.rollback() + assert serving.repo.stable_deployment(current.capability_contract_id).id == current.id + + +@pytest.mark.parametrize("read_before_revoke", [False, True]) +def test_committed_serving_approval_remains_readable_and_revocable(serving_pair, read_before_revoke): + session, serving, current, candidate, _, _ = serving_pair + service, plan, operation, _ = approved_plan(serving_pair) + service.commit_operation(operation.id, ApprovalDecision(actor="owner", reason="Explicit permanent replacement")) + if read_before_revoke: + historical = service.approval(plan.approval_request_id) + assert historical.status == "STALE" + assert "SERVING_ROUTE_CHANGED" in historical.blockers + revoked = service.decide_approval(plan.approval_request_id, + ApprovalDecision(actor="owner", reason="Withdraw obsolete replacement authority"), "revoke") + assert revoked.status == "REVOKED" + assert service.approval(plan.approval_request_id).status == "REVOKED" + events = list(session.scalars(select(LifecycleEvent).where( + LifecycleEvent.event_type == "APPROVAL_STALE"))) + assert len(events) == 1 + assert "SERVING_ROUTE_CHANGED" in str(events[0].details) + assert serving.repo.stable_deployment(current.capability_contract_id).id == candidate.id diff --git a/backend/tests/test_lifecycle_serving_postgres.py b/backend/tests/test_lifecycle_serving_postgres.py new file mode 100644 index 0000000..0cd4534 --- /dev/null +++ b/backend/tests/test_lifecycle_serving_postgres.py @@ -0,0 +1,113 @@ +"""Disposable PostgreSQL route CAS and runtime-role transaction qualification.""" + +import importlib +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest +from sqlalchemy.orm import Session + +from modelforge_api.domain.lifecycle_contracts import ApprovalDecision +from modelforge_api.persistence.models import CapabilityContract, LifecycleOperation +from modelforge_api.persistence.serving_repository import ServingRepository +from modelforge_api.services.lifecycle import LifecycleError, LifecycleService +from tests.test_lifecycle_serving import approved_plan, build_pair +from tests.test_managed_identities_postgres import IdentityDatabase, identity_database + +__all__ = ["identity_database"] + + +def test_two_committers_switch_once_and_exact_rollback_increments_generation( + identity_database: IdentityDatabase, +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + with Session(database.runtime) as session: + pair = build_pair(session) + _, _, current, candidate, selection, _ = pair + _, _, operation, _ = approved_plan(pair) + operation_id, current_id, candidate_id = operation.id, current.id, candidate.id + contract_id, generation = ( + current.capability_contract_id, + selection.expected_route_generation, + ) + + barrier = threading.Barrier(2) + + def contender(_index: int) -> str: + with Session(database.runtime) as session: + barrier.wait(timeout=30) + try: + return ( + LifecycleService(session) + .commit_operation( + operation_id, + ApprovalDecision( + actor="qualification", reason="Concurrent explicit commit qualification" + ), + ) + .stage + ) + except LifecycleError: + session.rollback() + return "CONFLICT" + + with ThreadPoolExecutor(max_workers=2) as pool: + # Same-operation retries return its persisted result, without a second switch. + assert list(pool.map(contender, [1, 2])) == ["COMMITTED", "COMMITTED"] + with Session(database.runtime) as session: + assert ServingRepository(session).stable_deployment(contract_id).id == candidate_id + assert session.get(CapabilityContract, contract_id).serving_generation == generation + 1 + assert session.get(LifecycleOperation, operation_id).stage == "COMMITTED" + restored = LifecycleService(session).rollback_operation( + operation_id, + ApprovalDecision(actor="qualification", reason="Restore exact prior serving route"), + ) + assert restored.stage == "ROLLED_BACK" + assert ServingRepository(session).stable_deployment(contract_id).id == current_id + assert session.get(CapabilityContract, contract_id).serving_generation == generation + 2 + with pytest.raises(RuntimeError, match="Serving route history exists"): + importlib.import_module("alembic.command").downgrade(database.migration(), "20260909_0033") + + +def test_current_route_generation_survives_postgres_dump_restore( + identity_database: IdentityDatabase, tmp_path +): + from modelforge_api.services.recovery_fingerprint import ( + BENCHMARK_RUN_SCHEMA, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, + ) + + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + with Session(database.runtime) as session: + pair = build_pair(session) + _, _, operation, _ = approved_plan(pair) + LifecycleService(session).commit_operation( + operation.id, + ApprovalDecision( + actor="qualification", reason="Qualify retained committed route after restore" + ), + ) + contract_id = pair[2].capability_contract_id + generation = session.get(CapabilityContract, contract_id).serving_generation + assert generation > 0 + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "committed-route-current.dump" + database.archive(archive) + database.archive(archive, restore=True) + with Session(database.restored_runtime) as session: + restored = semantic_fingerprint(session.connection()) + compatibility = fingerprint_compatibility( + source, restored, source_schema_revision=BENCHMARK_RUN_SCHEMA + ) + assert compatibility.compatible + assert audit_fingerprint_differences_are_compatible(source, restored, compatibility) + assert session.get(CapabilityContract, contract_id).serving_generation == generation + session.get(CapabilityContract, contract_id).serving_generation = 0 + session.flush() + changed = semantic_fingerprint(session.connection()) + assert not audit_fingerprint_differences_are_compatible(source, changed, compatibility) + session.rollback() diff --git a/backend/tests/test_managed_identities.py b/backend/tests/test_managed_identities.py new file mode 100644 index 0000000..f365c91 --- /dev/null +++ b/backend/tests/test_managed_identities.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from fastapi import HTTPException +from fastapi.testclient import TestClient +from pydantic import SecretStr, ValidationError +from sqlalchemy import create_engine, delete, event, select, update +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.authorization import Principal, PrincipalRole, authenticate_operator_token +from modelforge_api.db import get_session +from modelforge_api.domain.managed_identity import ( + ManagedIdentityChange, + ManagedIdentityCreate, + ManagedIdentityRevoke, + ManagedIdentityRotate, +) +from modelforge_api.main import app +from modelforge_api.persistence.models import ( + AuditEvent, + Base, + ManagedOperatorCredential, + ManagedOperatorCredentialActivation, + ManagedOperatorIdentity, + ManagedOperatorState, +) +from modelforge_api.services import audit as audit_module +from modelforge_api.services.operator_identities import ( + ManagedIdentityError, + ManagedIdentityService, + authenticate_managed_operator, +) +from modelforge_api.services.recovery_fingerprint import ( + CURRENT_TRUTH_TABLES, + MANAGED_ACTIVATION_TABLE, + MANAGED_IDENTITY_SCHEMA, + SECRET_SCHEMA, + _without_creative, + _without_lab_execution, + _without_managed_identities, + _without_model_changes, + _without_model_updates, + _without_secrets, + _without_selected_comparisons, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + fingerprint_session, +) +from modelforge_api.settings import Settings, get_settings + +ANCHOR = Principal("legacy-operator", PrincipalRole.ADMIN, "legacy_admin_token") +REASON = "Reviewed operator identity lifecycle" + + +@pytest.fixture +def session(): + engine = create_engine( + "sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(engine) + with Session(engine) as session: + yield session + engine.dispose() + + +def service(session: Session, principal: Principal = ANCHOR) -> ManagedIdentityService: + return ManagedIdentityService(session, principal, "managed-request-42") + + +def create(session: Session, slug: str = "alice", role: str = "admin"): + return service(session).create( + ManagedIdentityCreate( + slug=slug, + role=role, + expires_in_days=30, + expected_revision=service(session).list().revision, + reason=REASON, + ) + ) + + +def test_issue_metadata_auth_and_audit(session: Session) -> None: + issued = create(session) + assert issued.one_time_key.startswith("mfopm_") and len(issued.one_time_key) == 49 + assert issued.revision == 1 and issued.identity.subject == "managed:alice" + principal = authenticate_managed_operator(session, issued.one_time_key) + session.commit() + assert principal.subject == "managed:alice" and principal.role == PrincipalRole.ADMIN + metadata = service(session).list().model_dump_json() + digest = hashlib.sha256(issued.one_time_key.encode()).hexdigest() + assert ( + issued.one_time_key not in metadata + and digest not in metadata + and "token_sha256" not in metadata + ) + assert service(session).list().identities[0].credentials[0].last_used_at is not None + audit = session.scalar( + select(AuditEvent).where(AuditEvent.action == "managed_identity.created") + ) + assert audit is not None and audit.actor_id == "legacy-operator" + assert audit.correlation_id == "managed-request-42" and issued.one_time_key not in str( + audit.details + ) + + +def test_credential_metadata_preserves_utc_after_database_reload(session: Session) -> None: + create(session) + instant = datetime(2026, 9, 8, 10, 30, tzinfo=UTC) + session.execute(update(ManagedOperatorCredential).values( + revoked_at=instant, superseded_at=instant, last_used_at=instant, + )) + session.commit() + session.expire_all() + credential = service(session).list().identities[0].credentials[0] + assert credential.revoked_at == instant + assert credential.superseded_at == instant + assert credential.last_used_at == instant + + +@pytest.mark.parametrize("schema", ["20260908_0026", MANAGED_IDENTITY_SCHEMA]) +def test_recovery_allows_one_marker_but_preserves_history(session: Session, schema: str) -> None: + create(session) + before = _without_lab_execution(_without_model_changes( + _without_secrets( + _without_model_updates( + _without_selected_comparisons(_without_creative(fingerprint_session(session))) + ) + ) + )) + audit_module.AuditWriter(session, "operator", "recovery").write( + "RECOVERY_RECONCILIATION_COMPLETED", "restore", None, {"restored": True}, + ) + session.commit() + after = _without_lab_execution(_without_model_changes( + _without_secrets( + _without_model_updates( + _without_selected_comparisons(_without_creative(fingerprint_session(session))) + ) + ) + )) + if schema == "20260908_0026": + before, after = _without_managed_identities(before), _without_managed_identities(after) + contract = fingerprint_compatibility(before, after, source_schema_revision=schema) + assert audit_fingerprint_differences_are_compatible(before, after, contract) + for table in before["tables"]: + altered = copy.deepcopy(after) + if table == "audit_events": + altered["tables"][table]["row_count"] += 1 + elif table == "audit_chain_heads": + altered["tables"][table]["row_count"] = 0 + elif table == "operational_alerts": + altered["tables"][table]["row_count"] += 1 + else: + altered["tables"][table]["digest"] = "0" * 64 + assert not audit_fingerprint_differences_are_compatible(before, altered, contract), table + + +@pytest.mark.parametrize("operation", ["revoke", "disable", "demote"]) +def test_last_admin_is_preserved_atomically(session: Session, operation: str) -> None: + issued = create(session) + before = service(session).list().model_dump_json() + events = list(session.scalars(select(AuditEvent.id))) + with pytest.raises(ManagedIdentityError) as refused: + if operation == "revoke": + service(session).revoke( + "alice", + ManagedIdentityRevoke( + expected_version=1, confirm_subject="managed:alice", reason=REASON + ), + ) + else: + service(session).change( + "alice", + ManagedIdentityChange( + expected_version=1, + role="viewer" if operation == "demote" else "admin", + enabled=operation != "disable", + reason=REASON, + ), + ) + assert refused.value.code == "LAST_MANAGED_ADMIN" + assert service(session).list().model_dump_json() == before + assert list(session.scalars(select(AuditEvent.id))) == events + assert authenticate_managed_operator(session, issued.one_time_key).role == PrincipalRole.ADMIN + + +def test_initial_admin_and_revision_conflicts(session: Session) -> None: + with pytest.raises(ManagedIdentityError, match="first managed"): + create(session, role="viewer") + assert service(session).list().revision == 0 + create(session) + with pytest.raises(ManagedIdentityError) as conflict: + service(session).create( + ManagedIdentityCreate( + slug="bob", role="viewer", expires_in_days=1, expected_revision=0, reason=REASON + ) + ) + assert conflict.value.code == "MANAGED_IDENTITY_VERSION_CONFLICT" + assert session.get(ManagedOperatorIdentity, "bob") is None + + +def test_bounded_overlap_revoke_and_no_fallback(session: Session) -> None: + first = create(session) + rotated = service(session).rotate( + "alice", + ManagedIdentityRotate( + expected_version=1, expires_in_days=30, overlap_seconds=900, reason=REASON + ), + ) + assert len(rotated.identity.credentials) == 2 + assert authenticate_managed_operator(session, first.one_time_key).subject == "managed:alice" + latest = service(session).rotate( + "alice", + ManagedIdentityRotate( + expected_version=2, expires_in_days=30, overlap_seconds=0, reason=REASON + ), + ) + for token in (first.one_time_key, rotated.one_time_key): + with pytest.raises(ManagedIdentityError): + authenticate_managed_operator(session, token) + assert authenticate_managed_operator(session, latest.one_time_key) + with pytest.raises(HTTPException): + authenticate_operator_token( + Settings(_env_file=None, operator_api_key=SecretStr(latest.one_time_key)), + latest.one_time_key, + ) + + +def test_initiator_revoked_after_authentication_is_rechecked(session: Session) -> None: + alice = create(session) + create(session, "bob") + stale = authenticate_managed_operator(session, alice.one_time_key) + session.commit() + service(session).revoke( + "alice", + ManagedIdentityRevoke(expected_version=1, confirm_subject="managed:alice", reason=REASON), + ) + with pytest.raises(ManagedIdentityError) as denied: + service(session, stale).change( + "bob", + ManagedIdentityChange(expected_version=1, role="admin", enabled=True, reason=REASON), + ) + assert denied.value.code == "MANAGED_ADMIN_NO_LONGER_AUTHORIZED" + assert session.get(ManagedOperatorIdentity, "bob").version == 1 + + +def test_recovery_activation_quarantine_preserves_authoritative_history(session: Session) -> None: + original = create(session) + before = _without_lab_execution(_without_model_changes( + _without_model_updates( + _without_selected_comparisons(_without_creative(fingerprint_session(session))) + ) + )) + assert MANAGED_ACTIVATION_TABLE in CURRENT_TRUTH_TABLES + session.execute(delete(ManagedOperatorCredentialActivation)) + session.commit() + after = _without_lab_execution(_without_model_changes( + _without_model_updates( + _without_selected_comparisons(_without_creative(fingerprint_session(session))) + ) + )) + contract = fingerprint_compatibility( + before, after, source_schema_revision=SECRET_SCHEMA + ) + assert audit_fingerprint_differences_are_compatible(before, after, contract) + with pytest.raises(ManagedIdentityError): + authenticate_managed_operator(session, original.one_time_key) + metadata = service(session).list().identities[0].credentials[0] + assert not metadata.activated and not metadata.usable + recovered = service(session).rotate( + "alice", + ManagedIdentityRotate( + expected_version=1, expires_in_days=30, overlap_seconds=900, reason=REASON + ), + ) + assert authenticate_managed_operator(session, recovered.one_time_key) + with pytest.raises(ManagedIdentityError): + authenticate_managed_operator(session, original.one_time_key) + + +def test_audit_failure_rolls_back_key_and_revision( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + def failed(*args, **kwargs): + raise RuntimeError("audit unavailable") + + monkeypatch.setattr(audit_module, "_append_canonical_audit_event", failed) + with pytest.raises(RuntimeError): + create(session) + assert service(session).list().revision == 0 + assert session.scalar(select(ManagedOperatorCredential)) is None + + +def test_metadata_failure_preserves_previous_key_and_audit( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + issued = create(session) + before = list(session.scalars(select(AuditEvent.id))) + + def unavailable(*args, **kwargs): + raise RuntimeError("metadata unavailable") + + subject = service(session) + monkeypatch.setattr(subject, "_metadata", unavailable) + with pytest.raises(RuntimeError, match="metadata unavailable"): + subject.rotate( + "alice", + ManagedIdentityRotate( + expected_version=1, expires_in_days=30, overlap_seconds=0, reason=REASON + ), + ) + assert list(session.scalars(select(AuditEvent.id))) == before + assert service(session).list().revision == 1 + assert authenticate_managed_operator(session, issued.one_time_key) + + +def test_issuance_returns_snapshot_without_reads_after_commit(session: Session) -> None: + committed = False + + def mark_commit(session): + nonlocal committed + committed = True + + def reject_late_read(orm_execute_state): + if committed: + raise AssertionError("database read after commit") + + event.listen(session, "after_commit", mark_commit) + event.listen(session, "do_orm_execute", reject_late_read) + try: + issued = create(session) + assert issued.revision == 1 and issued.identity.credentials[0].usable + assert "one_time_key" in issued.model_dump_json() + finally: + event.remove(session, "after_commit", mark_commit) + event.remove(session, "do_orm_execute", reject_late_read) + + +@pytest.mark.parametrize("alteration", ["expiry", "disabled", "revoked", "quarantined"]) +def test_unusable_credential_rejected(session: Session, alteration: str) -> None: + issued = create(session) + credential_id = uuid.UUID(issued.identity.credentials[0].id) + if alteration == "expiry": + session.execute( + update(ManagedOperatorCredential) + .where(ManagedOperatorCredential.id == credential_id) + .values(expires_at=datetime.now(UTC) - timedelta(seconds=1)) + ) + elif alteration == "revoked": + session.execute( + update(ManagedOperatorCredential) + .where(ManagedOperatorCredential.id == credential_id) + .values(revoked_at=datetime.now(UTC)) + ) + elif alteration == "disabled": + session.execute(update(ManagedOperatorIdentity).values(enabled=False)) + else: + session.execute(delete(ManagedOperatorCredentialActivation)) + session.commit() + with pytest.raises(ManagedIdentityError) as error: + authenticate_managed_operator(session, issued.one_time_key) + assert error.value.status_code == 401 + assert not service(session).list().identities[0].credentials[0].usable + + +def test_non_admin_management_and_exact_revoke_confirmation(session: Session) -> None: + create(session) + viewer = create(session, "viewer", "viewer") + principal = authenticate_managed_operator(session, viewer.one_time_key) + session.commit() + with pytest.raises(ManagedIdentityError) as denied: + service(session, principal).list() + assert denied.value.status_code == 403 + with pytest.raises(ManagedIdentityError) as denied: + service(session).revoke( + "viewer", + ManagedIdentityRevoke(expected_version=1, confirm_subject="viewer", reason=REASON), + ) + assert denied.value.code == "MANAGED_IDENTITY_CONFIRMATION_REQUIRED" + result = service(session).revoke( + "viewer", + ManagedIdentityRevoke(expected_version=1, confirm_subject="managed:viewer", reason=REASON), + ) + assert result.identity.version == 2 and not result.identity.credentials[0].usable + + +def test_metadata_history_is_bounded(session: Session) -> None: + create(session) + for version in range(1, 12): + service(session).rotate( + "alice", + ManagedIdentityRotate( + expected_version=version, expires_in_days=1, overlap_seconds=0, reason=REASON + ), + ) + assert len(service(session).list().identities[0].credentials) == 10 + + +def test_migration_empty_downgrade_and_history_refusal() -> None: + path = ( + Path(__file__).resolve().parents[1] + / "alembic/versions/20260908_0027_managed_operator_identities.py" + ) + specification = importlib.util.spec_from_file_location("managed_identity_migration", path) + assert specification is not None and specification.loader is not None + migration = importlib.util.module_from_spec(specification) + specification.loader.exec_module(migration) + engine = create_engine("sqlite://") + with engine.begin() as connection, Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + assert connection.scalar(select(ManagedOperatorState.revision)) == 0 + migration.downgrade() + migration.upgrade() + connection.execute( + ManagedOperatorIdentity.__table__.insert().values( + slug="retained", + role="admin", + enabled=True, + version=1, + created_at=datetime.now(UTC), + ) + ) + with pytest.raises(RuntimeError, match="history exists"): + migration.downgrade() + engine.dispose() + + +@pytest.mark.parametrize( + "field,value", + [ + ("expires_in_days", True), + ("expires_in_days", "30"), + ("expires_in_days", 366), + ("expected_revision", False), + ("reason", " "), + ("reason", "copied mfopm_" + "a" * 43), + ("slug", "managed:alice"), + ], +) +def test_strict_inputs_and_secret_safe_reason(field: str, value: object) -> None: + body = {"slug": "alice", "role": "admin", "expires_in_days": 30, "expected_revision": 0, "reason": REASON} + body[field] = value + with pytest.raises(ValidationError): + ManagedIdentityCreate.model_validate(body) + + +def test_http_boundaries_no_store_and_dependency_failure( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = Settings(_env_file=None, env="test", operator_api_key="anchor-secret") + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_session] = lambda: session + try: + client = TestClient(app) + url = "/api/v1/operator/managed-identities" + headers = { + "X-ModelForge-Admin-Token": "anchor-secret", + "X-Correlation-ID": "http-managed-42", + } + assert client.post(url, content="invalid").status_code == 401 + response = client.post( + url, + json={ + "slug": "alice", "role": "admin", "expires_in_days": 30, "expected_revision": 0, "reason": REASON + }, + headers=headers, + ) + assert response.status_code == 201, response.text + token = response.json()["one_time_key"] + assert ( + response.headers["cache-control"] == "no-store" and "set-cookie" not in response.headers + ) + own = {"X-ModelForge-Admin-Token": token} + identity = client.get("/api/v1/operator/identity", headers=own) + assert identity.json()["subject"] == "managed:alice" + assert token not in client.get(url, headers=own).text + failure = client.post( + url, + json={ + "slug": "bob", + "role": "admin", + "expires_in_days": 30, + "expected_revision": 1, + "reason": "copied " + token, + }, + headers=own, + ) + assert failure.status_code == 422 and token not in failure.text + + def unavailable(*args, **kwargs): + raise OperationalError("hidden", {}, Exception("private database detail")) + + monkeypatch.setattr("modelforge_api.main.authenticate_managed_operator", unavailable) + response = client.post(url, content="not JSON", headers=own) + assert response.status_code == 503 + assert "private database detail" not in response.text + assert client.get("/api/v1/operator/identity", headers=headers).status_code == 200 + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/test_managed_identities_postgres.py b/backend/tests/test_managed_identities_postgres.py new file mode 100644 index 0000000..f04251a --- /dev/null +++ b/backend/tests/test_managed_identities_postgres.py @@ -0,0 +1,406 @@ +"""Managed-PG qualification: 0027 upgrades, recovery quarantine and identity races.""" + +from __future__ import annotations + +import importlib +import os +import re +import threading +import uuid +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest +from alembic.config import Config +from sqlalchemy import create_engine, select, text +from sqlalchemy.engine import URL, Engine, make_url +from sqlalchemy.exc import DBAPIError +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import Principal, PrincipalRole +from modelforge_api.domain.managed_identity import ( + ManagedIdentityChange, + ManagedIdentityCreate, + ManagedIdentityRevoke, + ManagedIdentityRotate, +) +from modelforge_api.domain.release import TARGET_SCHEMA_REVISION +from modelforge_api.persistence.models import ( + AuditEvent, + ManagedOperatorCredentialActivation, + ManagedOperatorState, +) +from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.recovery_fingerprint import ( + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from modelforge_api.services.recovery_postgres import CommandResult, PostgresEngine, PostgresTarget +from modelforge_api.settings import Settings +from tests.test_configuration_postgres import _database_tool + +PRIOR = "20260908_0026" +CURRENT = TARGET_SCHEMA_REVISION +EXTERNAL_ADMIN = Principal("legacy-operator", PrincipalRole.ADMIN, "legacy_admin_token") +REASON = "Isolated managed PostgreSQL qualification" + + +@dataclass +class IdentityDatabase: + runtime: Engine + restored_runtime: Engine + owner: URL + restored_owner: URL + + def migration(self, *, restored: bool = False) -> Config: + config = Config() + config.set_main_option("script_location", str(Path(__file__).resolve().parents[1] / "alembic")) + url = self.restored_owner if restored else self.owner + config.set_main_option( + "sqlalchemy.url", url.render_as_string(hide_password=False).replace("%", "%%") + ) + return config + + def archive(self, path: Path, *, restore: bool = False) -> None: + owner = self.restored_owner if restore else self.owner + host = ( + "127.0.0.1" if os.getenv("MODELFORGE_TEST_POSTGRES_CONTAINER") + else owner.host or "localhost" + ) + args = ["--host", host, "--port", str(owner.port or 5432), "--username", + owner.username or "modelforge", "--dbname", owner.database or ""] + _database_tool( + "pg_restore" if restore else "pg_dump", + [*args, "--exit-on-error" if restore else "--format=custom"], + owner.password, path, + ) + assert path.stat().st_size > 0 + + +@pytest.fixture +def identity_database() -> Iterator[IdentityDatabase]: + admin_url = os.getenv("MODELFORGE_TEST_POSTGRES_URL") + if not admin_url: + pytest.skip("requires managed MODELFORGE_TEST_POSTGRES_URL") + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + assert owner_url and runtime_url, "managed qualification requires owner and runtime roles" + owner, runtime = make_url(owner_url), make_url(runtime_url) + assert owner.username == "modelforge" + assert runtime.username == "modelforge_runtime" + names = ["test_identities_" + uuid.uuid4().hex, + "test_identities_restore_" + uuid.uuid4().hex] + admin = create_engine(admin_url, isolation_level="AUTOCOMMIT") + runtime_engine = create_engine(runtime.set(database=names[0]), connect_args={ + "connect_timeout": 15, "options": "-c statement_timeout=30000 -c lock_timeout=15000", + }) + restored_engine = create_engine(runtime.set(database=names[1])) + created: list[str] = [] + try: + with admin.connect() as connection: + for name in names: + connection.execute(text(f'CREATE DATABASE "{name}" OWNER modelforge')) + created.append(name) + yield IdentityDatabase(runtime_engine, restored_engine, + owner.set(database=names[0]), owner.set(database=names[1])) + finally: + runtime_engine.dispose() + restored_engine.dispose() + with admin.connect() as connection: + for name in created: + connection.execute(text(f'DROP DATABASE "{name}" WITH (FORCE)')) + admin.dispose() + + +def _service(session: Session, principal: Principal = EXTERNAL_ADMIN): + from modelforge_api.services.operator_identities import ManagedIdentityService + + return ManagedIdentityService(session, principal, "managed-pg-qualification") + + +def _create(engine: Engine, slug: str): + with Session(engine) as session: + revision = session.scalar(select(ManagedOperatorState.revision)) + assert revision is not None + return _service(session).create(ManagedIdentityCreate( + slug=slug, role="admin", expires_in_days=14, + expected_revision=revision, reason=REASON, + )) + + +def test_managed_identity_upgrade_prior_dump_restore_and_least_privilege( + identity_database: IdentityDatabase, tmp_path: Path, +) -> None: + database = identity_database + alembic = importlib.import_module("alembic.command") + config = database.migration() + alembic.upgrade(config, PRIOR) + with Session(database.runtime) as session: + AuditWriter(session, "operator", "prior-release").write( + "qualification.baseline", "managed_identity", None, {"source_schema": PRIOR}, + ) + session.commit() + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "pre-managed-identities.dump" + database.archive(archive) + alembic.upgrade(config, "head") + with Session(database.runtime) as session: + assert session.scalar(text("SELECT version_num FROM alembic_version")) == CURRENT + assert session.scalar(select(ManagedOperatorState.revision)) == 0 + current = semantic_fingerprint(session.connection()) + comparison = fingerprint_compatibility(source, current, source_schema_revision=PRIOR) + assert comparison.compatible + assert audit_fingerprint_differences_are_compatible(source, current, comparison) + alembic.downgrade(config, PRIOR) + alembic.upgrade(config, "head") + _create(database.runtime, "qualified-admin") + with database.runtime.connect() as connection: + role = connection.execute(text( + "SELECT rolsuper, rolcreatedb, rolcreaterole FROM pg_roles WHERE rolname=current_user" + )).one() + assert tuple(role) == (False, False, False) + for statement in ( + "ALTER TABLE managed_operator_identities ADD COLUMN forbidden integer", + "TRUNCATE managed_operator_credentials CASCADE", + "DELETE FROM managed_operator_identities", + "DELETE FROM audit_events", + ): + with pytest.raises(DBAPIError): + connection.execute(text(statement)) + connection.rollback() + with pytest.raises(RuntimeError, match="history exists"): + alembic.downgrade(config, PRIOR) + with database.runtime.connect() as connection: + assert connection.scalar(text("SELECT version_num FROM alembic_version")) == CURRENT + database.archive(archive, restore=True) + with database.restored_runtime.connect() as connection: + restored = semantic_fingerprint(connection) + assert restored["tables"] == source["tables"] + assert connection.scalar(text("SELECT version_num FROM alembic_version")) == PRIOR + alembic.upgrade(database.migration(restored=True), "head") + with database.restored_runtime.connect() as connection: + restored = semantic_fingerprint(connection) + comparison = fingerprint_compatibility(source, restored, source_schema_revision=PRIOR) + assert comparison.compatible + assert audit_fingerprint_differences_are_compatible(source, restored, comparison) + + +@pytest.mark.parametrize("operation", ["revoke", "demote", "disable"]) +def test_concurrent_cross_admin_changes_preserve_a_usable_admin( + identity_database: IdentityDatabase, operation: str, +) -> None: + from modelforge_api.services.operator_identities import ( + ManagedIdentityError, + authenticate_managed_operator, + ) + + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + alpha, beta = _create(database.runtime, "alpha"), _create(database.runtime, "beta") + barrier = threading.Barrier(2) + + def contender(pair) -> str: + initiator, target = pair + with Session(database.runtime) as session: + principal = authenticate_managed_operator(session, initiator.one_time_key) + session.commit() + barrier.wait(timeout=30) + try: + service = _service(session, principal) + if operation == "revoke": + service.revoke(target.identity.slug, ManagedIdentityRevoke( + expected_version=target.identity.version, + confirm_subject=target.identity.subject, reason=REASON, + )) + else: + service.change(target.identity.slug, ManagedIdentityChange( + expected_version=target.identity.version, + role="viewer" if operation == "demote" else "admin", + enabled=operation != "disable", reason=REASON, + )) + return "applied" + except ManagedIdentityError as error: + assert error.code == "MANAGED_ADMIN_NO_LONGER_AUTHORIZED" + return "rejected" + + with ThreadPoolExecutor(max_workers=2) as pool: + assert sorted(pool.map(contender, [(alpha, beta), (beta, alpha)])) == ["applied", "rejected"] + usable_admins = 0 + for issued in (alpha, beta): + with Session(database.runtime) as session: + try: + principal = authenticate_managed_operator(session, issued.one_time_key) + usable_admins += principal.role is PrincipalRole.ADMIN + except ManagedIdentityError: + pass + assert usable_admins == 1 + with Session(database.runtime) as session: + events = list(session.scalars(select(AuditEvent).where( + AuditEvent.action.in_(["managed_identity.revoked", "managed_identity.changed"]), + ))) + assert len(events) == 1 + assert events[0].actor_id in {"managed:alpha", "managed:beta"} + + +def test_simultaneous_rotation_uses_version_cas(identity_database: IdentityDatabase) -> None: + from modelforge_api.services.operator_identities import ManagedIdentityError + + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + identity = _create(database.runtime, "rotate-admin") + barrier = threading.Barrier(2) + + def contender(_index: int) -> str: + with Session(database.runtime) as session: + barrier.wait(timeout=30) + try: + _service(session).rotate("rotate-admin", ManagedIdentityRotate( + expected_version=identity.identity.version, expires_in_days=14, + overlap_seconds=0, reason=REASON, + )) + return "applied" + except ManagedIdentityError as error: + assert error.status_code == 409 + assert error.code == "MANAGED_IDENTITY_VERSION_CONFLICT" + return "conflict" + + with ThreadPoolExecutor(max_workers=2) as pool: + assert sorted(pool.map(contender, [0, 1])) == ["applied", "conflict"] + with Session(database.runtime) as session: + assert len(list(session.scalars(select(AuditEvent).where( + AuditEvent.action == "managed_identity.rotated", + )))) == 1 + + +def test_revoked_initiator_cannot_commit_a_previously_authorized_change( + identity_database: IdentityDatabase, +) -> None: + from modelforge_api.services.operator_identities import ( + ManagedIdentityError, + authenticate_managed_operator, + ) + + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + alpha, beta = _create(database.runtime, "alpha"), _create(database.runtime, "beta") + authenticated = threading.Event() + revoked = threading.Event() + + def stale_request() -> str: + with Session(database.runtime) as session: + principal = authenticate_managed_operator(session, alpha.one_time_key) + session.commit() + authenticated.set() + assert revoked.wait(timeout=30) + with pytest.raises(ManagedIdentityError) as rejected: + _service(session, principal).rotate("beta", ManagedIdentityRotate( + expected_version=beta.identity.version, expires_in_days=14, + overlap_seconds=0, reason=REASON, + )) + assert rejected.value.status_code in {401, 403} + return "rejected" + + with ThreadPoolExecutor(max_workers=1) as pool: + pending = pool.submit(stale_request) + assert authenticated.wait(timeout=30) + try: + with Session(database.runtime) as session: + _service(session).revoke("alpha", ManagedIdentityRevoke( + expected_version=alpha.identity.version, + confirm_subject=alpha.identity.subject, reason=REASON, + )) + finally: + revoked.set() + assert pending.result(timeout=30) == "rejected" + with Session(database.runtime) as session: + assert authenticate_managed_operator(session, beta.one_time_key).role is PrincipalRole.ADMIN + + +class ManagedPostgresTools(PostgresEngine): + """Use real psql in the managed DB container, retaining the production SQL adapter.""" + + def _run( + self, argv: list[str], target: PostgresTarget, *, stdin: str | None = None, + ) -> CommandResult: + container = os.getenv("MODELFORGE_TEST_POSTGRES_CONTAINER") + if container: + assert re.fullmatch(r"[0-9a-f]{12,64}", container) + argv = list(argv) + argv[argv.index("--host") + 1] = "127.0.0.1" + argv = ["docker", "exec", "-i", "-e", "PGPASSWORD", container, *argv] + return super()._run(argv, target, stdin=stdin) + + +def test_supported_restore_quarantines_old_managed_keys_until_fresh_issuance( + identity_database: IdentityDatabase, tmp_path: Path, +) -> None: + from modelforge_api.services.operator_identities import ( + ManagedIdentityError, + authenticate_managed_operator, + ) + from modelforge_api.services.recovery import RecoveryError, RecoveryService + + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + issued = _create(database.runtime, "restore-admin") + with Session(database.runtime) as session: + assert authenticate_managed_operator(session, issued.one_time_key).role is PrincipalRole.ADMIN + session.commit() + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "managed-identity.dump" + database.archive(archive) + database.archive(archive, restore=True) + with Session(database.restored_runtime) as session: + assert len(list(session.scalars(select(ManagedOperatorCredentialActivation)))) == 1 + assert semantic_fingerprint(session.connection())["tables"] == source["tables"] + + # Exercise the supported recovery phase itself, with the actual restored database, + # audit-chain checks and marker CAS; only the tool's execution location is adapted. + restore_id = uuid.uuid4() + owner = database.restored_owner + with Session(database.runtime) as session: + recovery = RecoveryService(session, Settings( + _env_file=None, env="test", + alembic_directory=Path(__file__).resolve().parents[1] / "alembic", + )) + recovery.engine = ManagedPostgresTools(base_environment=dict(os.environ), timeout_seconds=60) + record = SimpleNamespace(id=restore_id, source_fingerprint=source) + plan = SimpleNamespace( + database_destination=owner.render_as_string(hide_password=False), + backup_set=SimpleNamespace(backup_id="managed-identity-qualification", + schema_revision=CURRENT, completed_at=datetime.now(UTC)), + validation_requirements={}, + ) + with pytest.raises(RecoveryError) as unready: + recovery._validate_restored(record, plan) + assert unready.value.details["gate"]["current_truth_reconciled"] is False + session.rollback() + result = recovery._reconcile_restored(record, plan) + session.commit() + assert result["cleared_current_truth"]["managed_operator_credential_activations"] == 1 + ready = recovery._validate_restored(record, plan) + assert ready["gate"]["current_truth_reconciled"] is True + assert ready["residual_current_truth_rows"]["managed_operator_credential_activations"] == 0 + session.commit() + with Session(database.restored_runtime) as session: + assert list(session.scalars(select(ManagedOperatorCredentialActivation))) == [] + restored = semantic_fingerprint(session.connection()) + for table in ("managed_operator_state", "managed_operator_identities", + "managed_operator_credentials"): + assert restored["tables"][table] == source["tables"][table] + with pytest.raises(ManagedIdentityError): + authenticate_managed_operator(session, issued.one_time_key) + session.rollback() + fresh = _service(session).rotate("restore-admin", ManagedIdentityRotate( + expected_version=issued.identity.version, expires_in_days=14, + overlap_seconds=0, reason=REASON, + )) + with Session(database.restored_runtime) as session: + assert authenticate_managed_operator(session, fresh.one_time_key).role is PrincipalRole.ADMIN + with pytest.raises(ManagedIdentityError): + authenticate_managed_operator(session, issued.one_time_key) diff --git a/backend/tests/test_manifests.py b/backend/tests/test_manifests.py index 97634f8..e005611 100644 --- a/backend/tests/test_manifests.py +++ b/backend/tests/test_manifests.py @@ -1,7 +1,9 @@ from pathlib import Path import pytest +from pydantic import ValidationError +from modelforge_api.domain.contracts import ProjectManifest from modelforge_api.services.manifest_registry import PROJECT_ROOT, ManifestRegistry @@ -26,3 +28,35 @@ def test_project_referencing_missing_contract_is_rejected(tmp_path: Path) -> Non ) with pytest.raises(ValueError, match="missing capability"): ManifestRegistry(tmp_path).projects() + + +def test_duplicate_project_manifest_identity_is_rejected(tmp_path: Path) -> None: + (tmp_path / "capabilities").mkdir() + projects = tmp_path / "projects" + projects.mkdir() + (projects / "first.yaml").write_text( + "project:\n id: shared\n name: First\n description: First declaration\nbindings: {}\n", + encoding="utf-8", + ) + (projects / "second.yaml").write_text( + "project:\n id: shared\n name: Second\n description: Conflicting declaration\nbindings: {}\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="duplicate project manifest identity"): + ManifestRegistry(tmp_path).projects() + + +def test_project_manifest_rejects_secret_bearing_extra_fields() -> None: + payload = { + "project": { + "id": "local-consumer", + "name": "Local Consumer", + "description": "A non-secret project contract.", + "service_token": "placeholder-only", + }, + "bindings": {}, + } + + with pytest.raises(ValidationError): + ProjectManifest.model_validate(payload) diff --git a/backend/tests/test_migration_engine_m13.py b/backend/tests/test_migration_engine_m13.py index 5b7e51c..6e4a73a 100644 --- a/backend/tests/test_migration_engine_m13.py +++ b/backend/tests/test_migration_engine_m13.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import uuid +from datetime import timedelta import pytest from sqlalchemy import create_engine @@ -579,3 +580,28 @@ def test_migration_events_are_append_only(session: Session) -> None: with pytest.raises(ValueError, match="append-only"): stored.reason = "rewritten" session.commit() + + +def test_plan_index_respects_the_requested_result_limit(session: Session) -> None: + service, first = planned_migration(session, target="m13-index-first") + source = session.get(MigrationPlan, first.id) + assert source is not None + values = {column.key: getattr(source, column.key) for column in MigrationPlan.__table__.columns} + values.update( + id=uuid.uuid4(), + target_shadow_target="m13-index-second", + idempotency_key=f"m13-index-second-{uuid.uuid4()}", + plan_fingerprint=digest("m13-index-second-plan"), + created_at=source.created_at + timedelta(seconds=1), + ) + second = MigrationPlan(**values) + session.add(second) + session.commit() + + limited = service.plans(limit=1) + complete = service.plans(limit=2) + + assert len(limited) == 1 + assert limited[0].id == second.id + assert len(complete) == 2 + assert {item.id for item in complete} == {first.id, second.id} diff --git a/backend/tests/test_migration_plan_index_api.py b/backend/tests/test_migration_plan_index_api.py new file mode 100644 index 0000000..c6f3b73 --- /dev/null +++ b/backend/tests/test_migration_plan_index_api.py @@ -0,0 +1,36 @@ +from uuid import uuid4 + +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from modelforge_api.api.routes.migrations import get_service +from modelforge_api.main import app +from modelforge_api.settings import Settings, get_settings + +_TOKEN = uuid4().hex +_HEADERS = {"X-ModelForge-Admin-Token": _TOKEN} + + +def test_migration_plan_index_passes_the_bounded_limit_and_rejects_out_of_range_values() -> None: + class IndexService: + requested_limit: int | None = None + + def plans(self, limit: int) -> list[object]: + self.requested_limit = limit + return [] + + service = IndexService() + app.dependency_overrides[get_settings] = lambda: Settings( + _env_file=None, operator_api_key=SecretStr(_TOKEN) + ) + app.dependency_overrides[get_service] = lambda: service + try: + client = TestClient(app) + response = client.get("/api/v1/admin/migrations/plans?limit=37", headers=_HEADERS) + invalid = client.get("/api/v1/admin/migrations/plans?limit=201", headers=_HEADERS) + + assert response.status_code == 200 + assert service.requested_limit == 37 + assert invalid.status_code == 422 + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/test_model_change_recovery.py b/backend/tests/test_model_change_recovery.py new file mode 100644 index 0000000..60ddcd8 --- /dev/null +++ b/backend/tests/test_model_change_recovery.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from copy import deepcopy + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import Base +from modelforge_api.services.recovery_fingerprint import ( + CURRENT_TRUTH_TABLES, + MODEL_CHANGE_SCHEMA, + MODEL_CHANGE_TABLES, + PORTFOLIO_SCHEMA, + SERVING_GENERATION_SCHEMA, + _without_lab_execution, + _without_model_changes, + audit_fingerprint_differences_are_compatible, + classified_tables, + fingerprint_compatibility, + fingerprint_session, + unclassified_tables, +) + + +def _current_fingerprint() -> dict[str, object]: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as session: + return _without_lab_execution(fingerprint_session(session)) + + +def test_model_change_cases_are_authoritative_history() -> None: + assert frozenset({"model_change_cases"}) == MODEL_CHANGE_TABLES + assert classified_tables() >= MODEL_CHANGE_TABLES + assert not MODEL_CHANGE_TABLES & CURRENT_TRUTH_TABLES + assert unclassified_tables() == frozenset() + + +def test_schema_0035_requires_exact_model_change_history() -> None: + from modelforge_api.services.recovery_fingerprint import _without_portfolio + + source = _without_portfolio(_current_fingerprint()) + restored = deepcopy(source) + + contract = fingerprint_compatibility( + source, + restored, + source_schema_revision=MODEL_CHANGE_SCHEMA, + ) + assert contract.compatible is True + assert contract.mode.startswith("ALERT_NOTIFICATION_UPGRADE|MODEL_CHANGE_STRICT|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) is True + + changed = deepcopy(restored) + entry = changed["tables"]["model_change_cases"] # type: ignore[index] + entry["digest"] = "f" * 64 # type: ignore[index] + assert audit_fingerprint_differences_are_compatible(source, changed, contract) is False + + +def test_schema_0034_upgrade_may_introduce_only_empty_model_change_history() -> None: + restored = _current_fingerprint() + from modelforge_api.services.recovery_fingerprint import _without_portfolio + + source = _without_portfolio(_without_model_changes(restored)) + + contract = fingerprint_compatibility( + source, + restored, + source_schema_revision=SERVING_GENERATION_SCHEMA, + ) + assert contract.compatible is True + assert contract.mode.startswith("ALERT_NOTIFICATION_UPGRADE|PORTFOLIO_UPGRADE|MODEL_CHANGE_UPGRADE|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) is True + + populated = deepcopy(restored) + entry = populated["tables"]["model_change_cases"] # type: ignore[index] + entry["row_count"] = 1 # type: ignore[index] + entry["digest"] = "e" * 64 # type: ignore[index] + rejected = fingerprint_compatibility( + source, + populated, + source_schema_revision=SERVING_GENERATION_SCHEMA, + ) + assert rejected.compatible is False + + +def test_schema_0036_requires_exact_portfolio_history() -> None: + source = _current_fingerprint() + restored = deepcopy(source) + + contract = fingerprint_compatibility( + source, + restored, + source_schema_revision=PORTFOLIO_SCHEMA, + ) + assert contract.compatible is True + assert contract.mode.startswith("ALERT_NOTIFICATION_UPGRADE|PORTFOLIO_STRICT|MODEL_CHANGE_STRICT|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) is True + + changed = deepcopy(restored) + changed["tables"]["portfolio_reconciliation_runs"]["digest"] = "f" * 64 # type: ignore[index] + assert audit_fingerprint_differences_are_compatible(source, changed, contract) is False + + +def test_schema_0035_upgrade_may_introduce_only_empty_portfolio_history() -> None: + from modelforge_api.services.recovery_fingerprint import _without_portfolio + + restored = _current_fingerprint() + source = _without_portfolio(restored) + + contract = fingerprint_compatibility( + source, + restored, + source_schema_revision=MODEL_CHANGE_SCHEMA, + ) + assert contract.compatible is True + assert contract.mode.startswith("ALERT_NOTIFICATION_UPGRADE|PORTFOLIO_UPGRADE|MODEL_CHANGE_STRICT|") + assert audit_fingerprint_differences_are_compatible(source, restored, contract) is True + + populated = deepcopy(restored) + populated["tables"]["portfolio_reconciliation_runs"]["row_count"] = 1 # type: ignore[index] + rejected = fingerprint_compatibility( + source, + populated, + source_schema_revision=MODEL_CHANGE_SCHEMA, + ) + assert rejected.compatible is False diff --git a/backend/tests/test_model_changes.py b/backend/tests/test_model_changes.py new file mode 100644 index 0000000..a9115ee --- /dev/null +++ b/backend/tests/test_model_changes.py @@ -0,0 +1,200 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy.dialects import sqlite + +from modelforge_api.domain.model_changes import ( + ModelChangeCandidateLink, + ModelChangeEvidenceLink, + ModelChangeResponse, +) +from modelforge_api.persistence.models import ( + CapabilityDeployment, + DiscoveryCandidateAssessment, + SelectedModelComparison, +) +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.model_changes import ModelChangeError, ModelChangeService + + +def _service() -> tuple[ModelChangeService, MagicMock]: + session = MagicMock() + service = ModelChangeService(session, AuditContext("operator", "test-user", "test-correlation")) + return service, session + + +def _open_case() -> SimpleNamespace: + return SimpleNamespace( + id=uuid4(), + project_binding_id=uuid4(), + current_deployment_id=uuid4(), + candidate_model_id=None, + candidate_revision_id=None, + candidate_artifact_set_id=None, + candidate_deployment_id=None, + discovery_assessment_id=None, + download_plan_id=None, + artifact_job_id=None, + selected_comparison_id=None, + migration_plan_id=None, + lifecycle_plan_id=None, + status="searching", + status_reason="", + version=1, + closed_at=None, + updated_by="test-user", + ) + + +def test_list_returns_a_bounded_filtered_page_with_stable_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, session = _service() + binding_id = uuid4() + row = SimpleNamespace(id=uuid4()) + session.scalar.return_value = 241 + session.scalars.return_value.all.return_value = [row] + monkeypatch.setattr( + service, + "_response", + lambda item: ModelChangeResponse.model_construct(id=item.id), + ) + + page = service.list( + project_key="examplerag", + project_binding_id=binding_id, + open_only=True, + limit=100, + offset=100, + ) + + assert page.total == 241 + assert [item.id for item in page.items] == [row.id] + statement = session.scalars.call_args.args[0] + sql = str(statement.compile(dialect=sqlite.dialect())) + assert "ORDER BY model_change_cases.updated_at DESC, model_change_cases.id ASC" in sql + assert "LIMIT ? OFFSET ?" in sql + assert statement._limit_clause.value == 100 + assert statement._offset_clause.value == 100 + count_sql = str(session.scalar.call_args.args[0].compile(dialect=sqlite.dialect())) + assert "model_change_cases.closed_at IS NULL" in count_sql + assert "model_change_cases.project_binding_id" in count_sql + assert 'projects."key" =' in count_sql + + +def _binding_context(row: SimpleNamespace) -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, SimpleNamespace]: + binding = SimpleNamespace(id=row.project_binding_id) + project = SimpleNamespace(id=uuid4(), key="examplerag", name="ExampleRAG") + contract = SimpleNamespace(id=uuid4()) + capability = SimpleNamespace(id=uuid4(), key="rag.embedding") + return binding, project, contract, capability + + +def test_candidate_deployment_can_never_be_the_current_production_deployment(monkeypatch: pytest.MonkeyPatch) -> None: + service, session = _service() + row = _open_case() + binding, project, contract, capability = _binding_context(row) + deployment_id = row.current_deployment_id + deployment = SimpleNamespace( + id=deployment_id, + capability_contract_id=contract.id, + artifact_set_id=uuid4(), + production=True, + channel="stable", + status="stable", + deprecated_at=None, + ) + monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) + monkeypatch.setattr(service, "_binding_context", lambda _binding_id: (binding, project, contract, capability)) + session.get.side_effect = lambda model, identifier: deployment if model is CapabilityDeployment and identifier == deployment_id else None + + request = ModelChangeCandidateLink(expected_version=1, candidate_deployment_id=deployment_id) + with pytest.raises(ModelChangeError) as exc: + service.link_candidate(row.id, request) + + assert exc.value.code == "MODEL_CHANGE_CANDIDATE_DEPLOYMENT_INVALID" + session.commit.assert_not_called() + + +def test_comparison_evidence_must_match_exact_project_and_current_candidate_pair(monkeypatch: pytest.MonkeyPatch) -> None: + service, session = _service() + row = _open_case() + row.candidate_deployment_id = uuid4() + binding, project, contract, capability = _binding_context(row) + comparison_id = uuid4() + comparison = SimpleNamespace( + id=comparison_id, + project_binding_id=uuid4(), + current_deployment_id=row.current_deployment_id, + candidate_deployment_id=row.candidate_deployment_id, + status="completed", + ) + monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) + monkeypatch.setattr(service, "_binding_context", lambda _binding_id: (binding, project, contract, capability)) + session.get.side_effect = lambda model, identifier: comparison if model is SelectedModelComparison and identifier == comparison_id else None + + request = ModelChangeEvidenceLink(expected_version=1, selected_comparison_id=comparison_id) + with pytest.raises(ModelChangeError) as exc: + service.link_evidence(row.id, request) + + assert exc.value.code == "MODEL_CHANGE_COMPARISON_MISMATCH" + session.commit.assert_not_called() + + +def test_discovery_assessment_must_belong_to_exact_immutable_revision(monkeypatch: pytest.MonkeyPatch) -> None: + service, session = _service() + row = _open_case() + binding, project, contract, capability = _binding_context(row) + model_id = uuid4() + revision_id = uuid4() + assessment_id = uuid4() + revision = SimpleNamespace(id=revision_id, model_id=model_id, resolved_commit_sha="exact-sha") + model = SimpleNamespace(id=model_id) + assessment = SimpleNamespace( + id=assessment_id, + model_id=model_id, + resolved_commit_sha="different-sha", + ) + monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) + monkeypatch.setattr(service, "_binding_context", lambda _binding_id: (binding, project, contract, capability)) + + def get(model_type: object, identifier: object) -> object | None: + from modelforge_api.persistence.models import Model, ModelRevision + + if model_type is ModelRevision and identifier == revision_id: + return revision + if model_type is Model and identifier == model_id: + return model + if model_type is DiscoveryCandidateAssessment and identifier == assessment_id: + return assessment + return None + + session.get.side_effect = get + request = ModelChangeCandidateLink( + expected_version=1, + candidate_model_id=model_id, + candidate_revision_id=revision_id, + discovery_assessment_id=assessment_id, + ) + + with pytest.raises(ModelChangeError) as exc: + service.link_candidate(row.id, request) + + assert exc.value.code == "MODEL_CHANGE_ASSESSMENT_MISMATCH" + session.commit.assert_not_called() + + +def test_stale_case_version_is_rejected_before_candidate_mutation(monkeypatch: pytest.MonkeyPatch) -> None: + service, session = _service() + row = _open_case() + row.version = 7 + monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) + + request = ModelChangeCandidateLink(expected_version=6, candidate_model_id=uuid4()) + with pytest.raises(ModelChangeError) as exc: + service.link_candidate(row.id, request) + + assert exc.value.code == "MODEL_CHANGE_VERSION_CONFLICT" + session.commit.assert_not_called() diff --git a/backend/tests/test_model_update_polling.py b/backend/tests/test_model_update_polling.py new file mode 100644 index 0000000..f0ac515 --- /dev/null +++ b/backend/tests/test_model_update_polling.py @@ -0,0 +1,78 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import Mock + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import Base, Model, ModelRevision, ModelUpdateWatch +from modelforge_api.services.model_update_polling import ModelUpdatePollingService +from modelforge_api.services.registry import RegistryConflict +from modelforge_api.settings import Settings + + +def test_monitor_checks_only_one_due_enabled_unclaimed_watch_and_passes_due_fence() -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + settings = Settings(_env_file=None, env="test") + now = datetime.now(UTC) + with Session(engine) as session: + model = Model(key="chosen", display_name="Chosen", upstream_provider="huggingface", + upstream_source="fixture/chosen") + session.add(model) + session.flush() + revision = ModelRevision(model_id=model.id, upstream_revision="main", + resolved_commit_sha="a" * 40) + session.add(revision) + session.flush() + watches = [ModelUpdateWatch( + model_id=model.id, baseline_revision_id=revision.id, + baseline_commit_sha=revision.resolved_commit_sha, upstream_ref=ref, + enabled=enabled, last_checked_at=checked, check_until=claimed, + ) for ref, enabled, checked, claimed in ( + ("paused", False, None, None), + ("fresh", True, now, None), + ("busy", True, None, now + timedelta(minutes=5)), + ("due", True, now - timedelta(days=2), None), + ("later", True, now - timedelta(hours=25), None), + )] + session.add_all(watches) + session.commit() + due_id = watches[3].id + poller = ModelUpdatePollingService(settings, engine) + service = Mock() + poller.service = Mock(return_value=service) + poller.run_once() + assert service.check.call_count == 1 + assert service.check.call_args.args == (due_id,) + cutoff = service.check.call_args.kwargs["if_checked_before"] + assert now - timedelta(days=1) <= cutoff <= datetime.now(UTC) - timedelta(days=1) + # A concurrent winner is a safe no-op and cannot kill the monitor. + service.check.side_effect = RegistryConflict("already checked") + poller.run_once() + poller.stop() + poller.run_once() + assert service.check.call_count == 2 + engine.dispose() + + +def test_disabled_monitor_never_opens_database_or_constructs_provider() -> None: + engine = Mock() + poller = ModelUpdatePollingService( + Settings(_env_file=None, env="test", model_update_monitor_enabled=False), engine, + ) + poller.service = Mock() + poller.run_once() + assert engine.mock_calls == [] + poller.service.assert_not_called() + + +def test_unexpected_database_failure_is_redacted_and_does_not_escape(capsys) -> None: + # Deliberately absent tables in an isolated database; no deployment state is consulted. + engine = create_engine("sqlite://") + poller = ModelUpdatePollingService(Settings(_env_file=None, env="test"), engine) + poller.run_once() + output = capsys.readouterr().out + assert "model_update_monitor_check_failed" in output + assert "OperationalError" in output + assert "SELECT" not in output + engine.dispose() diff --git a/backend/tests/test_model_update_recovery.py b/backend/tests/test_model_update_recovery.py new file mode 100644 index 0000000..dfd9f90 --- /dev/null +++ b/backend/tests/test_model_update_recovery.py @@ -0,0 +1,118 @@ +import copy +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import ( + Base, + Model, + ModelRevision, + ModelUpdateNotice, + ModelUpdateWatch, +) +from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.recovery_fingerprint import ( + CREATIVE_TABLES, + MODEL_UPDATE_TABLES, + SECRET_SCHEMA, + SELECTED_COMPARISON_SCHEMA, + SELECTED_COMPARISON_TABLES, + _without_creative, + _without_lab_execution, + _without_model_changes, + _without_model_updates, + _without_portfolio, + _without_selected_comparisons, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, +) +from modelforge_api.services.recovery_fingerprint import ( + fingerprint_session as _current_fingerprint_session, +) + + +def fingerprint_session(session: Session): + return _without_lab_execution( + _without_portfolio(_without_model_changes(_current_fingerprint_session(session))) + ) + + +def test_previous_schema_allows_only_exact_empty_update_introduction() -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + restored = fingerprint_session(session) + source = _without_creative(_without_selected_comparisons(_without_model_updates(restored))) + assert not (CREATIVE_TABLES | SELECTED_COMPARISON_TABLES | MODEL_UPDATE_TABLES) & source["tables"].keys() + contract = fingerprint_compatibility(source, restored, source_schema_revision=SECRET_SCHEMA) + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + for table in MODEL_UPDATE_TABLES: + for field, value in (("row_count", 1), ("digest", "0" * 64), ("status", "BOUNDED")): + changed = copy.deepcopy(restored) + changed["tables"][table][field] = value + assert not fingerprint_compatibility(source, changed, source_schema_revision=SECRET_SCHEMA).compatible + assert not audit_fingerprint_differences_are_compatible(source, changed, contract) + missing = copy.deepcopy(restored) + del missing["tables"][table] + assert not fingerprint_compatibility(source, missing, source_schema_revision=SECRET_SCHEMA).compatible + engine.dispose() + + +def test_watch_claim_is_transient_but_baseline_and_notice_history_are_authoritative() -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + model = Model(key="watched", display_name="Watched", upstream_provider="huggingface", upstream_source="org/watched") + session.add(model) + session.flush() + baseline = ModelRevision(model_id=model.id, upstream_revision="main", resolved_commit_sha="a" * 40) + candidate = ModelRevision(model_id=model.id, upstream_revision="main", resolved_commit_sha="b" * 40) + session.add_all([baseline, candidate]) + session.flush() + watch = ModelUpdateWatch(model_id=model.id, baseline_revision_id=baseline.id, + baseline_commit_sha=baseline.resolved_commit_sha, upstream_ref="main", + check_token=uuid4(), check_until=datetime.now(UTC) + timedelta(minutes=5)) + session.add(watch) + session.flush() + notice = ModelUpdateNotice(watch_id=watch.id, model_id=model.id, + baseline_commit_sha=baseline.resolved_commit_sha, candidate_revision_id=candidate.id, + candidate_commit_sha=candidate.resolved_commit_sha) + session.add(notice) + session.commit() + before = fingerprint_session(session) + watch.check_token = None + watch.check_until = None + session.commit() + cleared = fingerprint_session(session) + assert before["tables"] == cleared["tables"] + AuditWriter(session, "operator", "restore-test").write("RECOVERY_RECONCILIATION_COMPLETED", "restore", None, {}) + session.commit() + restored = fingerprint_session(session) + contract = fingerprint_compatibility(before, restored, source_schema_revision=SELECTED_COMPARISON_SCHEMA) + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(before, restored, contract) + notice.status = "dismissed" + session.commit() + changed = fingerprint_session(session) + assert not audit_fingerprint_differences_are_compatible(before, changed, contract) + notice.status = "new" + watch.baseline_commit_sha = "c" * 40 + session.commit() + assert not audit_fingerprint_differences_are_compatible(before, fingerprint_session(session), contract) + engine.dispose() + + +@pytest.mark.parametrize("table", sorted(MODEL_UPDATE_TABLES)) +def test_current_schema_never_projects_away_missing_update_state(table: str) -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + before = fingerprint_session(session) + restored = copy.deepcopy(before) + del restored["tables"][table] + assert not fingerprint_compatibility(before, restored, source_schema_revision=SELECTED_COMPARISON_SCHEMA).compatible + engine.dispose() diff --git a/backend/tests/test_model_updates.py b/backend/tests/test_model_updates.py new file mode 100644 index 0000000..8fe7055 --- /dev/null +++ b/backend/tests/test_model_updates.py @@ -0,0 +1,345 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session + +from modelforge_api.api.authorization import ( + Principal, + PrincipalRole, + authenticate_operator_principal, +) +from modelforge_api.api.routes.model_updates import get_model_update_service, router +from modelforge_api.domain.model_updates import ModelWatchCreate, ModelWatchUpdate +from modelforge_api.persistence.models import ( + ArtifactJob, + AuditEvent, + Base, + CapabilityDeployment, + Model, + ModelRevision, + ModelUpdateWatch, +) +from modelforge_api.services.acquisition import AcquisitionService, ProviderBoundaryError +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.model_updates import ModelUpdateService +from modelforge_api.services.registry import RegistryConflict, RegistryService + + +@pytest.fixture +def setup(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + model = Model(key="selected", display_name="Selected", upstream_provider="huggingface", upstream_source="org/selected") + session.add(model) + session.flush() + revision = ModelRevision(model_id=model.id, upstream_revision="main", resolved_commit_sha="a" * 40) + session.add(revision) + session.commit() + acquisition = Mock(spec=AcquisitionService) + def refresh(model_id, ref): + candidate = session.scalar(select(ModelRevision).where(ModelRevision.resolved_commit_sha == "b" * 40)) + if candidate is None: + session.add(ModelRevision(model_id=model_id, upstream_revision=ref, resolved_commit_sha="b" * 40)) + session.commit() + return Mock(resolved_commit_sha="b" * 40) + acquisition.refresh_model.side_effect = refresh + service = ModelUpdateService(session, acquisition, AuditContext("operator", "test")) + request = ModelWatchCreate(model_id=model.id, baseline_revision_id=revision.id) + yield session, service, acquisition, request + engine.dispose() + + +def test_tracking_and_notice_history_explain_registry_delete_refusal(setup): + session, service, _, request = setup + watch = service.create(request) + service.check(watch.id) + notice, = service.notices() + service.dismiss(notice.id) + service.change(watch.id, ModelWatchUpdate( + expected_version=watch.version, + expected_baseline_revision_id=watch.baseline_revision_id, + baseline_revision_id=watch.baseline_revision_id, enabled=False, + reason="Keep historical tracking while paused", + )) + registry = RegistryService(session) + for revision_id, relation in ( + (watch.baseline_revision_id, "update_tracking_baseline"), + (notice.candidate_revision_id, "update_candidate_history"), + ): + with pytest.raises(RegistryConflict) as blocked: + registry.delete("model_revision", revision_id) + assert any(item["relation"] == relation for item in blocked.value.details["dependencies"]) + assert session.get(ModelRevision, revision_id) is not None + with pytest.raises(RegistryConflict) as blocked_model: + registry.delete("model", request.model_id) + assert {"update_tracking_baseline", "update_candidate_history"}.issubset( + {item["relation"] for item in blocked_model.value.details["dependencies"]}) + + +def test_check_deduplicates_dismissed_notice_and_never_deploys(setup): + session, service, acquisition, request = setup + watch = service.create(request) + assert service.create(request).id == watch.id + for _ in range(2): + assert service.check(watch.id).last_error_code is None + notice, = service.notices() + assert notice.baseline_commit_sha == "a" * 40 + assert notice.candidate_commit_sha == "b" * 40 + service.dismiss(notice.id) + service.check(watch.id) + assert len(service.notices()) == 1 + assert service.notices()[0].status == "dismissed" + assert session.scalar(select(func.count()).select_from(ArtifactJob)) == 0 + assert session.scalar(select(func.count()).select_from(CapabilityDeployment)) == 0 + acquisition.create_plan.assert_not_called() + acquisition.execute_plan.assert_not_called() + + +def test_unchanged_and_provider_failure_preserve_baseline(setup): + session, service, acquisition, request = setup + watch = service.create(request) + acquisition.refresh_model.side_effect = None + acquisition.refresh_model.return_value = Mock(resolved_commit_sha="a" * 40) + service.check(watch.id) + assert service.notices() == [] + acquisition.refresh_model.side_effect = ProviderBoundaryError("private provider details") + failed = service.check(watch.id) + assert failed.last_error_code == "upstream_unavailable" + assert failed.baseline_commit_sha == watch.baseline_commit_sha + assert failed.last_checked_at is not None + assert service.notices() == [] + assert "private provider details" not in failed.model_dump_json() + assert session.get(ModelUpdateWatch, watch.id).check_token is None + + +def test_concurrent_check_refused_and_expired_lease_recoverable(setup): + session, service, acquisition, request = setup + watch = service.create(request) + item = session.get(ModelUpdateWatch, watch.id) + item.check_until = datetime.now(UTC) + timedelta(minutes=1) + item.check_token = uuid4() + session.commit() + with pytest.raises(RegistryConflict): + service.check(watch.id) + acquisition.refresh_model.assert_not_called() + item.check_until = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + service.check(watch.id) + assert len(service.notices()) == 1 + + +def test_unexpected_failure_records_failure_and_releases_lease(setup): + session, service, acquisition, request = setup + watch = service.create(request) + acquisition.refresh_model.side_effect = RuntimeError("test crash") + with pytest.raises(RuntimeError): + service.check(watch.id) + item = session.get(ModelUpdateWatch, watch.id) + assert item.last_error_code == "check_failed" + assert item.check_token is None + assert item.baseline_commit_sha == watch.baseline_commit_sha + + +def test_baseline_mismatch_and_stale_baseline_refused(setup): + session, service, _acquisition, request = setup + watch = service.create(request) + service.check(watch.id) + revision = session.scalar(select(ModelRevision).where(ModelRevision.resolved_commit_sha == "b" * 40)) + with pytest.raises(RegistryConflict, match="explicit tracking update"): + service.create(request.model_copy(update={"baseline_revision_id": revision.id})) + other = Model(key="other", display_name="Other", upstream_provider="huggingface", upstream_source="org/other") + session.add(other) + session.commit() + with pytest.raises(RegistryConflict, match="own exact"): + service.create(request.model_copy(update={"model_id": other.id})) + + +def test_watch_bound_and_pagination(setup): + _, service, _, request = setup + for index in range(100): + service.create(request.model_copy(update={"upstream_ref": f"branch-{index}"})) + with pytest.raises(RegistryConflict, match="100"): + service.create(request) + assert len(service.watches(limit=100)) == 100 + assert len(service.watches(limit=10, offset=95)) == 5 + + +@pytest.mark.parametrize("failure", [False, True]) +def test_superseded_check_cannot_clear_successor_or_publish_notice(setup, failure): + session, service, acquisition, request = setup + watch = service.create(request) + successor = uuid4() + def supersede(model_id, ref): + item = session.get(ModelUpdateWatch, watch.id) + item.check_token = successor + item.check_until = datetime.now(UTC) + timedelta(minutes=5) + session.commit() + if failure: + raise RuntimeError("superseded failure") + return Mock(resolved_commit_sha="a" * 40) + acquisition.refresh_model.side_effect = supersede + with pytest.raises(RuntimeError if failure else RegistryConflict): + service.check(watch.id) + item = session.get(ModelUpdateWatch, watch.id) + assert item.check_token == successor + assert item.last_checked_at is None + assert service.notices() == [] + + +def test_metadata_audit_uses_authenticated_context(setup): + _, service, acquisition, _ = setup + assert acquisition.audit is service.audit + assert acquisition.audit.context.actor_id == "test" + + +@pytest.mark.parametrize("role,status", [(None,401),(PrincipalRole.VIEWER,403),(PrincipalRole.OPERATOR,200),(PrincipalRole.ADMIN,200)]) +@pytest.mark.parametrize("action", ["create", "check", "dismiss", "change"]) +def test_mutation_roles(role, status, action, setup): + _, service, _, request = setup + watch = service.create(request) + service.check(watch.id) + notice = service.notices()[0] + app = FastAPI() + app.include_router(router) + def principal(): + if role is None: + raise HTTPException(401) + return Principal("test", role, "test") + mocked = Mock() + mocked.create.return_value = watch + mocked.check.return_value = watch + mocked.dismiss.return_value = notice + mocked.change.return_value = watch + app.dependency_overrides[authenticate_operator_principal] = principal + app.dependency_overrides[get_model_update_service] = lambda: mocked + with TestClient(app) as client: + if action == "create": + response = client.post("/api/v1/model-updates/watches", json=request.model_dump(mode="json")) + elif action == "change": + response = client.patch(f"/api/v1/model-updates/watches/{watch.id}", json={ + "expected_version": watch.version, "expected_baseline_revision_id": str(watch.baseline_revision_id), + "baseline_revision_id": str(watch.baseline_revision_id), "enabled": False, "reason": "Pause tracking", + }) + else: + path = f"watches/{watch.id}/check" if action == "check" else f"notices/{notice.id}/dismiss" + response = client.post(f"/api/v1/model-updates/{path}") + assert response.status_code == status + assert getattr(mocked, action).call_count == int(status == 200) + + +def test_list_bounds_require_viewer(): + app = FastAPI() + app.include_router(router) + mocked = Mock() + mocked.watches.return_value = [] + app.dependency_overrides[get_model_update_service] = lambda: mocked + app.dependency_overrides[authenticate_operator_principal] = lambda: Principal("viewer", PrincipalRole.VIEWER, "test") + with TestClient(app) as client: + assert client.get("/api/v1/model-updates/watches").status_code == 200 + assert client.get("/api/v1/model-updates/watches?limit=101").status_code == 422 + assert client.get("/api/v1/model-updates/watches?offset=-1").status_code == 422 + + +def tracking_change(watch, **changes): + return ModelWatchUpdate(expected_version=watch.version, + expected_baseline_revision_id=watch.baseline_revision_id, + baseline_revision_id=changes.get("baseline_revision_id", watch.baseline_revision_id), + enabled=changes.get("enabled", watch.enabled), reason="Reviewed tracking change") + + +def test_pause_resume_fences_in_flight_check_and_stale_changes(setup): + session, service, acquisition, request = setup + watch = service.create(request) + original = acquisition.refresh_model.side_effect + def interrupted(model_id, ref): + candidate = original(model_id, ref) + paused = service.change(watch.id, tracking_change(watch, enabled=False)) + assert not paused.enabled and paused.version == 2 + assert session.get(ModelUpdateWatch, watch.id).check_token is None + with pytest.raises(RegistryConflict, match="changed"): + service.change(watch.id, tracking_change(watch, enabled=True)) + resumed = service.change(watch.id, tracking_change(paused, enabled=True)) + assert resumed.enabled and resumed.version == 3 + return candidate + acquisition.refresh_model.side_effect = interrupted + with pytest.raises(RegistryConflict, match="superseded"): + service.check(watch.id) + assert service.notices() == [] + assert service.watches()[0].last_checked_at is None + + +def test_paused_watch_and_not_due_check_do_not_call_provider(setup): + _, service, acquisition, request = setup + watch = service.create(request) + paused = service.change(watch.id, tracking_change(watch, enabled=False)) + with pytest.raises(RegistryConflict, match="paused"): + service.check(watch.id) + acquisition.refresh_model.assert_not_called() + service.change(watch.id, tracking_change(paused, enabled=True)) + service.check(watch.id) + acquisition.refresh_model.reset_mock() + with pytest.raises(RegistryConflict, match="not due"): + service.check(watch.id, if_checked_before=datetime.now(UTC) - timedelta(hours=1)) + acquisition.refresh_model.assert_not_called() + + +def test_baseline_advance_is_audited_cas_and_preserves_notice_provenance(setup): + session, service, _, request = setup + watch = service.create(request) + service.check(watch.id) + notice, = service.notices() + advanced = service.change(watch.id, tracking_change(watch, baseline_revision_id=notice.candidate_revision_id)) + assert advanced.baseline_commit_sha == notice.candidate_commit_sha + assert advanced.version == watch.version + 1 + assert advanced.last_checked_at is None + assert service.notices()[0] == notice + audit = session.scalar(select(AuditEvent).where(AuditEvent.action == "MODEL_WATCH_TRACKING_CHANGED")) + assert audit.details["previous"]["baseline_revision_id"] == str(watch.baseline_revision_id) + assert audit.details["baseline_revision_id"] == str(notice.candidate_revision_id) + assert audit.details["reason"] == "Reviewed tracking change" + with pytest.raises(RegistryConflict, match="changed"): + service.change(watch.id, tracking_change(watch, enabled=False)) + assert session.scalar(select(func.count()).select_from(CapabilityDeployment)) == 0 + + +def test_baseline_advance_rejects_other_models_and_active_check(setup): + session, service, _, request = setup + watch = service.create(request) + service.check(watch.id) + notice = service.notices()[0] + item = session.get(ModelUpdateWatch, watch.id) + item.check_token = uuid4() + item.check_until = datetime.now(UTC) + timedelta(minutes=1) + session.commit() + with pytest.raises(RegistryConflict, match="active check"): + service.change(watch.id, tracking_change(watch, baseline_revision_id=notice.candidate_revision_id)) + other = Model(key="other", display_name="Other", upstream_provider="huggingface", upstream_source="org/other") + session.add(other) + session.flush() + other_revision = ModelRevision(model_id=other.id, upstream_revision="main", resolved_commit_sha="c" * 40) + session.add(other_revision) + session.commit() + with pytest.raises(RegistryConflict, match="same model"): + service.change(watch.id, tracking_change(watch, baseline_revision_id=other_revision.id)) + paused = service.change(watch.id, tracking_change(watch, enabled=False)) + updated = service.change(watch.id, tracking_change(paused, baseline_revision_id=notice.candidate_revision_id)) + assert not updated.enabled + assert session.get(ModelUpdateWatch, watch.id).check_token is None + + +@pytest.mark.parametrize("env,enabled,active", [("production",True,True),("production",False,False),("development",True,False)]) +def test_monitor_status_reports_actual_environment_gate(env, enabled, active): + from modelforge_api.settings import Settings, get_settings + app = FastAPI() + app.include_router(router) + app.dependency_overrides[authenticate_operator_principal] = lambda: Principal("viewer", PrincipalRole.VIEWER, "test") + app.dependency_overrides[get_settings] = lambda: Settings(env=env, model_update_monitor_enabled=enabled, + model_update_check_interval_seconds=7200) + with TestClient(app) as client: + assert client.get("/api/v1/model-updates/monitor").json() == {"enabled": active, "check_interval_seconds": 7200} diff --git a/backend/tests/test_model_updates_postgres.py b/backend/tests/test_model_updates_postgres.py new file mode 100644 index 0000000..e3df9e0 --- /dev/null +++ b/backend/tests/test_model_updates_postgres.py @@ -0,0 +1,156 @@ +"""Actual owner/runtime PostgreSQL upgrade and dump/restore qualification.""" + +import importlib +import os +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from sqlalchemy import inspect, select, text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.orm import Session + +from modelforge_api.domain.model_updates import ModelWatchCreate, ModelWatchUpdate +from modelforge_api.domain.release import TARGET_SCHEMA_REVISION +from modelforge_api.persistence.models import Model, ModelRevision, ModelUpdateWatch +from modelforge_api.services.acquisition import AcquisitionService +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.model_updates import ModelUpdateService +from modelforge_api.services.recovery import RecoveryError, RecoveryService +from modelforge_api.services.recovery_fingerprint import ( + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from modelforge_api.settings import Settings +from tests.test_managed_identities_postgres import ( + IdentityDatabase, + ManagedPostgresTools, + identity_database, +) + +__all__ = ["identity_database"] +PRIOR = "20260909_0029" +CURRENT = "20260910_0034" + + +def test_model_watch_upgrade_prior_restore_and_runtime_permissions( + identity_database: IdentityDatabase, tmp_path: Path, +) -> None: + database = identity_database + alembic = importlib.import_module("alembic.command") + alembic.upgrade(database.migration(), PRIOR) + with database.runtime.connect() as connection: + prior_tables = set(inspect(connection).get_table_names()) + assert not {"model_update_watches", "model_update_notices"} & prior_tables + with Session(database.runtime) as session: + model = Model(key="selected-model", display_name="Selected model", + upstream_provider="huggingface", upstream_source="fixture/selected") + session.add(model) + session.flush() + revision = ModelRevision(model_id=model.id, upstream_revision="main", + resolved_commit_sha="a" * 40) + session.add(revision) + AuditWriter(session, "operator", "prior-release").write( + "qualification.baseline", "model", str(model.id), {}) + session.commit() + request = ModelWatchCreate(model_id=model.id, baseline_revision_id=revision.id) + before = semantic_fingerprint(session.connection()) + archive = tmp_path / "pre-model-updates.dump" + database.archive(archive) + alembic.upgrade(database.migration(), CURRENT) + with database.runtime.connect() as connection: + after = semantic_fingerprint(connection) + comparison = fingerprint_compatibility(before, after, source_schema_revision=PRIOR) + assert comparison.compatible + assert audit_fingerprint_differences_are_compatible(before, after, comparison) + # Empty tables are mechanically reversible; populated history is deliberately retained. + alembic.downgrade(database.migration(), PRIOR) + alembic.upgrade(database.migration(), CURRENT) + with Session(database.runtime) as session: + service = ModelUpdateService(session, Mock(spec=AcquisitionService), + AuditContext("operator", "qualified", "model-watch-pg")) + watch = service.create(request) + assert session.scalar(select(ModelUpdateWatch.baseline_commit_sha)) == "a" * 40 + assert watch.model_id == request.model_id + assert watch.enabled and watch.version == 1 + paused = service.change(watch.id, ModelWatchUpdate( + expected_version=watch.version, expected_baseline_revision_id=watch.baseline_revision_id, + baseline_revision_id=watch.baseline_revision_id, enabled=False, reason="Qualified pause", + )) + assert not paused.enabled and paused.version == 2 + with database.runtime.connect() as connection: + for statement in ( + "DELETE FROM model_update_watches", + "TRUNCATE model_update_notices CASCADE", + "ALTER TABLE model_update_watches ADD COLUMN forbidden integer", + ): + with pytest.raises(DBAPIError): + connection.execute(text(statement)) + connection.rollback() + with pytest.raises(RuntimeError, match="history exists"): + alembic.downgrade(database.migration(), PRIOR) + database.archive(archive, restore=True) + alembic.upgrade(database.migration(restored=True), CURRENT) + with database.restored_runtime.connect() as connection: + restored = semantic_fingerprint(connection) + comparison = fingerprint_compatibility(before, restored, source_schema_revision=PRIOR) + assert comparison.compatible + assert audit_fingerprint_differences_are_compatible(before, restored, comparison) + assert connection.scalar(text("SELECT count(*) FROM model_update_watches")) == 0 + + +def test_model_watch_restore_clears_execution_claim_but_preserves_tracking( + identity_database: IdentityDatabase, tmp_path: Path, +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), TARGET_SCHEMA_REVISION) + with Session(database.runtime) as session: + model = Model(key="restore-watch", display_name="Restore watch", + upstream_provider="huggingface", upstream_source="fixture/restore") + session.add(model) + session.flush() + revision = ModelRevision(model_id=model.id, upstream_revision="main", + resolved_commit_sha="a" * 40) + session.add(revision) + session.commit() + watch = ModelUpdateService(session, Mock(spec=AcquisitionService), + AuditContext("operator", "restore-test")).create( + ModelWatchCreate(model_id=model.id, baseline_revision_id=revision.id)) + row = session.get(ModelUpdateWatch, watch.id) + assert row is not None + row.check_token = uuid.uuid4() + row.check_until = datetime.now(UTC) + timedelta(minutes=5) + session.commit() + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "model-watch.dump" + database.archive(archive) + database.archive(archive, restore=True) + settings = Settings(_env_file=None, env="test", + alembic_directory=Path(__file__).resolve().parents[1] / "alembic") + with Session(database.runtime) as session: + recovery = RecoveryService(session, settings) + recovery.engine = ManagedPostgresTools(base_environment=dict(os.environ), timeout_seconds=60) + record = SimpleNamespace(id=uuid.uuid4(), source_fingerprint=source) + plan = SimpleNamespace( + database_destination=database.restored_owner.render_as_string(hide_password=False), + backup_set=SimpleNamespace(backup_id="watch-qualification", schema_revision=TARGET_SCHEMA_REVISION, + completed_at=datetime.now(UTC)), validation_requirements={}, + ) + with pytest.raises(RecoveryError): + recovery._validate_restored(record, plan) + session.rollback() + recovery._reconcile_restored(record, plan) + session.commit() + assert all(recovery._validate_restored(record, plan)["gate"].values()) + session.commit() + with database.restored_runtime.connect() as connection: + assert connection.execute(text( + "SELECT check_token, check_until FROM model_update_watches" + )).one() == (None, None) + restored = semantic_fingerprint(connection) + for table in ("model_update_watches", "model_update_notices"): + assert restored["tables"][table] == source["tables"][table] diff --git a/backend/tests/test_node_agent_api.py b/backend/tests/test_node_agent_api.py index 7615ef8..88d5943 100644 --- a/backend/tests/test_node_agent_api.py +++ b/backend/tests/test_node_agent_api.py @@ -5,6 +5,7 @@ import pytest from fastapi import HTTPException from fastapi.testclient import TestClient from hardware_fakes import FakeAcceleratorCollector, FakeHostCollector +from http_contract_fakes import isolated_http_contract # noqa: F401 - opt-in pytest fixture from pydantic import SecretStr from sqlalchemy import create_engine, text from sqlalchemy.orm import Session @@ -18,6 +19,8 @@ from modelforge_api.services.hardware_inventory import HardwareInventoryService from modelforge_api.services.node_agent import NodeAgentService from modelforge_api.settings import Settings, get_settings +pytestmark = pytest.mark.usefixtures("isolated_http_contract") + def enrollment_payload(token: str, identity: str = "remote-a") -> dict: return { @@ -71,6 +74,16 @@ def test_agent_api_enrollment_secret_is_once_only_and_node_scoped() -> None: assert enrolled.status_code == 201 assert enrolled.headers["cache-control"] == "no-store" credential = enrolled.json()["node_credential"] + enrollments_after_use = client.get( + "/api/v1/admin/node-enrollments", + headers={"X-ModelForge-Admin-Token": "admin-key"}, + ) + assert enrollments_after_use.status_code == 200 + used_summary = next( + item for item in enrollments_after_use.json() if item["id"] == created.json()["id"] + ) + assert used_summary["enrolled_node_id"] == enrolled.json()["node_id"] + assert "enrollment_token" not in used_summary assert ( client.post("/api/v1/agent/enroll", json=enrollment_payload(token)).status_code == 401 @@ -189,6 +202,8 @@ def test_agent_openapi_contract_contains_versioned_surfaces_without_list_secret( "/api/v1/agent/telemetry", "/api/v1/admin/node-enrollments", "/api/v1/admin/hardware/nodes/{node_id}/credential/rotate", + "/api/v1/admin/hardware/nodes/{node_id}/lifecycle/preview", + "/api/v1/admin/hardware/nodes/{node_id}/lifecycle", "/api/v1/admin/hardware/nodes/{node_id}/decommission/preview", "/api/v1/admin/hardware/nodes/{node_id}/decommission", "/api/v1/agent/runtime-probes/next", @@ -199,3 +214,4 @@ def test_agent_openapi_contract_contains_versioned_surfaces_without_list_secret( assert path in schema["paths"] summary = schema["components"]["schemas"]["EnrollmentTokenSummary"] assert "enrollment_token" not in summary["properties"] + assert "enrolled_node_id" in summary["properties"] diff --git a/backend/tests/test_node_auth_scope_migration.py b/backend/tests/test_node_auth_scope_migration.py index cf4b53c..3b8abde 100644 --- a/backend/tests/test_node_auth_scope_migration.py +++ b/backend/tests/test_node_auth_scope_migration.py @@ -6,13 +6,13 @@ from types import ModuleType import pytest import sqlalchemy as sa -from sqlalchemy import Engine -from sqlalchemy.exc import IntegrityError - from alembic.config import Config from alembic.migration import MigrationContext from alembic.operations import Operations from alembic.script import ScriptDirectory +from sqlalchemy import Engine +from sqlalchemy.exc import IntegrityError + from modelforge_api.domain.release import TARGET_SCHEMA_REVISION from modelforge_api.persistence.models import NodeCredential, NodeEnrollment @@ -75,10 +75,23 @@ def test_0023_is_the_linear_auth_step_before_the_current_audit_head() -> None: scripts = ScriptDirectory.from_config(config) migration = _migration() - assert scripts.get_heads() == ["20260830_0024"] + assert scripts.get_heads() == ["20260924_0039"] assert migration.down_revision == "20260828_0022" assert scripts.get_revision("20260830_0024").down_revision == "20260830_0023" - assert TARGET_SCHEMA_REVISION == "20260830_0024" + assert scripts.get_revision("20260907_0025").down_revision == "20260830_0024" + assert scripts.get_revision("20260908_0026").down_revision == "20260907_0025" + assert scripts.get_revision("20260908_0027").down_revision == "20260908_0026" + assert scripts.get_revision("20260908_0028").down_revision == "20260908_0027" + assert scripts.get_revision("20260909_0029").down_revision == "20260908_0028" + assert scripts.get_revision("20260909_0030").down_revision == "20260909_0029" + assert scripts.get_revision("20260909_0031").down_revision == "20260909_0030" + assert scripts.get_revision("20260909_0032").down_revision == "20260909_0031" + assert scripts.get_revision("20260909_0033").down_revision == "20260909_0032" + assert scripts.get_revision("20260910_0034").down_revision == "20260909_0033" + assert scripts.get_revision("20260911_0035").down_revision == "20260910_0034" + assert scripts.get_revision("20260923_0036").down_revision == "20260911_0035" + assert TARGET_SCHEMA_REVISION == "20260924_0039" + assert scripts.get_revision(TARGET_SCHEMA_REVISION).down_revision == "20260924_0038" def test_current_model_metadata_carries_both_exact_scope_constraints() -> None: diff --git a/backend/tests/test_node_lifecycle.py b/backend/tests/test_node_lifecycle.py new file mode 100644 index 0000000..7f85127 --- /dev/null +++ b/backend/tests/test_node_lifecycle.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import SecretStr +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.routes.agent import ( + get_agent_service, + get_decommission_service, +) +from modelforge_api.api.routes.agent import ( + router as agent_router, +) +from modelforge_api.domain.agent_protocol import ( + AgentMetadata, + HeartbeatRequest, + NodeManagementUpdate, +) +from modelforge_api.domain.node_decommission import NodeDecommissionExecute +from modelforge_api.domain.node_lifecycle import ( + NodeLifecycleChange, + NodeLifecyclePreviewRequest, +) +from modelforge_api.main import agent_protocol_error, node_decommission_error +from modelforge_api.persistence.models import ( + Accelerator, + AuditEvent, + Base, + ComputeNode, + GatewayRequest, + GpuLease, + NodeCredential, +) +from modelforge_api.services.node_agent import ( + AgentAuthorizationError, + AgentConflictError, + NodeAgentService, + secret_hash, +) +from modelforge_api.services.node_decommission import ( + NodeDecommissionError, + NodeDecommissionService, +) +from modelforge_api.settings import Settings, get_settings + + +@pytest.fixture +def session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + + +def _node(session: Session, *, enabled: bool = True, liveness: str = "offline") -> ComputeNode: + node = ComputeNode( + key=f"node-{uuid.uuid4()}", + hostname="node-lifecycle-test", + display_name="Lifecycle test node", + enabled=enabled, + status="active", + liveness_state=liveness, + inventory={"source": "test"}, + observation_source="remote_agent", + agent_protocol_version=1, + agent_capabilities=["hardware.inventory"], + agent_version="1.3.0-rc.1", + last_heartbeat_at=datetime.now(UTC), + ) + session.add(node) + session.commit() + return node + + +def _request(preview, *, target_enabled: bool | None = None) -> NodeLifecycleChange: + return NodeLifecycleChange( + target_enabled=preview.target_enabled if target_enabled is None else target_enabled, + expected_generation=preview.node_generation, + dependency_digest=preview.dependency_digest, + operator="test-operator", + reason="Planned node maintenance with workloads drained.", + confirmation=preview.persisted_identity, + ) + + +def test_disable_requires_preview_confirmation_and_audits_transition(session: Session) -> None: + node = _node(session, liveness="online") + service = NodeDecommissionService(session) + + preview = service.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=False) + ) + assert preview.can_apply is True + assert preview.current_enabled is True + assert preview.target_enabled is False + assert preview.blockers == [] + + result = service.apply_enabled_change(node.id, _request(preview)) + + session.refresh(node) + assert result.enabled is False + assert result.liveness == "disabled" + assert node.enabled is False + assert node.liveness_state == "disabled" + event = session.scalar( + select(AuditEvent).where( + AuditEvent.action == "NODE_DISABLED", + AuditEvent.resource_id == str(node.id), + ) + ) + assert event is not None + assert event.details["reason"] == "Planned node maintenance with workloads drained." + + +def test_disable_fails_closed_for_active_gateway_request_and_gpu_lease(session: Session) -> None: + node = _node(session) + accelerator = Accelerator( + compute_node_id=node.id, + device_index=0, + device_uuid="GPU-node-lifecycle", + name="Test GPU", + ) + session.add(accelerator) + session.flush() + gateway = GatewayRequest( + request_id=uuid.uuid4(), + capability_key="assistant.general", + capability_version=1, + compute_node_id=node.id, + status="running", + priority="production", + input_sha256="a" * 64, + input_count=1, + ) + lease = GpuLease( + accelerator_id=accelerator.id, + deployment_id=uuid.uuid4(), + priority="production", + reserved_vram_mb=1024, + state="active", + ) + session.add_all([gateway, lease]) + session.commit() + service = NodeDecommissionService(session) + + preview = service.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=False) + ) + assert preview.can_apply is False + assert {item.code for item in preview.blockers} >= { + "active_gateway_request", + "active_gpu_lease", + } + with pytest.raises(NodeDecommissionError) as raised: + service.apply_enabled_change(node.id, _request(preview)) + assert raised.value.code == "node_lifecycle_blocked" + session.refresh(node) + assert node.enabled is True + + +def test_disable_rejects_preview_when_work_arrives_after_impact_review(session: Session) -> None: + node = _node(session) + service = NodeDecommissionService(session) + preview = service.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=False) + ) + request = _request(preview) + session.add( + GatewayRequest( + request_id=uuid.uuid4(), + capability_key="assistant.general", + capability_version=1, + compute_node_id=node.id, + status="queued", + priority="production", + input_sha256="b" * 64, + input_count=1, + ) + ) + session.commit() + + with pytest.raises(NodeDecommissionError) as raised: + service.apply_enabled_change(node.id, request) + assert raised.value.code == "node_lifecycle_preview_stale" + session.refresh(node) + assert node.enabled is True + assert node.liveness_state == "offline" + + +def test_node_generation_change_invalidates_lifecycle_preview(session: Session) -> None: + node = _node(session) + service = NodeDecommissionService(session) + preview = service.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=False) + ) + request = _request(preview) + node.display_name = "Renamed after preview" + session.commit() + + with pytest.raises(NodeDecommissionError) as raised: + service.apply_enabled_change(node.id, request) + assert raised.value.code == "node_generation_conflict" + session.refresh(node) + assert node.enabled is True + + +def test_enable_requires_active_remote_credential_and_preserves_unknown_readiness( + session: Session, +) -> None: + node = _node(session, enabled=False, liveness="disabled") + session.add( + NodeCredential( + compute_node_id=node.id, + secret_hash="c" * 64, + scope="node.publish", + ) + ) + session.commit() + service = NodeDecommissionService(session) + + preview = service.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=True) + ) + assert preview.can_apply is True + result = service.apply_enabled_change(node.id, _request(preview)) + + session.refresh(node) + assert result.enabled is True + assert result.liveness == "offline" + assert result.agent_evidence_state == "awaiting_fresh_authenticated_heartbeat" + assert node.enabled is True + assert node.liveness_state == "offline" + assert node.agent_protocol_version is None + assert node.agent_capabilities == [] + assert node.agent_version is None + assert node.last_heartbeat_at is None + assert "awaiting fresh authenticated heartbeat" in node.last_connection_error + + +def test_enable_without_active_credential_and_enable_of_tombstone_are_blocked( + session: Session, +) -> None: + node = _node(session, enabled=False, liveness="disabled") + service = NodeDecommissionService(session) + preview = service.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=True) + ) + assert preview.can_apply is False + assert "active_node_credential_missing" in {item.code for item in preview.blockers} + with pytest.raises(NodeDecommissionError) as raised: + service.apply_enabled_change(node.id, _request(preview)) + assert raised.value.code == "node_lifecycle_blocked" + with pytest.raises(AgentConflictError, match="impact preview and typed confirmation"): + NodeAgentService(session, Settings(_env_file=None)).update_node( + node.id, NodeManagementUpdate(enabled=True) + ) + + +def test_direct_node_patch_rejects_enabled_but_keeps_metadata_updates(session: Session) -> None: + node = _node(session) + service = NodeAgentService(session, Settings(_env_file=None)) + + updated = service.update_node(node.id, NodeManagementUpdate(display_name="Renamed safely")) + assert updated.display_name == "Renamed safely" + with pytest.raises(AgentConflictError, match="impact preview and typed confirmation"): + service.update_node(node.id, NodeManagementUpdate(enabled=False)) + session.refresh(node) + assert node.enabled is True + + +def test_agent_heartbeat_and_post_body_auth_refuse_node_disabled_after_prebody_auth( + session: Session, +) -> None: + node = _node(session) + credential_id = uuid.uuid4() + raw_credential = f"mfnode_{credential_id}_race-test-secret" + session.add( + NodeCredential( + id=credential_id, + compute_node_id=node.id, + secret_hash=secret_hash(raw_credential), + scope="node.publish", + ) + ) + session.commit() + + # Keep the pre-body identity cached like a request whose lifecycle changes while its body is + # being read. A later admin transaction disables the same row before request dispatch. + agent_session = Session(session.get_bind(), expire_on_commit=False) + agent_service = NodeAgentService(agent_session, Settings(_env_file=None)) + (_credential, stale_node), evidence = agent_service.authenticate_with_evidence( + f"Bearer {raw_credential}" + ) + agent_session.commit() + assert stale_node.enabled is True + + lifecycle_session = Session(session.get_bind()) + lifecycle = NodeDecommissionService(lifecycle_session) + preview = lifecycle.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=False) + ) + lifecycle.apply_enabled_change(node.id, _request(preview)) + lifecycle_session.close() + + with pytest.raises(AgentAuthorizationError, match="compute node is disabled"): + agent_service.reuse_authentication(evidence, f"Bearer {raw_credential}") + + heartbeat = HeartbeatRequest( + identity_key=node.key, + metadata=AgentMetadata( + agent_version="1.3.0-rc.1", + protocol_version=1, + supported_capabilities=["hardware.inventory"], + started_at=datetime.now(UTC), + ), + observed_at=datetime.now(UTC), + ) + with pytest.raises(AgentAuthorizationError, match="compute node is disabled"): + agent_service.heartbeat(stale_node, heartbeat) + + session.refresh(node) + assert node.enabled is False + assert node.liveness_state == "disabled" + agent_session.close() + + +def test_decommissioned_identity_cannot_be_reenabled(session: Session) -> None: + node = _node(session, enabled=False, liveness="disabled") + service = NodeDecommissionService(session) + retirement = service.preview(node.id) + service.execute( + node.id, + NodeDecommissionExecute( + expected_generation=retirement.node_generation, + preview_digest=retirement.dependency_digest, + idempotency_key=f"decom-{uuid.uuid4()}", + operator="test-operator", + reason="This test node has left service permanently.", + confirmation=retirement.persisted_identity, + ), + ) + + preview = service.preview_enabled_change( + node.id, NodeLifecyclePreviewRequest(target_enabled=True) + ) + assert preview.can_apply is False + assert "node_decommissioned" in {item.code for item in preview.blockers} + with pytest.raises(NodeDecommissionError) as raised: + service.apply_enabled_change(node.id, _request(preview)) + assert raised.value.code == "node_lifecycle_blocked" + + +def test_admin_api_requires_preview_for_enabled_patch_but_preserves_metadata_patch( + session: Session, +) -> None: + node = _node(session) + settings = Settings(_env_file=None, operator_api_key=SecretStr("admin-key")) + agent = NodeAgentService(session, settings) + lifecycle = NodeDecommissionService(session) + test_app = FastAPI() + test_app.include_router(agent_router) + test_app.add_exception_handler(AgentConflictError, agent_protocol_error) + test_app.add_exception_handler(NodeDecommissionError, node_decommission_error) + test_app.dependency_overrides[get_agent_service] = lambda: agent + test_app.dependency_overrides[get_decommission_service] = lambda: lifecycle + test_app.dependency_overrides[get_settings] = lambda: settings + + try: + with TestClient(test_app) as client: + path = f"/api/v1/admin/hardware/nodes/{node.id}/lifecycle/preview" + assert client.post(path, json={"target_enabled": False}).status_code == 401 + headers = {"X-ModelForge-Admin-Token": "admin-key"} + preview_response = client.post(path, headers=headers, json={"target_enabled": False}) + assert preview_response.status_code == 200 + preview = preview_response.json() + assert preview["can_apply"] is True + + bypass = client.patch( + f"/api/v1/admin/hardware/nodes/{node.id}", + headers=headers, + json={"enabled": False}, + ) + assert bypass.status_code == 409 + assert bypass.json()["error"]["code"] == "agent_conflict" + metadata = client.patch( + f"/api/v1/admin/hardware/nodes/{node.id}", + headers=headers, + json={"display_name": "Updated metadata"}, + ) + assert metadata.status_code == 200 + assert node.display_name == "Updated metadata" + + stale_change = { + "target_enabled": False, + "expected_generation": preview["node_generation"], + "dependency_digest": preview["dependency_digest"], + "operator": "test-operator", + "reason": "Planned node maintenance after draining workloads.", + "confirmation": preview["persisted_identity"], + } + stale = client.post( + f"/api/v1/admin/hardware/nodes/{node.id}/lifecycle", + headers=headers, + json=stale_change, + ) + assert stale.status_code == 409 + assert stale.json()["error"]["code"] == "node_generation_conflict" + + refreshed_preview = client.post( + path, headers=headers, json={"target_enabled": False} + ).json() + change = {**stale_change, "expected_generation": refreshed_preview["node_generation"], "dependency_digest": refreshed_preview["dependency_digest"]} + applied = client.post( + f"/api/v1/admin/hardware/nodes/{node.id}/lifecycle", + headers=headers, + json=change, + ) + assert applied.status_code == 200 + assert applied.json()["enabled"] is False + assert applied.json()["liveness"] == "disabled" + finally: + test_app.dependency_overrides.clear() diff --git a/backend/tests/test_node_lifecycle_postgres.py b/backend/tests/test_node_lifecycle_postgres.py new file mode 100644 index 0000000..3e42fa0 --- /dev/null +++ b/backend/tests/test_node_lifecycle_postgres.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import importlib +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError +from datetime import UTC, datetime + +import pytest +from sqlalchemy import event, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session + +pytest_plugins = ("tests.test_managed_identities_postgres",) + +from modelforge_api.domain.agent_protocol import AgentMetadata, HeartbeatRequest +from modelforge_api.domain.node_lifecycle import NodeLifecycleChange, NodeLifecyclePreviewRequest +from modelforge_api.persistence.models import ComputeNode, NodeCredential +from modelforge_api.services.node_agent import ( + AgentAuthorizationError, + NodeAgentService, + NodeAuthenticationEvidence, + secret_hash, +) +from modelforge_api.services.node_decommission import ( + NodeDecommissionError, + NodeDecommissionService, +) +from modelforge_api.settings import Settings +from tests.test_managed_identities_postgres import IdentityDatabase + + +def test_agent_request_waits_for_disable_and_rechecks_locked_node( + identity_database: IdentityDatabase, +) -> None: + """PostgreSQL proof that a pre-body auth result cannot outlive a lifecycle disable.""" + importlib.import_module("alembic.command").upgrade(identity_database.migration(), "head") + engine = identity_database.runtime + node_id = uuid.uuid4() + credential_id = uuid.uuid4() + raw = f"mfnode_{credential_id}_lifecycle-race-secret" + with Session(engine) as session: + node = ComputeNode( + id=node_id, + key=f"postgres-race-{node_id}", + hostname="postgres-race-node", + display_name="PostgreSQL lifecycle race", + enabled=True, + status="active", + liveness_state="offline", + inventory={"source": "test"}, + observation_source="remote_agent", + ) + session.add(node) + session.add( + NodeCredential( + id=credential_id, + compute_node_id=node_id, + secret_hash=secret_hash(raw), + scope="node.publish", + ) + ) + session.commit() + + preview = NodeDecommissionService(session).preview_enabled_change( + node_id, NodeLifecyclePreviewRequest(target_enabled=False) + ) + change = NodeLifecycleChange( + target_enabled=False, + expected_generation=preview.node_generation, + dependency_digest=preview.dependency_digest, + operator="postgres-race-test", + reason="Prove that disable serializes with agent authentication.", + confirmation=preview.persisted_identity, + ) + + request_session = Session(engine, expire_on_commit=False) + agent_service = NodeAgentService(request_session, Settings(_env_file=None)) + (_credential, stale_node), evidence = agent_service.authenticate_with_evidence( + f"Bearer {raw}" + ) + request_session.commit() + assert stale_node.enabled is True + + lifecycle_has_locks = threading.Event() + allow_lifecycle_to_commit = threading.Event() + agent_lock_attempted = threading.Event() + + with Session(engine) as lifecycle_session: + lifecycle_service = NodeDecommissionService(lifecycle_session) + lock_dependencies = lifecycle_service._lock_dependency_tables + + def hold_lifecycle_locks() -> None: + lock_dependencies() + lifecycle_has_locks.set() + if not allow_lifecycle_to_commit.wait(timeout=20): + raise RuntimeError("timed out holding lifecycle locks in race test") + + lifecycle_service._lock_dependency_tables = hold_lifecycle_locks # type: ignore[method-assign] + + @event.listens_for(engine, "before_cursor_execute") + def observe_agent_lock_attempt( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + normalized = statement.lower() + if ( + threading.current_thread().name.startswith("agent-request") + and "compute_nodes" in normalized + and "for update" in normalized + ): + agent_lock_attempted.set() + + try: + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="lifecycle") as lifecycle_pool: + lifecycle_future = lifecycle_pool.submit( + lifecycle_service.apply_enabled_change, node_id, change + ) + assert lifecycle_has_locks.wait(timeout=10), "lifecycle never acquired its locks" + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="agent-request") as agent_pool: + agent_future = agent_pool.submit( + _publish_authenticated_heartbeat, + agent_service, + evidence, + raw, + stale_node.key, + ) + assert agent_lock_attempted.wait(timeout=10), "agent never requested its node lock" + with pytest.raises(FutureTimeoutError): + agent_future.result(timeout=0.25) + + allow_lifecycle_to_commit.set() + lifecycle_future.result(timeout=30) + assert agent_future.result(timeout=30) == "rejected" + finally: + allow_lifecycle_to_commit.set() + event.remove(engine, "before_cursor_execute", observe_agent_lock_attempt) + request_session.close() + + with Session(engine) as session: + persisted = session.scalar(select(ComputeNode).where(ComputeNode.id == node_id)) + assert persisted is not None + assert persisted.enabled is False + assert persisted.liveness_state == "disabled" + assert persisted.generation == change.expected_generation + 1 + + +def test_heartbeat_wins_node_lock_and_invalidates_lifecycle_preview( + identity_database: IdentityDatabase, +) -> None: + """A heartbeat serialized before disable advances the CAS generation.""" + importlib.import_module("alembic.command").upgrade(identity_database.migration(), "head") + engine = identity_database.runtime + node_id = uuid.uuid4() + credential_id = uuid.uuid4() + raw = f"mfnode_{credential_id}_heartbeat-first-race-secret" + with Session(engine) as session: + node = ComputeNode( + id=node_id, + key=f"postgres-heartbeat-race-{node_id}", + hostname="postgres-heartbeat-race-node", + display_name="PostgreSQL heartbeat race", + enabled=True, + status="active", + liveness_state="offline", + inventory={"source": "test"}, + observation_source="remote_agent", + ) + session.add(node) + session.add( + NodeCredential( + id=credential_id, + compute_node_id=node_id, + secret_hash=secret_hash(raw), + scope="node.publish", + ) + ) + session.commit() + lifecycle = NodeDecommissionService(session) + preview = lifecycle.preview_enabled_change( + node_id, NodeLifecyclePreviewRequest(target_enabled=False) + ) + change = NodeLifecycleChange( + target_enabled=False, + expected_generation=preview.node_generation, + dependency_digest=preview.dependency_digest, + operator="postgres-race-test", + reason="Prove that fresh heartbeat evidence invalidates this preview.", + confirmation=preview.persisted_identity, + ) + + agent_session = Session(engine, expire_on_commit=False) + agent_service = NodeAgentService(agent_session, Settings(_env_file=None)) + (_credential, stale_node), evidence = agent_service.authenticate_with_evidence( + f"Bearer {raw}" + ) + agent_session.commit() + heartbeat_has_node_lock = threading.Event() + allow_heartbeat_to_commit = threading.Event() + lifecycle_lock_attempted = threading.Event() + original_commit = agent_session.commit + + def hold_heartbeat_commit() -> None: + heartbeat_has_node_lock.set() + if not allow_heartbeat_to_commit.wait(timeout=20): + raise RuntimeError("timed out holding the heartbeat node lock") + original_commit() + + agent_session.commit = hold_heartbeat_commit # type: ignore[method-assign] + + @event.listens_for(engine, "before_cursor_execute") + def observe_lifecycle_lock_attempt( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + normalized = statement.lower() + if ( + threading.current_thread().name.startswith("lifecycle-after-heartbeat") + and "compute_nodes" in normalized + and "for update" in normalized + ): + lifecycle_lock_attempted.set() + + request = HeartbeatRequest( + identity_key=stale_node.key, + metadata=AgentMetadata( + agent_version="1.3.0-rc.1", + protocol_version=1, + supported_capabilities=["hardware.inventory"], + started_at=datetime.now(UTC), + ), + observed_at=datetime.now(UTC), + ) + + try: + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="heartbeat") as agent_pool: + heartbeat_future = agent_pool.submit( + _publish_and_hold_authenticated_heartbeat, + agent_service, + evidence, + raw, + request, + ) + assert heartbeat_has_node_lock.wait(timeout=10), "heartbeat never acquired node lock" + with ThreadPoolExecutor( + max_workers=1, thread_name_prefix="lifecycle-after-heartbeat" + ) as lifecycle_pool: + lifecycle_future = lifecycle_pool.submit( + _apply_stale_lifecycle_change, engine, node_id, change + ) + assert lifecycle_lock_attempted.wait(timeout=10), "lifecycle never requested node lock" + with pytest.raises(FutureTimeoutError): + lifecycle_future.result(timeout=0.25) + allow_heartbeat_to_commit.set() + heartbeat_future.result(timeout=30) + assert lifecycle_future.result(timeout=30) == "node_generation_conflict" + finally: + allow_heartbeat_to_commit.set() + event.remove(engine, "before_cursor_execute", observe_lifecycle_lock_attempt) + agent_session.close() + + with Session(engine) as session: + persisted = session.scalar(select(ComputeNode).where(ComputeNode.id == node_id)) + assert persisted is not None + assert persisted.enabled is True + assert persisted.liveness_state == "online" + assert persisted.last_heartbeat_at is not None + assert persisted.generation == change.expected_generation + 1 + + +def _publish_and_hold_authenticated_heartbeat( + service: NodeAgentService, + evidence: NodeAuthenticationEvidence, + raw_credential: str, + request: HeartbeatRequest, +) -> None: + _credential, node = service.reuse_authentication(evidence, f"Bearer {raw_credential}") + service.heartbeat(node, request) + + +def _apply_stale_lifecycle_change( + engine: Engine, node_id: uuid.UUID, change: NodeLifecycleChange +) -> str: + with Session(engine) as session: + try: + NodeDecommissionService(session).apply_enabled_change(node_id, change) + except NodeDecommissionError as exc: + if exc.code != "node_generation_conflict": + raise + return exc.code + return "applied" + + +def _publish_authenticated_heartbeat( + service: NodeAgentService, + evidence: NodeAuthenticationEvidence, + raw_credential: str, + identity_key: str, +) -> str: + try: + _credential, node = service.reuse_authentication( + evidence, f"Bearer {raw_credential}" + ) + service.heartbeat( + node, + HeartbeatRequest( + identity_key=identity_key, + metadata=AgentMetadata( + agent_version="1.3.0-rc.1", + protocol_version=1, + supported_capabilities=["hardware.inventory"], + started_at=datetime.now(UTC), + ), + observed_at=datetime.now(UTC), + ), + ) + except AgentAuthorizationError: + return "rejected" + return "published" diff --git a/backend/tests/test_observability_api_m14.py b/backend/tests/test_observability_api_m14.py index ff080ea..b4c8733 100644 --- a/backend/tests/test_observability_api_m14.py +++ b/backend/tests/test_observability_api_m14.py @@ -1,9 +1,14 @@ from __future__ import annotations +import hashlib +import json from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from uuid import UUID import pytest from fastapi.testclient import TestClient +from pydantic import SecretStr from sqlalchemy import create_engine from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -11,7 +16,16 @@ from sqlalchemy.pool import StaticPool from modelforge_api.api.routes.observability import get_service from modelforge_api.main import app -from modelforge_api.persistence.models import Base +from modelforge_api.persistence.models import ( + Accelerator, + Base, + ComputeNode, + GatewayRequest, + OperationalIncident, + ProjectBinding, + ServiceClient, + ServingGpuLease, +) from modelforge_api.services.observability import ObservabilityService from modelforge_api.settings import Settings, get_settings @@ -34,6 +48,84 @@ def clean_overrides() -> Iterator[None]: app.dependency_overrides.clear() +def add_active_lease( + session: Session, + *, + lease_id: str, + lease_created_at: datetime, + expires_at: datetime, + state: str = "active", + request_status: str = "running", + heartbeat_at: datetime | None = None, + include_request: bool = True, + request_deployment_id: UUID | None = None, +) -> tuple[str, str, str, str]: + node_id = UUID(f"00000000-0000-0000-0000-{lease_id[-12:]}") + accelerator_id = UUID(f"10000000-0000-0000-0000-{lease_id[-12:]}") + deployment_id = UUID(f"20000000-0000-0000-0000-{lease_id[-12:]}") + lease_uuid = UUID(f"30000000-0000-0000-0000-{lease_id[-12:]}") + request_uuid = UUID(f"40000000-0000-0000-0000-{lease_id[-12:]}") + client_uuid = UUID(f"50000000-0000-0000-0000-{lease_id[-12:]}") + project_binding_uuid = UUID(f"60000000-0000-0000-0000-{lease_id[-12:]}") + project_uuid = UUID(f"70000000-0000-0000-0000-{lease_id[-12:]}") + contract_uuid = UUID(f"80000000-0000-0000-0000-{lease_id[-12:]}") + node = ComputeNode(id=node_id, key=f"node-{lease_id}", hostname=f"{lease_id}.local") + accelerator = Accelerator( + id=accelerator_id, + compute_node_id=node_id, + device_index=0, + device_uuid=f"GPU-{lease_id}", + name="Test GPU", + ) + binding = ProjectBinding( + id=project_binding_uuid, + project_id=project_uuid, + capability_contract_id=contract_uuid, + channel="stable", + priority="production", + ) + client = ServiceClient( + id=client_uuid, + name=f"private-client-{lease_id}", + project_binding_id=project_binding_uuid, + ) + session.add_all([node, accelerator, binding, client]) + if include_request: + session.add( + GatewayRequest( + request_id=request_uuid, + service_client_id=client_uuid, + capability_key="rag.embedding", + capability_version=1, + capability_deployment_id=request_deployment_id or deployment_id, + compute_node_id=node_id, + status=request_status, + priority="production", + input_sha256="f" * 64, + input_count=1, + ) + ) + session.add( + ServingGpuLease( + id=lease_uuid, + accelerator_id=accelerator_id, + capability_deployment_id=deployment_id, + request_id=request_uuid if include_request else None, + reserved_vram_bytes=2_048, + priority="production", + state=state, + owner=f"private-owner-{lease_id}", + created_at=lease_created_at, + expires_at=expires_at, + acquired_at=lease_created_at, + heartbeat_at=heartbeat_at or lease_created_at, + lease_type="request_execution", + materialized_vram_bytes=512, + ) + ) + return str(lease_uuid), str(project_uuid), str(node_id), str(deployment_id) + + def test_operations_routes_require_the_operator_credential(session: Session) -> None: subject = ObservabilityService(session, Settings(observability_monitor_enabled=False)) subject.ensure_defaults() @@ -56,6 +148,239 @@ def test_operations_routes_require_the_operator_credential(session: Session) -> assert response.json()["status"] == "HEALTHY" +def test_incident_list_keeps_legacy_shape_and_supports_bounded_pages(session: Session) -> None: + observed_at = datetime.now(UTC) + session.add_all([ + OperationalIncident( + fingerprint=f"{index:064x}", + title=f"Incident {index}", + state="OPEN", + severity="CRITICAL", + root_subject_type="node", + root_subject_ref=f"node-{index}", + correlation="LIKELY_ROOT", + first_seen_at=observed_at + timedelta(seconds=index), + last_seen_at=observed_at + timedelta(seconds=index), + ) + for index in range(3) + ]) + session.commit() + + service = ObservabilityService(session, Settings(observability_monitor_enabled=False)) + app.dependency_overrides[get_service] = lambda: service + app.dependency_overrides[get_settings] = lambda: Settings( + operator_api_key="operator-secret", observability_monitor_enabled=False + ) + client = TestClient(app) + headers = {"X-ModelForge-Admin-Token": "operator-secret"} + path = "/api/v1/admin/operations/incidents" + + legacy = client.get(path, headers=headers) + first_page = client.get(f"{path}?limit=2&offset=0", headers=headers) + second_page = client.get(f"{path}?limit=2&offset=2", headers=headers) + empty_page = client.get(f"{path}?limit=2&offset=3", headers=headers) + + assert legacy.status_code == 200 and len(legacy.json()) == 3 + assert first_page.status_code == 200 and len(first_page.json()) == 2 + assert second_page.status_code == 200 and len(second_page.json()) == 1 + assert empty_page.status_code == 200 and empty_page.json() == [] + assert [row["title"] for row in first_page.json() + second_page.json()] == [ + "Incident 2", "Incident 1", "Incident 0", + ] + assert set(first_page.json()[0]) == { + "id", "fingerprint", "title", "state", "severity", "root_subject_type", + "root_subject_ref", "correlation", "first_seen_at", "last_seen_at", "resolved_at", + } + assert client.get(f"{path}?limit=501&offset=0", headers=headers).status_code == 422 + assert client.get(f"{path}?offset=2", headers=headers).status_code == 422 + + +def test_active_lease_inventory_is_admin_only_and_returns_safe_current_identity( + session: Session, +) -> None: + now = datetime.now(UTC) + lease_id, project_id, node_id, deployment_id = add_active_lease( + session, + lease_id="lease-000000000001", + lease_created_at=now - timedelta(seconds=2), + expires_at=now + timedelta(seconds=30), + ) + subject = ObservabilityService(session, Settings(observability_monitor_enabled=False)) + app.dependency_overrides[get_service] = lambda: subject + tokens = {role: f"mfop_{role[0] * 43}" for role in ("viewer", "operator", "admin")} + identities = [ + { + "credential_id": f"active-leases-{role}", + "subject": f"active-leases-{role}", + "role": role, + "token_sha256": hashlib.sha256(token.encode()).hexdigest(), + "expires_at": (now + timedelta(hours=1)).isoformat(), + } + for role, token in tokens.items() + ] + app.dependency_overrides[get_settings] = lambda: Settings( + _env_file=None, + operator_api_key=None, + operator_identities=SecretStr(json.dumps(identities)), + observability_monitor_enabled=False, + ) + client = TestClient(app) + path = "/api/v1/admin/operations/active-leases" + + assert client.get(path).status_code == 401 + + for role in ("viewer", "operator"): + response = client.get(path, headers={"X-ModelForge-Admin-Token": tokens[role]}) + assert response.status_code == 403 + + response = client.get(path, headers={"X-ModelForge-Admin-Token": tokens["admin"]}) + + assert response.status_code == 200 + payload = response.json() + assert set(payload) == {"observed_at", "completeness", "limit", "returned_count", "leases"} + assert payload["completeness"] == "COMPLETE" + assert payload["limit"] == 100 + assert payload["returned_count"] == 1 + assert set(payload["leases"][0]) == { + "lease_id", + "project_id", + "node_id", + "accelerator_id", + "deployment_id", + "lease_state", + "workload_status", + "priority", + "lease_type", + "reserved_vram_bytes", + "materialized_vram_bytes", + "created_at", + "acquired_at", + "heartbeat_at", + "expires_at", + } + lease = payload["leases"][0] + assert lease["lease_id"] == lease_id + assert lease["project_id"] == project_id + assert lease["node_id"] == node_id + assert lease["deployment_id"] == deployment_id + assert lease["lease_state"] == "ACTIVE" + assert lease["workload_status"] == "RUNNING" + for forbidden in ( + "owner", + "request_id", + "private-owner", + "private-client", + "input_sha256", + "payload", + ): + assert forbidden not in response.text + assert not session.new + assert not session.dirty + assert not session.deleted + + +def test_active_lease_inventory_excludes_expired_rows_and_bounds_sorted_results( + session: Session, +) -> None: + now = datetime.now(UTC) + first_id, *_ = add_active_lease( + session, + lease_id="lease-000000000011", + lease_created_at=now - timedelta(seconds=10), + expires_at=now + timedelta(seconds=60), + ) + second_id, *_ = add_active_lease( + session, + lease_id="lease-000000000012", + lease_created_at=now - timedelta(seconds=5), + expires_at=now + timedelta(seconds=60), + ) + expired_id, *_ = add_active_lease( + session, + lease_id="lease-000000000013", + lease_created_at=now - timedelta(seconds=30), + expires_at=now - timedelta(seconds=1), + ) + terminal_id, *_ = add_active_lease( + session, + lease_id="lease-000000000014", + lease_created_at=now - timedelta(seconds=3), + expires_at=now + timedelta(seconds=60), + state="released", + ) + app.dependency_overrides[get_service] = lambda: ObservabilityService( + session, Settings(observability_monitor_enabled=False) + ) + app.dependency_overrides[get_settings] = lambda: Settings( + _env_file=None, + operator_api_key=SecretStr("operator-secret"), + observability_monitor_enabled=False, + ) + + response = TestClient(app).get( + "/api/v1/admin/operations/active-leases?limit=1&sort_by=created_at&order=desc", + headers={"X-ModelForge-Admin-Token": "operator-secret"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["completeness"] == "TRUNCATED" + assert payload["limit"] == payload["returned_count"] == 1 + assert [lease["lease_id"] for lease in payload["leases"]] == [second_id] + assert expired_id not in response.text + assert terminal_id not in response.text + assert first_id not in response.text + assert TestClient(app).get( + "/api/v1/admin/operations/active-leases?limit=501", + headers={"X-ModelForge-Admin-Token": "operator-secret"}, + ).status_code == 422 + + +def test_active_lease_inventory_marks_unresolved_workload_status_unknown( + session: Session, +) -> None: + now = datetime.now(UTC) + lease_id, project_id, *_ = add_active_lease( + session, + lease_id="lease-000000000021", + lease_created_at=now - timedelta(seconds=2), + expires_at=now + timedelta(seconds=30), + request_status="completed", + heartbeat_at=now - timedelta(minutes=5), + ) + mismatched_lease_id, *_ = add_active_lease( + session, + lease_id="lease-000000000022", + lease_created_at=now - timedelta(seconds=1), + expires_at=now + timedelta(seconds=30), + state="pending", + request_status="running", + ) + app.dependency_overrides[get_service] = lambda: ObservabilityService( + session, Settings(observability_monitor_enabled=False) + ) + app.dependency_overrides[get_settings] = lambda: Settings( + _env_file=None, + operator_api_key=SecretStr("operator-secret"), + observability_monitor_enabled=False, + ) + + response = TestClient(app).get( + "/api/v1/admin/operations/active-leases", + headers={"X-ModelForge-Admin-Token": "operator-secret"}, + ) + + assert response.status_code == 200 + leases = {item["lease_id"]: item for item in response.json()["leases"]} + lease = leases[lease_id] + assert lease["project_id"] == project_id + assert lease["workload_status"] == "UNKNOWN" + assert lease["lease_state"] == "ACTIVE" + assert lease["heartbeat_at"] == (now - timedelta(minutes=5)).isoformat().replace("+00:00", "Z") + assert leases[mismatched_lease_id]["lease_state"] == "PENDING" + assert leases[mismatched_lease_id]["workload_status"] == "UNKNOWN" + + def test_prometheus_endpoint_is_bounded_and_contains_no_request_payload( session: Session, ) -> None: diff --git a/backend/tests/test_operator_authorization.py b/backend/tests/test_operator_authorization.py index fee3df8..6824c1e 100644 --- a/backend/tests/test_operator_authorization.py +++ b/backend/tests/test_operator_authorization.py @@ -2,7 +2,7 @@ from __future__ import annotations import json import uuid -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass from datetime import UTC, datetime from unittest.mock import Mock @@ -31,10 +31,12 @@ from modelforge_api.api.authorization import ( from modelforge_api.api.request_limits import MAX_CONSECUTIVE_EMPTY_REQUEST_EVENTS from modelforge_api.api.routes.acquisition import get_acquisition_service from modelforge_api.api.routes.agent import authenticated_node, get_agent_service +from modelforge_api.api.routes.external_creative import creative_client from modelforge_api.api.routes.hardware import get_hardware_service from modelforge_api.api.routes.registry import get_registry_service from modelforge_api.api.routes.runtime import get_runtime_service from modelforge_api.api.routes.serving import ( + authenticate_check_client, authenticate_document_ocr_client, authenticate_rag_embedding_client, authenticate_rag_reranking_client, @@ -70,6 +72,19 @@ ALLOWED_ORIGIN = application_settings.cors_origin_list[0] DISALLOWED_ORIGIN = "https://not-allowed.invalid" +def _api_routes(routes: Iterable[object]) -> Iterator[APIRoute]: + """Walk both flattened and FastAPI 0.141+ nested included-router inventories.""" + + for route in routes: + if isinstance(route, APIRoute): + yield route + continue + nested_owner = getattr(route, "original_router", route) + nested_routes = getattr(nested_owner, "routes", None) + if isinstance(nested_routes, list): + yield from _api_routes(nested_routes) + + @dataclass(frozen=True, slots=True) class MachineBoundaryHarness: settings: Settings @@ -308,6 +323,7 @@ def operator_client() -> Iterator[TestClient]: TARGET_MUTATION_POLICIES = { + ("POST", "/api/v1/admin/operations/notifications/test"): "admin", # Acquisition: operators prepare and run work; approval is an administrative decision. ("POST", "/api/v1/discovery/search"): "operator", ("POST", "/api/v1/models/{model_id}/refresh-upstream"): "operator", @@ -350,6 +366,7 @@ TARGET_MUTATION_POLICIES = { DEPENDENCY_POLICIES = { + authenticate_check_client: "capability_client", require_viewer: "viewer", require_operator: "operator", require_admin: "admin", @@ -359,14 +376,17 @@ DEPENDENCY_POLICIES = { authenticate_document_ocr_client: "capability_client", authenticate_vision_embedding_client: "capability_client", authenticate_speech_transcription_client: "capability_client", + creative_client: "capability_client", } CAPABILITY_AUTHENTICATORS = { + authenticate_check_client, authenticate_rag_embedding_client, authenticate_rag_reranking_client, authenticate_document_ocr_client, authenticate_vision_embedding_client, authenticate_speech_transcription_client, + creative_client, } CAPABILITY_AUTHENTICATOR_SCOPES = { @@ -375,9 +395,16 @@ CAPABILITY_AUTHENTICATOR_SCOPES = { authenticate_document_ocr_client: "document.ocr@1", authenticate_vision_embedding_client: "vision.embedding@1", authenticate_speech_transcription_client: "speech.transcription@1", + creative_client: "creative.resource@1", } EXPECTED_CAPABILITY_AUTHENTICATORS = { + ("GET", "/api/v1/selected-model-comparisons/assigned"): authenticate_rag_embedding_client, + **{("POST", f"/api/v1/selected-model-comparisons/{{comparison_id}}/{action}"): authenticate_rag_embedding_client + for action in ("claim", "progress", "result", "finish")}, + **{("GET", f"/api/v1/capabilities/{capability}/check"): authenticate_check_client for capability in ( + "rag.embedding@1", "rag.reranking@1", "document.ocr@1", "vision.embedding@1", "speech.transcription@1", + )}, ("POST", "/api/v1/capabilities/rag.embedding@1/invoke"): authenticate_rag_embedding_client, ("POST", "/api/v1/capabilities/rag.reranking@1/invoke"): authenticate_rag_reranking_client, ("POST", "/api/v1/capabilities/document.ocr@1/invoke"): authenticate_document_ocr_client, @@ -394,6 +421,13 @@ EXPECTED_CAPABILITY_AUTHENTICATORS = { "/api/v1/capability-experiments/{route_key}/invoke", ): authenticate_rag_embedding_client, ("POST", "/v1/embeddings"): authenticate_rag_embedding_client, + ("POST", "/api/v1/capabilities/creative.resource@1/allocations"): creative_client, + ("GET", "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}"): creative_client, + ("POST", "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/heartbeat"): creative_client, + ("POST", "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/attest"): creative_client, + ("POST", "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/release"): creative_client, + ("POST", "/api/v1/capabilities/creative.resource@1/allocations/{allocation_id}/uncertain"): creative_client, + ("POST", "/api/v1/capabilities/creative.resource@1/profiles/observations"): creative_client, } @@ -423,9 +457,7 @@ def route_policy(route: APIRoute, method: str) -> str | None: def test_every_route_has_one_explicit_access_policy() -> None: unclassified: list[tuple[str, str]] = [] - for route in app.routes: - if not isinstance(route, APIRoute): - continue + for route in _api_routes(app.routes): for method in route.methods: if route_policy(route, method) is None: unclassified.append((method, route.path)) @@ -434,9 +466,7 @@ def test_every_route_has_one_explicit_access_policy() -> None: def test_every_control_plane_route_has_an_explicit_human_role_dependency() -> None: missing: list[tuple[str, str]] = [] - for route in app.routes: - if not isinstance(route, APIRoute): - continue + for route in _api_routes(app.routes): dependencies = {dependency.call for dependency in route.dependant.dependencies} for method in route.methods: if access_boundary_for_request( @@ -451,11 +481,11 @@ def test_every_control_plane_route_has_an_explicit_human_role_dependency() -> No def test_only_contractual_routes_bypass_the_control_plane_boundary() -> None: boundaries = { (method, route.path): access_boundary_for_request(method, route.path) - for route in app.routes - if isinstance(route, APIRoute) + for route in _api_routes(app.routes) for method in route.methods } assert {key for key, boundary in boundaries.items() if boundary is AccessBoundary.PUBLIC} == { + ("POST", "/api/v1/operator/login"), ("GET", "/"), ("GET", "/api/v1/health/live"), ("GET", "/api/v1/health/ready"), @@ -481,8 +511,14 @@ def test_only_contractual_routes_bypass_the_control_plane_boundary() -> None: ("GET", "/api/v1/agent/serving-jobs/next"), ("POST", "/api/v1/agent/serving-jobs/{job_id}/complete"), ("POST", "/api/v1/agent/serving-jobs/{job_id}/fail"), + ("POST", "/api/v1/agent/serving-jobs/{job_id}/cancellation"), + ("POST", "/api/v1/agent/serving-jobs/{job_id}/cancelled"), ("POST", "/api/v1/agent/serving-state"), } + for action in ("cancellation", "cancelled"): + assert access_boundary_for_request( + "POST", f"/api/v1/agent/serving-jobs/00000000-0000-0000-0000-000000000001/{action}" + ) is AccessBoundary.NODE assert { key for key, boundary in boundaries.items() if boundary is AccessBoundary.CAPABILITY_CLIENT } == set(EXPECTED_CAPABILITY_AUTHENTICATORS) @@ -490,9 +526,7 @@ def test_only_contractual_routes_bypass_the_control_plane_boundary() -> None: def test_machine_routes_have_direct_authentication_dependency_evidence() -> None: missing: list[tuple[str, str, str]] = [] - for route in app.routes: - if not isinstance(route, APIRoute): - continue + for route in _api_routes(app.routes): dependency_calls = {dependency.call for dependency in route.dependant.dependencies} for method in route.methods: boundary = access_boundary_for_request(method, route.path) @@ -508,8 +542,7 @@ def test_machine_routes_have_direct_authentication_dependency_evidence() -> None def test_each_capability_route_authenticates_its_exact_required_scope() -> None: routes = { (method, route.path): route - for route in app.routes - if isinstance(route, APIRoute) + for route in _api_routes(app.routes) for method in route.methods } for key, expected_authenticator in EXPECTED_CAPABILITY_AUTHENTICATORS.items(): @@ -586,8 +619,7 @@ def test_interactive_api_schema_is_enabled_only_for_development() -> None: def test_audited_mutations_have_the_intended_operator_or_admin_policy() -> None: routes = { (method, route.path): route_policy(route, method) - for route in app.routes - if isinstance(route, APIRoute) + for route in _api_routes(app.routes) for method in route.methods } assert {key: routes[key] for key in TARGET_MUTATION_POLICIES} == TARGET_MUTATION_POLICIES diff --git a/backend/tests/test_operator_identities.py b/backend/tests/test_operator_identities.py new file mode 100644 index 0000000..a608f59 --- /dev/null +++ b/backend/tests/test_operator_identities.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient +from pydantic import SecretStr, ValidationError +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.authorization import authenticate_operator_token +from modelforge_api.db import get_session +from modelforge_api.main import app +from modelforge_api.persistence.models import AuditEvent, Base +from modelforge_api.services.startup_validation import validate_settings +from modelforge_api.settings import Settings, get_settings + + +def token(role: str) -> str: + return "mfop_" + role[0] * 43 + + +def record(credential_role: str, **overrides: object) -> dict[str, object]: + role = credential_role + return { + "credential_id": f"test-{role}", + "subject": f"person-{role}", + "role": role, + "token_sha256": hashlib.sha256(token(role).encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + **overrides, + } + + +def settings(records: list[dict[str, object]] | None = None) -> Settings: + return Settings( + _env_file=None, env="test", operator_api_key=None, + operator_identities=SecretStr(json.dumps( + records if records is not None else [record(role) for role in ("viewer", "operator", "admin")] + )), + ) + + +@pytest.fixture +def client() -> Iterator[tuple[TestClient, Session]]: + engine = create_engine("sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + with Session(engine) as session: + app.dependency_overrides[get_settings] = lambda: settings() + app.dependency_overrides[get_session] = lambda: session + try: + yield TestClient(app), session + finally: + app.dependency_overrides.clear() + engine.dispose() + + +@pytest.mark.parametrize("role", ["viewer", "operator", "admin"]) +def test_real_key_authentication_and_no_store_identity(client: tuple[TestClient, Session], role: str) -> None: + http, _ = client + response = http.get("/api/v1/operator/identity", headers={"X-ModelForge-Admin-Token": token(role)}) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["subject"] == f"person-{role}" + assert response.json()["role"] == role + assert response.json()["credential_expires_at"] is not None + assert token(role) not in response.text + assert "token_sha256" not in response.text + assert "set-cookie" not in response.headers + + +def test_roles_and_attributable_configuration_audit(client: tuple[TestClient, Session]) -> None: + http, database = client + change = {"expected_revision": 0, "changes": {"gateway_max_batch_size": 2}} + viewer = {"X-ModelForge-Admin-Token": token("viewer")} + operator = {"X-ModelForge-Admin-Token": token("operator")} + admin = {"X-ModelForge-Admin-Token": token("admin"), "X-Correlation-ID": "named-admin-change"} + assert http.get("/api/v1/configuration/settings", headers=viewer).status_code == 200 + assert http.post("/api/v1/configuration/preview", json=change, headers=viewer).status_code == 403 + preview = http.post("/api/v1/configuration/preview", json=change, headers=operator) + assert preview.status_code == 200 + body = change | {"preview_digest": preview.json()["preview_digest"], "confirm": True} + assert http.post("/api/v1/configuration/apply", json=body, headers=operator).status_code == 403 + assert http.post("/api/v1/configuration/apply", json=body, headers=admin).status_code == 200 + event = database.scalar(select(AuditEvent).where(AuditEvent.action == "configuration.applied")) + assert event is not None and event.actor_id == "person-admin" + assert event.correlation_id == "named-admin-change" + assert token("admin") not in str(event.details) + + +@pytest.mark.parametrize("credential", [None, "unknown", "mfsvc_synthetic", "mfnode_synthetic", "mfenroll_synthetic", "a" * 513, "ünicode"]) +def test_other_credentials_are_refused_before_body(client: tuple[TestClient, Session], credential: str | None) -> None: + http, _ = client + response = http.post("/api/v1/configuration/apply", content="not JSON", headers={ + "X-ModelForge-Admin-Token": credential.encode().decode("latin1"), + } if credential and credential.isascii() else {}) + assert response.status_code == 401 + with pytest.raises(HTTPException) as failure: + authenticate_operator_token(settings(), credential) + assert failure.value.status_code == 401 + + +def test_expiry_revocation_rotation_and_no_legacy_fallback() -> None: + old = record("admin") + rotated_token = "mfop_" + "R" * 43 + rotated = record("admin", credential_id="rotated", token_sha256=hashlib.sha256(rotated_token.encode()).hexdigest()) + overlap = settings([old, rotated]) + assert authenticate_operator_token(overlap, token("admin")).subject == "person-admin" + assert authenticate_operator_token(overlap, rotated_token).subject == "person-admin" + revoked = settings([rotated]) + expired = settings([record("admin", expires_at=datetime.now(UTC).isoformat())]) + for snapshot in (revoked, expired): + snapshot.operator_api_key = SecretStr(token("admin")) + with pytest.raises(HTTPException) as failure: + authenticate_operator_token(snapshot, token("admin")) + assert failure.value.status_code == 401 + + +@pytest.mark.parametrize("records", [ + [], [record("admin", role="owner")], [record("admin", subject="legacy-operator")], + [record("admin"), record("admin")], + [record("viewer", subject="same"), record("admin", subject="same")], + [record("admin", expires_at="2026-09-09T12:00:00")], + [record("admin", plaintext="never-reflect-this")], +]) +def test_invalid_identity_configuration_fails_closed_without_echo(records: list[dict[str, object]]) -> None: + with pytest.raises(ValidationError) as failure: + settings(records) + assert "never-reflect-this" not in str(failure.value) + assert "token_sha256" not in str(failure.value) + + +def test_duplicate_json_fields_are_refused() -> None: + with pytest.raises(ValidationError): + Settings(_env_file=None, operator_identities=SecretStr('[{"subject":"one","subject":"two"}]')) + + +def test_malformed_named_namespace_never_falls_back_to_bootstrap() -> None: + candidate = settings() + candidate.operator_api_key = SecretStr("mfop_malformed") + with pytest.raises(HTTPException) as failure: + authenticate_operator_token(candidate, "mfop_malformed") + assert failure.value.status_code == 401 + + +def test_named_administrator_can_replace_bootstrap_key_in_production() -> None: + candidate = settings() + candidate.env = "production" + assert not any(issue.setting == "MODELFORGE_OPERATOR_API_KEY" for issue in validate_settings(candidate).problems) + candidate = settings([record("viewer")]) + candidate.env = "production" + assert any(issue.setting == "MODELFORGE_OPERATOR_API_KEY" for issue in validate_settings(candidate).problems) + + +@pytest.mark.parametrize("identities", [None, "", json.dumps([record("admin", expires_at="2020-01-01T00:00:00Z")])]) +def test_production_startup_refuses_missing_or_expired_administration(identities: str | None) -> None: + candidate = Settings( + _env_file=None, env="production", operator_api_key=SecretStr(""), + operator_identities=SecretStr(identities) if identities is not None else None, + ) + assert any(issue.setting == "MODELFORGE_OPERATOR_API_KEY" for issue in validate_settings(candidate).problems) diff --git a/backend/tests/test_operator_password.py b/backend/tests/test_operator_password.py new file mode 100644 index 0000000..c282bc7 --- /dev/null +++ b/backend/tests/test_operator_password.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import time +from unittest.mock import Mock + +import pytest +import redis +from fastapi import HTTPException +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from modelforge_api.api.authorization import authenticate_operator_token +from modelforge_api.api.routes import operator_login +from modelforge_api.main import app +from modelforge_api.services.operator_password import ( + hash_password, + issue_session, + parse_password_hash, + verify_password, + verify_session, +) +from modelforge_api.services.startup_validation import validate_settings +from modelforge_api.settings import Settings, get_settings + +PASSWORD = "synthetic password fixture" # noqa: S105 - synthetic test input +BOOTSTRAP = "synthetic-bootstrap-fixture-" + "x" * 32 + + +@pytest.fixture(scope="module") +def verifier() -> str: + return hash_password(PASSWORD) + + +@pytest.fixture +def settings(verifier: str) -> Settings: + return Settings( + _env_file=None, + env="test", + operator_api_key=SecretStr(BOOTSTRAP), + operator_password_hash=SecretStr(verifier), + ) + + +@pytest.fixture +def client(settings: Settings, monkeypatch: pytest.MonkeyPatch): + connection = Mock() + connection.__enter__ = Mock(return_value=connection) + connection.__exit__ = Mock(return_value=False) + connection.eval.return_value = 1 + monkeypatch.setattr(operator_login.redis.Redis, "from_url", Mock(return_value=connection)) + app.dependency_overrides[get_settings] = lambda: settings + yield TestClient(app), connection + app.dependency_overrides.pop(get_settings, None) + + +def test_salted_password_and_wrong_value(verifier: str): + assert verify_password(PASSWORD, verifier) + assert not verify_password("wrong", verifier) + assert hash_password(PASSWORD) != verifier + assert len(parse_password_hash(verifier)[0]) == 16 + + +@pytest.mark.parametrize( + "encoded", + [ + "", + "scrypt$2$8$1$a$b", + "x" * 10000, + "scrypt$999999999$8$1$a$b", + "scrypt$32768$8$1$" + "a" * 66, + ], +) +def test_malformed_or_weak_hash_rejected(encoded: str): + with pytest.raises(ValueError): + parse_password_hash(encoded) + configured = Settings(_env_file=None, operator_password_hash=SecretStr(encoded)) + problems = validate_settings(configured).problems + assert ( + any(p.setting == "MODELFORGE_OPERATOR_PASSWORD_HASH" for p in problems) + if encoded + else not any(p.setting == "MODELFORGE_OPERATOR_PASSWORD_HASH" for p in problems) + ) + assert all(encoded not in p.message for p in problems) if encoded else True + + +def test_sessions_signed_expiring_rotating(verifier: str): + token, expiry = issue_session(BOOTSTRAP, verifier, now=1000) + assert expiry == 1900 + assert verify_session(token, BOOTSTRAP, verifier, now=1000) + assert verify_session(token, BOOTSTRAP, verifier, now=1899) + assert not verify_session(token, BOOTSTRAP, verifier, now=1900) + assert not verify_session(token, BOOTSTRAP, verifier, now=999) + assert not verify_session(token, BOOTSTRAP + "rotated", verifier, now=1100) + assert not verify_session(token, BOOTSTRAP, hash_password(PASSWORD), now=1100) + assert not verify_session(token.replace("1900", "9900"), BOOTSTRAP, verifier, now=1100) + for malformed in [ + "mfpw_", + "mfpw_" + "x" * 2000, + token + ".", + "mfsvc_" + token, + "mfpw_1.901.😃.😃", + ]: + assert not verify_session(malformed, BOOTSTRAP, verifier, now=1100) + + +def test_login_session_and_legacy_key(client, settings: Settings): + http, connection = client + response = http.post("/api/v1/operator/login", json={"password": PASSWORD}) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + result = response.json() + assert result["token_type"] == "bearer" # noqa: S105 - protocol type + assert PASSWORD not in response.text and BOOTSTRAP not in response.text + assert result["expires_in"] == 900 + token = result["access_token"] + principal = authenticate_operator_token(settings, token) + assert principal.authentication_method == "password_session" + assert principal.role == "admin" + assert principal.expires_at is not None + identity = http.get("/api/v1/operator/identity", headers={"Authorization": f"Bearer {token}"}) + assert identity.status_code == 200 + assert identity.json()["authentication_method"] == "password_session" + assert identity.json()["credential_expires_at"] == result["expires_at"] + assert ( + authenticate_operator_token(settings, BOOTSTRAP).authentication_method + == "legacy_admin_token" + ) + for headers in [{"Authorization": f"Bearer {token}"}, {"X-ModelForge-Admin-Token": token}]: + # Routing 404 proves the protected request passed the authentication boundary. + assert http.get("/api/v1/not-a-route", headers=headers).status_code == 404 + assert ( + http.get("/api/v1/not-a-route", headers={"X-ModelForge-Admin-Token": PASSWORD}).status_code + == 401 + ) + connection.eval.assert_called_once() + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"password": ""}, + {"password": None}, + {"password": 123}, + {"password": ["do-not-echo"]}, + {"password": "z" * 1025}, + {"password": PASSWORD, "do-not-echo": PASSWORD}, + ], +) +def test_invalid_login_redacted(client, payload): + http, connection = client + response = http.post("/api/v1/operator/login", json=payload) + assert response.status_code == 422 + assert PASSWORD not in response.text and "do-not-echo" not in response.text + assert response.json()["error"]["details"] == {} + assert response.headers["cache-control"] == "no-store" + connection.eval.assert_called_once() + + +def test_wrong_password_and_no_config(client, settings: Settings): + http, _ = client + response = http.post("/api/v1/operator/login", json={"password": "wrong"}) + assert response.status_code == 401 and "wrong" not in response.text + settings.operator_password_hash = None + assert http.post("/api/v1/operator/login", json={"password": PASSWORD}).status_code == 503 + + +def test_limiter_runs_before_parser_and_ignores_forwarded_ip(client): + http, connection = client + connection.eval.side_effect = [1, 0, 0] + assert http.post("/api/v1/operator/login", content="not json").status_code == 422 + assert ( + http.post( + "/api/v1/operator/login", + json={"password": PASSWORD}, + headers={"X-Forwarded-For": "203.0.113.1"}, + ).status_code + == 429 + ) + assert ( + http.post( + "/api/v1/operator/login", + json={"password": PASSWORD}, + headers={"X-Forwarded-For": "203.0.113.2"}, + ).status_code + == 429 + ) + assert len({call.args[-1] for call in connection.eval.call_args_list}) == 1 + script = connection.eval.call_args.args[0] + assert "redis.call('INCR', KEYS[1])" in script + assert "redis.call('EXPIRE', KEYS[1], 60)" in script + assert "global > 30" in script and "peer > 5" in script + + +def test_redis_failure_fails_closed(client): + http, connection = client + connection.eval.side_effect = redis.ConnectionError("secret topology must not leak") + response = http.post("/api/v1/operator/login", json={"password": PASSWORD}) + assert response.status_code == 503 + assert "topology" not in response.text and PASSWORD not in response.text + + +def test_invalid_session_never_falls_back(settings: Settings): + for token in ["mfpw_fake", PASSWORD, None]: + with pytest.raises(HTTPException) as exc: + authenticate_operator_token(settings, token) + assert exc.value.status_code == 401 + token, _ = issue_session( + BOOTSTRAP, settings.operator_password_hash.get_secret_value(), now=int(time.time()) - 1000 + ) + with pytest.raises(HTTPException): + authenticate_operator_token(settings, token) + + +def test_rotations_reject_existing_session(client, settings: Settings): + http, _ = client + token = http.post("/api/v1/operator/login", json={"password": PASSWORD}).json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + settings.operator_password_hash = SecretStr(hash_password(PASSWORD)) + assert http.get("/api/v1/not-a-route", headers=headers).status_code == 401 + token = http.post("/api/v1/operator/login", json={"password": PASSWORD}).json()["access_token"] + settings.operator_api_key = SecretStr(BOOTSTRAP + "rotated") + assert ( + http.get("/api/v1/not-a-route", headers={"Authorization": f"Bearer {token}"}).status_code + == 401 + ) + + +def test_login_body_bounded_before_parsing(client): + http, connection = client + response = http.post("/api/v1/operator/login", content="x" * 9000) + assert response.status_code == 413 + assert response.headers["cache-control"] == "no-store" + assert "x" * 100 not in response.text + connection.eval.assert_called_once() + + +def test_login_requires_strong_bootstrap(client, settings: Settings): + http, _ = client + settings.operator_api_key = SecretStr("short fixture") + assert http.post("/api/v1/operator/login", json={"password": PASSWORD}).status_code == 503 + + +def test_cli_stdin_outputs_hash_only(): + import subprocess + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + result = subprocess.run( # noqa: S603 - fixed local CLI and synthetic stdin + [sys.executable, str(root / "scripts/operator_password.py")], + input=PASSWORD + "\n", + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0 and result.stderr == "" + assert PASSWORD not in result.stdout + assert verify_password(PASSWORD, result.stdout.strip()) + bad = subprocess.run( # noqa: S603 - fixed local CLI and synthetic stdin + [sys.executable, str(root / "scripts/operator_password.py")], + input="z" * 10000, + text=True, + capture_output=True, + check=False, + ) + assert bad.returncode == 2 and bad.stdout == "" and "z" * 10 not in bad.stderr + + +def test_empty_optional_password_hash_is_unset(): + for value in ["", SecretStr("")]: + configured = Settings(_env_file=None, operator_password_hash=value) + assert configured.operator_password_hash is None + assert not any( + p.setting == "MODELFORGE_OPERATOR_PASSWORD_HASH" + for p in validate_settings(configured).problems + ) + + +def test_legacy_key_with_session_prefix_remains_accepted(settings: Settings): + legacy = "mfpw_" + "synthetic-key-fixture" * 3 + settings.operator_api_key = SecretStr(legacy) + assert ( + authenticate_operator_token(settings, legacy).authentication_method == "legacy_admin_token" + ) + + +def test_password_configuration_requires_signing_key_in_production(verifier: str): + configured = Settings(_env_file=None, env="production", operator_api_key=None, + operator_password_hash=SecretStr(verifier)) + assert any(p.setting == "MODELFORGE_OPERATOR_API_KEY" and "session signing" in p.message + for p in validate_settings(configured).problems) + + +@pytest.mark.parametrize("bootstrap", [None, "", "short", "x" * 31, "x" * 32]) +def test_named_admin_cannot_replace_password_signing_key(verifier: str, bootstrap: str | None): + import hashlib + import json + from datetime import UTC, datetime, timedelta + + identities = SecretStr(json.dumps([{ + "credential_id": "fixture-admin", "subject": "fixture-person", "role": "admin", + "token_sha256": hashlib.sha256(("mfop_" + "a" * 43).encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + }])) + configured = Settings(_env_file=None, env="production", operator_identities=identities, + operator_api_key=None if bootstrap is None else SecretStr(bootstrap), + operator_password_hash=SecretStr(verifier)) + signing_problems = [p for p in validate_settings(configured).problems + if p.setting == "MODELFORGE_OPERATOR_API_KEY" and "session signing" in p.message] + assert bool(signing_problems) == (bootstrap is None or len(bootstrap) < 32) diff --git a/backend/tests/test_packaging_m17.py b/backend/tests/test_packaging_m17.py index d1edd91..9f44be7 100644 --- a/backend/tests/test_packaging_m17.py +++ b/backend/tests/test_packaging_m17.py @@ -114,7 +114,6 @@ def test_the_production_overlay_exists_and_sets_the_production_profile() -> None "MODELFORGE_RUNTIME_DB_PASSWORD", "MODELFORGE_MIGRATION_DATABASE_URL", "MODELFORGE_RUNTIME_DATABASE_URL", - "MODELFORGE_OPERATOR_API_KEY", "MODELFORGE_BACKUP_ENCRYPTION_KEY", "MODELFORGE_CORS_ORIGINS", ], @@ -215,13 +214,13 @@ def test_the_release_builder_never_mutates_a_floating_latest_tag() -> None: assert 'f"{image}:latest"' not in source -def test_upgrade_runbook_starts_prebuilt_release_images() -> None: +def test_upgrade_runbook_marks_its_v1_2_3_commands_as_historical() -> None: runbook = (ROOT / "docs" / "UPGRADE.md").read_text("utf-8") start_step = runbook.split("## 5. Start the new version", 1)[1].split("## 6. Verify", 1)[0] - version = (ROOT / "VERSION").read_text("utf-8").strip() - assert f"MODELFORGE_VERSION={version}" in start_step - assert f"MODELFORGE_API_IMAGE=modelforge-api:{version}" in start_step - assert f"MODELFORGE_WEB_IMAGE=modelforge-web:{version}" in start_step + assert "The remaining release commands document the previously documented v1.2.3 hotfix" in runbook + assert "MODELFORGE_VERSION=1.2.3" in start_step + assert "MODELFORGE_API_IMAGE=modelforge-api:1.2.3" in start_step + assert "MODELFORGE_WEB_IMAGE=modelforge-web:1.2.3" in start_step assert "up -d --no-build api web" in start_step assert "up -d --build api web" not in start_step @@ -275,15 +274,27 @@ def test_the_release_package_includes_what_an_operator_needs_to_install() -> Non module = module_from_spec(spec) spec.loader.exec_module(module) paths = set(module.ARTIFACT_PATHS) + assert "clients" in paths + for client in ("python/modelforge_client.py", "typescript/modelforge-client.mjs", "typescript/modelforge-client.d.mts"): + assert (ROOT / "clients" / client).is_file() for required in ( "docker-compose.yml", "docker-compose.production.yml", + "docker-compose.rc-ingress.yml", + "docker-compose.validation-tls.yml", ".env.example", "VERSION", "docs", "config", + "deploy/validation", "scripts/bootstrap.py", "scripts/preflight.py", + "scripts/operator_identity.py", + "scripts/rc_isolation_preflight.py", + "scripts/rc_stack_retention.py", + "scripts/m16_chaos.py", + "scripts/m16_invariants.py", + "scripts/m16_soak.py", ): assert required in paths, f"a release without {required} cannot be installed from" @@ -361,7 +372,7 @@ def test_every_manifest_the_control_plane_needs_at_startup_is_tracked() -> None: @pytest.mark.parametrize( "directory", - ["config", "backend/src", "backend/tests", "node-agent/src", "runtime-worker/src", "docs"], + ["config", "backend/src", "backend/tests", "node-agent/src", "runtime-worker/src", "docs", "clients"], ) def test_no_source_file_is_excluded_from_the_repository(directory: str) -> None: """Whatever these directories grow, none of it may be invisible to a clean clone. @@ -374,7 +385,7 @@ def test_no_source_file_is_excluded_from_the_repository(directory: str) -> None: """ tracked = _tracked_files() - suffixes = {".py", ".ts", ".tsx", ".md", ".yaml", ".yml", ".json", ".inc", ".template"} + suffixes = {".py", ".ts", ".tsx", ".mts", ".mjs", ".md", ".yaml", ".yml", ".json", ".inc", ".template"} on_disk = { str(path.relative_to(ROOT)).replace("\\", "/") for path in (ROOT / directory).rglob("*") diff --git a/backend/tests/test_portfolio_reconciliation.py b/backend/tests/test_portfolio_reconciliation.py new file mode 100644 index 0000000..bd8221d --- /dev/null +++ b/backend/tests/test_portfolio_reconciliation.py @@ -0,0 +1,617 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import ModuleType + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from fastapi.testclient import TestClient +from pydantic import SecretStr, ValidationError +from sqlalchemy import create_engine, inspect, select, text +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.authorization import AccessBoundary, access_boundary_for_request +from modelforge_api.db import get_session +from modelforge_api.domain.portfolio import PortfolioReconcileRequest +from modelforge_api.main import app +from modelforge_api.persistence.models import ( + AuditEvent, + Base, + PortfolioReconciliationRun, + PortfolioRepositoryObservation, + Project, + ProjectBinding, +) +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.portfolio_registry import ( + PortfolioRegistryError, + PortfolioRegistryService, +) +from modelforge_api.settings import Settings, get_settings + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION_PATH = ( + ROOT / "alembic" / "versions" / "20260923_0036_portfolio_repository_reconciliation.py" +) + + +def _migration() -> ModuleType: + spec = importlib.util.spec_from_file_location("portfolio_reconciliation_0036", MIGRATION_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + engine.dispose() + + +def observation( + *, + repository_id: str = "all-examplerag", + project_key: str = "examplerag", + classification: str = "gateway", + slug: str = "ITWorx/ExampleRAG", + head_sha: str = "a" * 40, + branch: str = "master", + accept_head_update: bool = False, + runtime_evidence: list[dict[str, object]] | None = None, +) -> dict[str, object]: + return { + "external_repository_id": repository_id, + "project_key": project_key, + "slug": slug, + "default_branch": branch, + "head_sha": head_sha, + "classification": classification, + "accept_head_update": accept_head_update, + "code_evidence": [ + { + "path": "backend/src/client.py", + "signal": "capability_client", + "capabilities": ["rag.embedding@1"], + } + ], + "runtime_evidence": runtime_evidence or [], + } + + +def request( + *, + snapshot_id: str = "gitea-001", + observed_at: datetime | None = None, + repositories: list[dict[str, object]] | None = None, + complete: bool = False, +) -> PortfolioReconcileRequest: + return PortfolioReconcileRequest.model_validate( + { + "source_system": "gitea", + "snapshot_id": snapshot_id, + "observed_at": (observed_at or datetime(2026, 9, 23, 12, tzinfo=UTC)).isoformat(), + "complete": complete, + "repositories": [observation()] if repositories is None else repositories, + } + ) + + +def reconcile( + session: Session, item: PortfolioReconcileRequest +): + return PortfolioRegistryService(session).reconcile( + item, AuditContext("operator", "portfolio-test", "portfolio-correlation") + ) + + +def test_reconciliation_reuses_project_and_exact_replay_is_a_noop(session: Session) -> None: + project = Project( + key="examplerag", name="ExampleRAG legacy", description="operator description", active=False + ) + session.add(project) + session.commit() + item = request() + + first = reconcile(session, item) + second = reconcile(session, item) + + assert first.projects_created == 0 + assert first.repositories_created == 1 + assert first.replayed is False + assert second.replayed is True + assert second.snapshot_digest == first.snapshot_digest + assert session.query(Project).count() == 1 + assert session.query(ProjectBinding).count() == 0 + assert session.query(PortfolioRepositoryObservation).count() == 1 + session.refresh(project) + assert (project.name, project.description, project.active) == ( + "ExampleRAG legacy", + "operator description", + False, + ) + assert session.query(PortfolioReconciliationRun).count() == 1 + assert session.scalar( + select(AuditEvent).where(AuditEvent.action == "PORTFOLIO_RECONCILED") + ) is not None + assert session.query(AuditEvent).filter_by(action="PORTFOLIO_RECONCILED").count() == 1 + + +def test_authenticated_portfolio_artifact_creates_only_source_confirmed_projects( + session: Session, +) -> None: + artifact = ROOT.parent / "docs/product/PORTFOLIO_RECONCILE_REQUEST_COMPLETE_2026-09-25.json" + item = PortfolioReconcileRequest.model_validate_json(artifact.read_text(encoding="utf-8")) + + first = reconcile(session, item) + second = reconcile(session, item) + + assert item.complete is True + assert first.repositories_created == 69 + assert first.projects_created == 12 + assert second.replayed is True + assert second.snapshot_digest == first.snapshot_digest + assert session.query(PortfolioRepositoryObservation).count() == 69 + assert session.query(Project).count() == 12 + assert session.query(ProjectBinding).count() == 0 + inventory = PortfolioRegistryService(session).inventory() + assert len(inventory.repositories) == 69 + assert sum(row.project_id is not None for row in inventory.repositories) == 12 + + +def test_non_consumer_repositories_are_recorded_without_creating_projects( + session: Session, +) -> None: + item = request( + repositories=[ + observation( + repository_id="all-docs-tool", + project_key="docs-tool", + classification="non_ai", + slug="ITWorx/Docs-Tool", + ) + ] + ) + + reconcile(session, item) + + row = session.scalar(select(PortfolioRepositoryObservation)) + view = PortfolioRegistryService(session).inventory().repositories[0] + assert row and row.project_id is None and row.project_key == "docs-tool" + assert view.project_key == "docs-tool" and view.project_id is None and view.project_name is None + assert session.query(Project).count() == 0 + + +def test_operator_classification_links_an_existing_observation_only_when_relevant( + session: Session, +) -> None: + reconcile( + session, + request( + repositories=[ + observation(classification="hosted_model", project_key="examplerag") + ] + ), + ) + row = session.scalar(select(PortfolioRepositoryObservation)) + assert row and row.project_id is None + assert session.query(Project).count() == 0 + + reconcile( + session, + request( + snapshot_id="reviewed-local-consumer", + observed_at=datetime(2026, 9, 23, 12, 5, tzinfo=UTC), + repositories=[observation(classification="gateway", project_key="examplerag")], + ), + ) + session.refresh(row) + assert row.project_id is not None + assert session.query(Project).count() == 1 + + +def test_nonlocal_to_local_head_change_stays_unlinked_until_exact_candidate_is_accepted( + session: Session, +) -> None: + start = datetime(2026, 9, 23, 12, tzinfo=UTC) + reconcile( + session, + request( + observed_at=start, + repositories=[observation(classification="hosted_model")], + ), + ) + + pending = reconcile( + session, + request( + snapshot_id="local-candidate", + observed_at=start + timedelta(minutes=5), + repositories=[ + observation(classification="gateway", head_sha="b" * 40) + ], + ), + ) + row = session.scalar(select(PortfolioRepositoryObservation)) + view = PortfolioRegistryService(session).inventory().repositories[0] + assert pending.head_changes_review_required == 1 + assert row and row.project_id is None and row.head_sha == "a" * 40 + assert row.classification == "hosted_model" + assert row.pending_head_sha == "b" * 40 + assert row.pending_classification == "gateway" + assert row.pending_project_key == "examplerag" + assert view.project_id is None and view.pending_project_key == "examplerag" + assert session.query(Project).count() == 0 + + with pytest.raises(PortfolioRegistryError) as error: + reconcile( + session, + request( + snapshot_id="accept-unreviewed-head", + observed_at=start + timedelta(minutes=7), + repositories=[ + observation( + classification="gateway", + head_sha="c" * 40, + accept_head_update=True, + ) + ], + ), + ) + assert error.value.code == "PORTFOLIO_PENDING_HEAD_CONFLICT" + session.refresh(row) + assert row.project_id is None and row.pending_head_sha == "b" * 40 + assert session.query(Project).count() == 0 + + accepted = reconcile( + session, + request( + snapshot_id="accept-local-candidate", + observed_at=start + timedelta(minutes=10), + repositories=[ + observation( + classification="gateway", + head_sha="b" * 40, + accept_head_update=True, + ) + ], + ), + ) + session.refresh(row) + assert accepted.head_changes_accepted == 1 + assert row.project_id is not None and row.head_sha == "b" * 40 + assert row.classification == "gateway" and row.pending_head_sha is None + assert row.pending_project_key is None + assert session.query(Project).count() == 1 + + +def test_duplicate_project_key_across_repositories_is_an_explicit_conflict( + session: Session, +) -> None: + documents = observation( + repository_id="all-docs-tool", + project_key="shared-key", + classification="non_ai", + slug="ITWorx/Docs-Tool", + ) + dashboard = observation( + repository_id="all-dashboard", + project_key="shared-key", + classification="runtime_observer", + slug="ITWorx/Dashboard", + ) + + with pytest.raises(PortfolioRegistryError) as error: + reconcile(session, request(repositories=[documents, dashboard])) + + assert error.value.status_code == 409 + assert error.value.code == "PORTFOLIO_PROJECT_KEY_CONFLICT" + assert session.query(PortfolioRepositoryObservation).count() == 0 + assert session.query(Project).count() == 0 + assert session.query(PortfolioReconciliationRun).count() == 0 + + +def test_snapshot_identity_conflict_does_not_change_portfolio(session: Session) -> None: + first = request() + reconcile(session, first) + changed = request( + snapshot_id=first.snapshot_id, + observed_at=first.observed_at + timedelta(minutes=1), + repositories=[observation(head_sha="b" * 40)], + ) + + with pytest.raises(PortfolioRegistryError) as error: + reconcile(session, changed) + + assert error.value.status_code == 409 + row = session.scalar(select(PortfolioRepositoryObservation)) + assert row and row.head_sha == "a" * 40 + assert session.query(PortfolioReconciliationRun).count() == 1 + + +def test_head_change_requires_explicit_operator_snapshot_acceptance(session: Session) -> None: + first_time = datetime(2026, 9, 23, 12, tzinfo=UTC) + reconcile(session, request(observed_at=first_time, complete=True)) + + pending = reconcile( + session, + request( + snapshot_id="gitea-002", + observed_at=first_time + timedelta(minutes=5), + repositories=[observation(head_sha="b" * 40)], + ), + ) + row = session.scalar(select(PortfolioRepositoryObservation)) + assert row and row.head_sha == "a" * 40 + assert row.pending_head_sha == "b" * 40 + assert pending.head_changes_review_required == 1 + assert row.source_observed_at.replace(tzinfo=UTC) == first_time + + stale = reconcile( + session, + request( + snapshot_id="gitea-old", + observed_at=first_time + timedelta(minutes=3), + repositories=[observation(head_sha="c" * 40)], + ), + ) + assert stale.stale_snapshot is False + assert stale.stale_observations == 1 + session.refresh(row) + assert row.head_sha == "a" * 40 + assert row.pending_head_sha == "b" * 40 + + accepted = reconcile( + session, + request( + snapshot_id="gitea-003", + observed_at=first_time + timedelta(minutes=7), + repositories=[observation(head_sha="b" * 40, accept_head_update=True)], + ), + ) + session.refresh(row) + assert accepted.head_changes_accepted == 1 + assert row.head_sha == "b" * 40 + assert row.pending_head_sha is None + + +def test_incomplete_snapshot_never_marks_absent_repository_stale(session: Session) -> None: + start = datetime(2026, 9, 23, 12, tzinfo=UTC) + second = observation( + repository_id="all-examplevision", project_key="examplevision", slug="ITWorx/ExampleVision" + ) + reconcile(session, request(observed_at=start, repositories=[observation(), second], complete=True)) + + partial = reconcile( + session, + request( + snapshot_id="partial", + observed_at=start + timedelta(minutes=1), + repositories=[observation()], + complete=False, + ), + ) + + examplevision = session.scalar( + select(PortfolioRepositoryObservation).where( + PortfolioRepositoryObservation.external_repository_id == "all-examplevision" + ) + ) + assert partial.repositories_marked_stale == 0 + assert examplevision and examplevision.stale_at is None + + +def test_newer_complete_snapshot_marks_only_missing_rows_stale_without_deleting(session: Session) -> None: + start = datetime(2026, 9, 23, 12, tzinfo=UTC) + second = observation( + repository_id="all-examplevision", project_key="examplevision", slug="ITWorx/ExampleVision" + ) + reconcile(session, request(observed_at=start, repositories=[observation(), second], complete=True)) + + result = reconcile( + session, + request( + snapshot_id="complete-newer", + observed_at=start + timedelta(minutes=2), + repositories=[observation()], + complete=True, + ), + ) + + examplevision = session.scalar( + select(PortfolioRepositoryObservation).where( + PortfolioRepositoryObservation.external_repository_id == "all-examplevision" + ) + ) + pokevault_project = session.scalar(select(Project).where(Project.key == "examplevision")) + assert result.repositories_marked_stale == 1 + assert examplevision and examplevision.stale_at == (start + timedelta(minutes=2)).replace(tzinfo=None) + assert pokevault_project and pokevault_project.active is True + assert session.query(PortfolioRepositoryObservation).count() == 2 + assert session.query(ProjectBinding).count() == 0 + + +def test_runtime_observations_are_separate_and_monotone(session: Session) -> None: + start = datetime(2026, 9, 23, 12, tzinfo=UTC) + running = { + "component": "backend", + "state": "running", + "observed_at": (start + timedelta(minutes=2)).isoformat(), + "observation_source": "container_inventory", + "git_revision": "1234567890ab", + } + reconcile( + session, + request(observed_at=start, repositories=[observation(runtime_evidence=[running])]), + ) + # A later source-only snapshot must not erase runtime evidence. + reconcile( + session, + request( + snapshot_id="source-only", + observed_at=start + timedelta(minutes=3), + repositories=[observation()], + ), + ) + row = session.scalar(select(PortfolioRepositoryObservation)) + assert row and row.classification == "gateway" + assert row.runtime_evidence[0]["state"] == "running" + from modelforge_api.domain.portfolio import PortfolioRuntimeEvidence + + runtime = PortfolioRuntimeEvidence.model_validate(row.runtime_evidence[0]) + assert runtime.observed_at == start + timedelta(minutes=2) + + +def test_payload_contract_rejects_secrets_and_unsafe_evidence_paths() -> None: + body = request().model_dump(mode="json") + body["api_key"] = "this-value-must-not-be-accepted" + with pytest.raises(ValidationError): + PortfolioReconcileRequest.model_validate(body) + + unsafe = request().model_dump(mode="json") + unsafe["repositories"][0]["code_evidence"][0]["path"] = "../private-key.env" + with pytest.raises(ValidationError): + PortfolioReconcileRequest.model_validate(unsafe) + + windows = request().model_dump(mode="json") + windows["repositories"][0]["code_evidence"][0]["path"] = "C:/private-key.env" + with pytest.raises(ValidationError): + PortfolioReconcileRequest.model_validate(windows) + + +def test_inventory_and_reconcile_roles_and_validation_responses_are_safe( + session: Session, +) -> None: + tokens = {role: f"mfop_{role[0] * 43}" for role in ("viewer", "operator", "admin")} + settings = Settings( + _env_file=None, + env="test", + operator_identities=SecretStr( + json.dumps( + [ + { + "subject": role, + "role": role, + "credential_id": role, + "token_sha256": hashlib.sha256(token.encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(days=1)).isoformat(), + } + for role, token in tokens.items() + ] + ) + ), + ) + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_session] = lambda: session + try: + client = TestClient(app) + assert access_boundary_for_request("GET", "/api/v1/portfolio") is AccessBoundary.CONTROL_PLANE + assert client.get("/api/v1/portfolio").status_code == 401 + assert client.get( + "/api/v1/portfolio", headers={"X-ModelForge-Admin-Token": tokens["viewer"]} + ).status_code == 200 + + body = request().model_dump(mode="json") + assert client.post("/api/v1/portfolio/reconcile", json=body).status_code == 401 + assert client.post( + "/api/v1/portfolio/reconcile", + headers={"X-ModelForge-Admin-Token": tokens["viewer"]}, + json=body, + ).status_code == 403 + + invalid = dict(body) + invalid["clone_url"] = "https://private-user:private-password@example.invalid/repo.git" + invalid["api-key-secret-value-must-not-echo"] = "untrusted" + rejected = client.post( + "/api/v1/portfolio/reconcile", + headers={"X-ModelForge-Admin-Token": tokens["operator"]}, + json=invalid, + ) + assert rejected.status_code == 422 + assert "private-password" not in rejected.text + assert "example.invalid" not in rejected.text + assert "api-key-secret-value-must-not-echo" not in rejected.text + + accepted = client.post( + "/api/v1/portfolio/reconcile", + headers={ + "X-ModelForge-Admin-Token": tokens["operator"], + "X-Correlation-ID": "portfolio-reconcile-test", + }, + json=body, + ) + assert accepted.status_code == 200 + assert accepted.json()["repositories_created"] == 1 + audit = session.scalar( + select(AuditEvent).where(AuditEvent.action == "PORTFOLIO_RECONCILED") + ) + assert audit and audit.actor_id == "operator" + assert audit.correlation_id == "portfolio-reconcile-test" + finally: + app.dependency_overrides.clear() + + +def test_portfolio_migration_upgrades_and_downgrades_empty_history() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + with engine.begin() as connection: + connection.execute(text("CREATE TABLE projects (id VARCHAR(36) PRIMARY KEY)")) + migration = _migration() + migration.op = Operations(MigrationContext.configure(connection)) + migration.upgrade() + assert inspect(connection).has_table("portfolio_repository_observations") + assert inspect(connection).has_table("portfolio_reconciliation_runs") + foreign_keys = inspect(connection).get_foreign_keys("portfolio_repository_observations") + assert any(item["referred_table"] == "projects" for item in foreign_keys) + repository_unique_constraints = { + item["name"] + for item in inspect(connection).get_unique_constraints( + "portfolio_repository_observations" + ) + } + assert "uq_portfolio_source_project_key" in repository_unique_constraints + assert "uq_portfolio_repository_project_id" in repository_unique_constraints + + migration.downgrade() + assert not inspect(connection).has_table("portfolio_repository_observations") + assert not inspect(connection).has_table("portfolio_reconciliation_runs") + engine.dispose() + + +def test_portfolio_migration_refuses_downgrade_after_a_snapshot_is_saved() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + with engine.begin() as connection: + connection.execute(text("CREATE TABLE projects (id VARCHAR(36) PRIMARY KEY)")) + migration = _migration() + migration.op = Operations(MigrationContext.configure(connection)) + migration.upgrade() + connection.execute( + text( + "INSERT INTO portfolio_reconciliation_runs " + "(id, source_system, snapshot_id, payload_digest, observed_at, complete, " + "stale_snapshot, projects_created, repositories_created, repositories_updated, " + "stale_observations, repositories_marked_stale, runtime_observations_updated, " + "head_changes_review_required, head_changes_accepted) " + "VALUES ('00000000-0000-0000-0000-000000000001', 'gitea', 'snapshot-1', :digest, " + "'2026-09-23 12:00:00', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)" + ), + {"digest": "a" * 64}, + ) + + with pytest.raises(RuntimeError, match="portfolio reconciliation history exists"): + migration.downgrade() + assert inspect(connection).has_table("portfolio_reconciliation_runs") + assert inspect(connection).has_table("portfolio_repository_observations") + engine.dispose() diff --git a/backend/tests/test_portfolio_review_artifact.py b/backend/tests/test_portfolio_review_artifact.py new file mode 100644 index 0000000..7e4e549 --- /dev/null +++ b/backend/tests/test_portfolio_review_artifact.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend/src")) + +from scripts.portfolio_gitea_snapshot import ( + EXECUTABLE_LOCAL_MODEL_REPOSITORIES, + build_snapshot, + load_offline_heads, + load_repository_ids, + main, + request_digest, +) + +from modelforge_api.domain.portfolio import PortfolioReconcileRequest +from modelforge_api.services.portfolio_registry import snapshot_digest + +REQUEST_PATH = ROOT / "docs/product/PORTFOLIO_RECONCILE_REQUEST_2026-09-25.json" +HEADS_PATH = ROOT / "docs/product/PORTFOLIO_DEFAULT_HEADS_2026-09-25.json" +RUNTIME_STATUS_PATH = ROOT / "docs/product/PORTFOLIO_RUNTIME_STATUS_2026-09-25.json" +COMPLETE_REQUEST_PATH = ROOT / "docs/product/PORTFOLIO_RECONCILE_REQUEST_COMPLETE_2026-09-25.json" + + +def test_review_request_is_partial_secret_free_and_identity_stable() -> None: + request = json.loads(REQUEST_PATH.read_text(encoding="utf-8")) + manifest = json.loads(HEADS_PATH.read_text(encoding="utf-8")) + runtime_status = json.loads(RUNTIME_STATUS_PATH.read_text(encoding="utf-8")) + validated = PortfolioReconcileRequest.model_validate(request) + + assert validated.complete is False + assert manifest["owner_inventory_verified"] is False + assert manifest["default_heads_stable_across_two_reads"] is True + assert len(validated.repositories) == manifest["repository_count"] == 69 + assert len({row.external_repository_id for row in validated.repositories}) == 69 + + heads = load_offline_heads(HEADS_PATH) + for row in validated.repositories: + current = heads[row.slug.split("/", 1)[1]] + assert row.default_branch == current["default_branch"] + assert row.head_sha == current["head_sha"] + assert row.accept_head_update is False + assert row.runtime_evidence == [] + assert all(not item.capabilities for item in row.code_evidence) + + local_model_rows = [row for row in validated.repositories if row.classification == "local_model"] + assert {row.slug.split("/", 1)[1] for row in local_model_rows} == set( + EXECUTABLE_LOCAL_MODEL_REPOSITORIES + ) + project_keys = [row.project_key for row in local_model_rows] + assert len(project_keys) == len(set(project_keys)) == 12 + + config_marker_rows = [ + row for row in validated.repositories if row.classification == "unknown" and row.code_evidence + ] + assert {row.slug.split("/", 1)[1] for row in config_marker_rows} == { + "codex-router", + "ITWorx_OpenChamber_MCP", + } + assert all(row.project_key is None for row in config_marker_rows) + + assert runtime_status["source_snapshot_id"] == validated.snapshot_id + runtime_rows = runtime_status["repositories"] + assert {row["external_repository_id"] for row in runtime_rows} == { + row.external_repository_id for row in local_model_rows + } + assert all(row["runtime_status"] == "UNKNOWN" for row in runtime_rows) + assert all(row["reason"] == "no_live_request_evidence_collected" for row in runtime_rows) + + observed_at = datetime.fromisoformat(request["observed_at"]) + rebuilt = build_snapshot( + [row.model_dump(mode="json") for row in validated.repositories], + observed_at, + complete=False, + expected_count=manifest["repository_count"], + repository_ids=load_repository_ids(), + ) + assert rebuilt == request + assert request_digest(request) == snapshot_digest(validated) + + encoded = json.dumps(request, sort_keys=True) + assert "MODELFORGE_GITEA_TOKEN" not in encoded + assert "Authorization" not in encoded + assert "OLLAMA_BASE_URL" not in encoded + + +def test_unverified_owner_inventory_cannot_be_emitted_as_complete(tmp_path: Path) -> None: + with pytest.raises(SystemExit) as error: + main( + [ + "--offline-heads", + str(HEADS_PATH), + "--checkout-root", + str(ROOT), + "--output", + str(tmp_path / "must-not-exist.json"), + ] + ) + + assert error.value.code == 2 + assert not (tmp_path / "must-not-exist.json").exists() + + +def test_authenticated_complete_request_has_exact_inventory_without_runtime_claims() -> None: + partial = PortfolioReconcileRequest.model_validate_json(REQUEST_PATH.read_text(encoding="utf-8")) + complete = PortfolioReconcileRequest.model_validate_json( + COMPLETE_REQUEST_PATH.read_text(encoding="utf-8") + ) + + assert complete.complete is True + assert complete.source_system == "gitea" + assert len(complete.repositories) == 69 + assert {row.external_repository_id for row in complete.repositories} == { + row.external_repository_id for row in partial.repositories + } + assert {row.slug: row.head_sha for row in complete.repositories} == { + row.slug: row.head_sha for row in partial.repositories + } + assert sum(row.classification == "local_model" for row in complete.repositories) == 12 + assert all(not row.accept_head_update for row in complete.repositories) + assert all(not row.runtime_evidence for row in complete.repositories) + assert all(not evidence.capabilities for row in complete.repositories for evidence in row.code_evidence) + assert request_digest(json.loads(COMPLETE_REQUEST_PATH.read_text(encoding="utf-8"))) == snapshot_digest(complete) diff --git a/backend/tests/test_project_model_management.py b/backend/tests/test_project_model_management.py new file mode 100644 index 0000000..0598c2f --- /dev/null +++ b/backend/tests/test_project_model_management.py @@ -0,0 +1,56 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.domain.projects import ProjectCreate, ProjectRequirementCreate +from modelforge_api.persistence.models import ArtifactSet, Base, Model, ModelRevision +from modelforge_api.services.projects import ProjectService +from tests.test_serving import approve_and_promote, setup_m5 + + +def test_project_workspace_resolves_exact_model_behind_stable_capability() -> None: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + try: + with Session(engine) as session: + serving, candidate, _node = setup_m5(session) + _approval, deployment = approve_and_promote(serving, candidate.id) + projects = ProjectService(serving) + projects.create(ProjectCreate( + key="managed-model-app", + name="Managed model app", + description="Model management contract", + owner="Operator", + )) + workspace = projects.bind( + "managed-model-app", + ProjectRequirementCreate( + capability="rag.embedding@1", + migration_support="reindex", + ), + ) + + requirement = workspace.requirements[0] + assert requirement.deployment_id == deployment.id + assert requirement.serving_model is not None + + artifact_set = session.get(ArtifactSet, deployment.artifact_set_id) + assert artifact_set is not None + revision = session.get(ModelRevision, artifact_set.revision_id) + assert revision is not None + model = session.get(Model, revision.model_id) + assert model is not None + + assert requirement.serving_model.model_id == model.id + assert requirement.serving_model.display_name == model.display_name + assert requirement.serving_model.revision_id == revision.id + assert requirement.serving_model.resolved_commit_sha == revision.resolved_commit_sha + assert requirement.serving_model.artifact_set_id == artifact_set.id + assert requirement.serving_model.runtime_profile_id == deployment.runtime_profile_id + assert requirement.serving_model.compute_node_id == deployment.compute_node_id + finally: + engine.dispose() diff --git a/backend/tests/test_project_onboarding.py b/backend/tests/test_project_onboarding.py new file mode 100644 index 0000000..b305c36 --- /dev/null +++ b/backend/tests/test_project_onboarding.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr, ValidationError +from sqlalchemy import create_engine, event, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.routes.serving import get_serving_service +from modelforge_api.db import get_session +from modelforge_api.domain.projects import ProjectCreate, ProjectRequirementCreate +from modelforge_api.domain.serving import ( + ServiceClientCreate, + ServiceCredentialRevoke, + ServiceCredentialRotate, +) +from modelforge_api.main import app +from modelforge_api.persistence.models import ( + AuditEvent, + Base, + Project, + ProjectOnboarding, + ServiceCredential, +) +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.projects import ProjectService +from modelforge_api.services.serving import ServingError, ServingService, _aware +from modelforge_api.services.transient_payloads import MemoryPayloadStore +from modelforge_api.settings import Settings, get_settings + + +@pytest.fixture +def serving() -> Iterator[ServingService]: + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + with Session(engine) as session: + yield ServingService(session, Settings(_env_file=None, env="test"), ManifestRegistry(), MemoryPayloadStore()) + engine.dispose() + + +def onboard(serving: ServingService): + projects = ProjectService(serving) + projects.create(ProjectCreate(key="example-app", name="Example", description="Integration", owner="Alice")) + project = projects.bind("example-app", ProjectRequirementCreate(capability="rag.embedding@1", migration_support="reindex")) + return serving.create_client(ServiceClientCreate( + name="example-reader", project_key=project.key, project_binding_id=project.requirements[0].id, + allowed_capabilities=["rag.embedding@1"], integration_environment="lab", purpose="Integration check", + credential_expires_at=datetime.now(UTC) + timedelta(days=30), workload_priority="interactive", + )) + + +def test_project_identity_binding_safety_and_readiness_are_persistent(serving: ServingService) -> None: + created = onboard(serving) + projects = ProjectService(serving) + project = projects.detail("example-app") + assert project.owner == "Alice" and project.environment == "lab" + assert project.production_eligible is None and project.state == "needs_attention" + requirement = project.requirements[0] + assert requirement.credential_ready and requirement.deployment_id is None + assert requirement.capability_checked_at is None + assert len(requirement.blockers) == 3 + serving.authenticate(f"Bearer {created.credential}", "rag.embedding@1") + assert projects.detail("example-app").requirements[0].connectivity_checked_at is not None + assert projects.detail("example-app").state == "needs_attention" + serving.session.expire_all() + listed = projects.list() + assert listed == [projects.detail("example-app")] + with pytest.raises(ServingError, match="already exists"): + projects.create(ProjectCreate(key="example-app", name="Duplicate", description="Duplicate", owner="Alice")) + with pytest.raises(ServingError, match="reindex"): + projects.bind("example-app", ProjectRequirementCreate(capability="vision.embedding@1")) + with pytest.raises(ValidationError): + ProjectRequirementCreate(capability="rag.embedding@1", model_path="/weights") + + +def test_project_workspace_list_batches_base_reads_as_projects_grow(serving: ServingService) -> None: + now = datetime.now(UTC) + + def add_projects(start: int, end: int) -> None: + for index in range(start, end): + project = Project( + key=f"workspace-{index:02d}", + name=f"Workspace {index}", + description="Representative project workspace", + ) + serving.session.add(project) + serving.session.flush() + serving.session.add( + ProjectOnboarding( + project_id=project.id, + owner="Owner", + environment="lab", + labels=["representative"], + created_at=now, + ) + ) + serving.session.commit() + + def list_with_select_count() -> tuple[int, list[object]]: + selects: list[str] = [] + + def count_selects( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + if statement.lstrip().upper().startswith("SELECT"): + selects.append(statement) + + serving.session.expire_all() + engine = serving.session.get_bind() + event.listen(engine, "before_cursor_execute", count_selects) + try: + result = ProjectService(serving).list() + finally: + event.remove(engine, "before_cursor_execute", count_selects) + return len(selects), result + + add_projects(1, 2) + one_project_selects, one_project_result = list_with_select_count() + assert len(one_project_result) == 1 + assert one_project_result[0].owner == "Owner" + assert one_project_result[0] == ProjectService(serving).detail("workspace-01") + + add_projects(2, 21) + twenty_project_selects, twenty_project_result = list_with_select_count() + assert len(twenty_project_result) == 20 + assert [item.key for item in twenty_project_result] == [ + f"workspace-{index:02d}" for index in range(1, 21) + ] + assert all(item.owner == "Owner" and item.environment == "lab" for item in twenty_project_result) + assert one_project_selects == twenty_project_selects == 3 + assert [item.key for item in ProjectService(serving).list(limit=2, after_key="workspace-18")] == [ + "workspace-19", + "workspace-20", + ] + + +def test_explicit_binding_cannot_cross_projects_and_inactive_projects_stop_serving(serving: ServingService) -> None: + created = onboard(serving) + projects = ProjectService(serving) + projects.create(ProjectCreate(key="other", name="Other", description="Other", owner="Bob")) + with pytest.raises(ServingError, match="active binding"): + serving.create_client(ServiceClientCreate( + name="cross-project", project_key="other", project_binding_id=created.project_binding_id, + allowed_capabilities=["rag.embedding@1"], integration_environment="lab", purpose="Rejected identity", + )) + project = serving.session.scalar(select(Project).where(Project.key == "example-app")) + assert project + project.active = False + serving.session.commit() + with pytest.raises(ServingError, match="inactive"): + serving.authenticate(f"Bearer {created.credential}", "rag.embedding@1") + + +def test_rotation_overlap_stale_requests_expiry_and_revoke_all(serving: ServingService) -> None: + first = onboard(serving) + assert first.credential_id + expires = datetime.now(UTC) + timedelta(days=7) + request = ServiceCredentialRotate(expected_credential_id=first.credential_id, overlap_seconds=900, expires_at=expires) + second = serving.rotate_client_credential(first.id, request) + assert serving.authenticate(f"Bearer {first.credential}", "rag.embedding@1").id == first.id + assert serving.authenticate(f"Bearer {second.credential}", "rag.embedding@1").id == first.id + assert second.credential_expires_at and _aware(second.credential_expires_at) == expires + with pytest.raises(ServingError, match="changed"): + serving.rotate_client_credential(first.id, request) + assert second.credential_id + third = serving.rotate_client_credential(first.id, ServiceCredentialRotate(expected_credential_id=second.credential_id, overlap_seconds=900, expires_at=expires)) + with pytest.raises(ServingError, match="revoked"): + serving.authenticate(f"Bearer {first.credential}", "rag.embedding@1") + assert third.credential_id + with pytest.raises(ServingError, match="exact"): + serving.revoke_client_credential(first.id, ServiceCredentialRevoke(expected_credential_id=third.credential_id, confirmation="wrong")) + serving.revoke_client_credential(first.id, ServiceCredentialRevoke(expected_credential_id=third.credential_id, confirmation=first.name)) + for token in (first.credential, second.credential, third.credential): + with pytest.raises(ServingError, match="revoked"): + serving.authenticate(f"Bearer {token}", "rag.embedding@1") + assert serving.clients()[0].credential_id is None + exposed = str(serving.credential_history(first.id)) + str([event.details for event in serving.session.scalars(select(AuditEvent))]) + assert all(token not in exposed for token in (first.credential, second.credential, third.credential)) + + +def test_overlap_never_extends_expiry_and_expired_or_disabled_rotation_refuses(serving: ServingService) -> None: + first = onboard(serving) + current = serving.session.get(ServiceCredential, first.credential_id) + assert current and first.credential_id + deadline = datetime.now(UTC) + timedelta(seconds=1) + current.expires_at = deadline + serving.session.commit() + serving.rotate_client_credential(first.id, ServiceCredentialRotate(expected_credential_id=first.credential_id, overlap_seconds=86400, expires_at=datetime.now(UTC) + timedelta(days=1))) + serving.session.refresh(current) + assert _aware(current.expires_at) == deadline + current.expires_at = datetime.now(UTC) - timedelta(seconds=1) + serving.session.commit() + with pytest.raises(ServingError, match="expired"): + serving.authenticate(f"Bearer {first.credential}", "rag.embedding@1") + + +def test_http_roles_audit_no_store_and_exact_machine_scope(serving: ServingService) -> None: + tokens = {role: "mfop_" + role[0] * 43 for role in ("viewer", "operator", "admin")} + settings = Settings(_env_file=None, env="test", operator_identities=SecretStr(json.dumps([ + {"subject": role, "role": role, "credential_id": role, "token_sha256": hashlib.sha256(token.encode()).hexdigest(), "expires_at": (datetime.now(UTC) + timedelta(days=1)).isoformat()} + for role, token in tokens.items() + ]))) + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_session] = lambda: serving.session + app.dependency_overrides[get_serving_service] = lambda: serving + try: + http = TestClient(app) + body = {"key": "api-project", "name": "API", "description": "API project", "owner": "Alice"} + path = "/api/v1/project-workspace" + assert http.get(path).status_code == 401 + assert http.get(f"{path}?limit=1").status_code == 401 + empty_page = http.get(f"{path}?limit=1", headers={"X-ModelForge-Admin-Token": tokens["viewer"]}) + assert empty_page.status_code == 200 and empty_page.json() == [] + assert http.post(path, json=body).status_code == 401 + assert http.post(path, json=body, headers={"X-ModelForge-Admin-Token": tokens["viewer"]}).status_code == 403 + response = http.post(path, json=body, headers={"X-ModelForge-Admin-Token": tokens["operator"], "X-Correlation-ID": "project-create"}) + assert response.status_code == 201 and response.headers["cache-control"] == "no-store" + event = serving.session.scalar(select(AuditEvent).where(AuditEvent.action == "PROJECT_CREATED")) + assert event and event.actor_id == "operator" and event.correlation_id == "project-create" + created = onboard(serving) + check = "/api/v1/capabilities/rag.embedding@1/check" + headers = {"Authorization": f"Bearer {created.credential}"} + assert http.get(check, headers=headers).json()["client_id"] == str(created.id) + assert http.get(check.replace("rag.embedding", "vision.embedding"), headers=headers).status_code == 403 + assert http.get(path, headers=headers).status_code == 401 + assert http.get(check, headers={"X-ModelForge-Admin-Token": tokens["admin"]}).status_code == 401 + assert http.post(f"/api/v1/admin/service-clients/{created.id}/credentials/rotate", json={}, headers={"X-ModelForge-Admin-Token": tokens["operator"]}).status_code == 403 + assert http.get("/api/v1/projects", headers={"X-ModelForge-Admin-Token": tokens["viewer"]}).status_code == 200 + viewer_headers = {"X-ModelForge-Admin-Token": tokens["viewer"]} + changes_path = f"{path}/changes" + assert http.get(changes_path).status_code == 401 + empty_changes = http.get( + f"{changes_path}?project_key=example-app&project_binding_id={created.project_binding_id}" + "&open_only=true&limit=1&offset=0", + headers=viewer_headers, + ) + assert empty_changes.status_code == 200 + assert empty_changes.json() == {"items": [], "total": 0} + assert http.get(f"{changes_path}?limit=201", headers=viewer_headers).status_code == 422 + assert http.get(f"{changes_path}?offset=-1", headers=viewer_headers).status_code == 422 + legacy_index = http.get(path, headers=viewer_headers) + first_page = http.get(f"{path}?limit=1", headers=viewer_headers) + first_key = first_page.json()[0]["key"] + second_page = http.get(f"{path}?limit=1&after_key={first_key}", headers=viewer_headers) + last_key = second_page.json()[0]["key"] + empty_page = http.get(f"{path}?limit=1&after_key={last_key}", headers=viewer_headers) + assert legacy_index.status_code == 200 + assert [item["key"] for item in legacy_index.json()] == ["api-project", "example-app"] + assert [item["key"] for item in first_page.json() + second_page.json()] == ["api-project", "example-app"] + assert set(first_page.json()[0]) == { + "id", "key", "name", "description", "active", "owner", "environment", "labels", + "source", "production_eligible", "state", "requirements", "clients", + } + assert empty_page.status_code == 200 and empty_page.json() == [] + assert http.get(f"{path}?limit=201", headers=viewer_headers).status_code == 422 + assert http.get(f"{path}?after_key={first_key}", headers=viewer_headers).status_code == 422 + finally: + app.dependency_overrides.clear() + + +def test_readiness_requires_current_credential_current_deployment_and_fresh_success(serving: ServingService) -> None: + from modelforge_api.persistence.models import GatewayRequest + from tests.test_serving import approve_and_promote, setup_m5 + + service, candidate, _node = setup_m5(serving.session) + _, deployment = approve_and_promote(service, candidate.id) + first = onboard(service) + projects = ProjectService(service) + service.authenticate("Bearer " + first.credential, "rag.embedding@1") + record = GatewayRequest( + request_id=uuid.uuid4(), service_client_id=first.id, + capability_key="rag.embedding", capability_version=1, + capability_deployment_id=deployment.id, status="completed", priority="interactive", + input_sha256="0" * 64, input_count=1, + decision_evidence={"service_credential_id": str(first.credential_id)}, created_at=datetime.now(UTC), + ) + service.session.add(record); service.session.commit() + assert projects.detail("example-app").state == "ready" + assert first.credential_id + second = service.rotate_client_credential(first.id, ServiceCredentialRotate( + expected_credential_id=first.credential_id, overlap_seconds=900, + expires_at=datetime.now(UTC) + timedelta(days=1), + )) + service.authenticate("Bearer " + second.credential, "rag.embedding@1") + record.created_at = datetime.now(UTC) + service.session.commit() + assert projects.detail("example-app").state == "needs_attention" # Old overlap cannot qualify new key. + record.decision_evidence = {"service_credential_id": str(second.credential_id)} + service.session.commit() + assert projects.detail("example-app").state == "ready" + record.created_at = datetime.now(UTC) - timedelta(days=2) + service.session.commit() + assert projects.detail("example-app").state == "needs_attention" diff --git a/backend/tests/test_project_onboarding_postgres.py b/backend/tests/test_project_onboarding_postgres.py new file mode 100644 index 0000000..4001f27 --- /dev/null +++ b/backend/tests/test_project_onboarding_postgres.py @@ -0,0 +1,246 @@ +"""Isolated PostgreSQL 0025 upgrade/backup restore and credential race qualification.""" +from __future__ import annotations + +import hashlib +import importlib +import os +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from alembic.config import Config +from sqlalchemy import create_engine, select, text +from sqlalchemy.engine import make_url +from sqlalchemy.orm import Session + +from modelforge_api.domain.serving import ( + ServiceClientCreated, + ServiceClientResponse, + ServiceCredentialRevoke, + ServiceCredentialRotate, +) +from modelforge_api.persistence.models import AuditEvent, ServiceCredential +from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.recovery_fingerprint import ( + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from modelforge_api.services.serving import ServingError, ServingService +from modelforge_api.settings import Settings +from tests.test_configuration_postgres import _database_tool + + +@pytest.mark.parametrize( + "competitors", + [("rotate", "rotate"), ("rotate", "revoke"), ("revoke", "revoke")], +) +def test_project_upgrade_restore_and_credential_rotation_races( + tmp_path: Path, + competitors: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + admin_url = os.getenv("MODELFORGE_TEST_POSTGRES_URL") + if not admin_url: + pytest.skip("requires managed MODELFORGE_TEST_POSTGRES_URL") + owner_url = os.getenv("MODELFORGE_TEST_POSTGRES_OWNER_URL") + runtime_url = os.getenv("MODELFORGE_TEST_POSTGRES_RUNTIME_URL") + assert owner_url and runtime_url + database = "test_onboarding_" + uuid.uuid4().hex + restored = "test_onboarding_restore_" + uuid.uuid4().hex + admin = create_engine(admin_url, isolation_level="AUTOCOMMIT") + owner = make_url(owner_url).set(database=database) + runtime = create_engine(make_url(runtime_url).set(database=database)) + restored_runtime = create_engine(make_url(runtime_url).set(database=restored)) + config = Config() + config.set_main_option("script_location", str(Path(__file__).resolve().parents[1] / "alembic")) + config.set_main_option("sqlalchemy.url", owner.render_as_string(hide_password=False).replace("%", "%%")) + alembic = importlib.import_module("alembic.command") + client_id, credential_id = uuid.uuid4(), uuid.uuid4() + secret = "mf_" + "p" * 48 + with admin.connect() as connection: + for name in (database, restored): + connection.execute(text(f'CREATE DATABASE "{name}" OWNER modelforge')) + try: + alembic.upgrade(config, "20260907_0025") + with Session(runtime) as session: + session.execute(text("INSERT INTO service_clients(id,name,status,allowed_capabilities,requests_per_minute,max_concurrent_requests,workload_priority) VALUES (:id,'prior-client','active','[\"rag.embedding@1\"]',60,1,'interactive')"), {"id": client_id}) + session.execute(text("INSERT INTO service_credentials(id,service_client_id,secret_hash,secret_prefix,expires_at) VALUES (:id,:client,:digest,'mf_prior',:expiry)"), {"id": credential_id, "client": client_id, "digest": hashlib.sha256(secret.encode()).hexdigest(), "expiry": datetime.now(UTC) + timedelta(days=30)}) + AuditWriter(session, "operator", "prior-release").write("qualification.baseline", "service_client", str(client_id), {}) + session.commit() + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "before-onboarding.dump" + args = ["--host", "127.0.0.1" if os.getenv("MODELFORGE_TEST_POSTGRES_CONTAINER") else owner.host or "localhost", "--port", str(owner.port or 5432), "--username", owner.username or "modelforge", "--dbname", database] + _database_tool("pg_dump", [*args, "--format=custom"], owner.password, archive) + assert archive.stat().st_size > 0 + alembic.upgrade(config, "head") + with Session(runtime) as session: + current = semantic_fingerprint(session.connection()) + comparison = fingerprint_compatibility(source, current, source_schema_revision="20260907_0025") + assert comparison.compatible + assert audit_fingerprint_differences_are_compatible(source, current, comparison) + service = ServingService(session, Settings(_env_file=None), ManifestRegistry(), None) + assert service.authenticate("Bearer " + secret, "rag.embedding@1").id == client_id + alembic.downgrade(config, "20260907_0025") + alembic.upgrade(config, "head") + with Session(runtime) as session: + service = ServingService(session, Settings(_env_file=None), ManifestRegistry(), None) + prior_overlap = service.rotate_client_credential( + client_id, + ServiceCredentialRotate( + expected_credential_id=credential_id, + overlap_seconds=1800, + expires_at=datetime.now(UTC) + timedelta(days=14), + ), + ) + assert prior_overlap.credential_id + prior_overlap_id = prior_overlap.credential_id + prior_overlap_secret = prior_overlap.credential + prior_overlap_expires_at = prior_overlap.credential_expires_at + assert prior_overlap_expires_at + assert service.authenticate("Bearer " + secret, "rag.embedding@1").id == client_id + with Session(runtime) as session: + existing_event_ids = set(session.scalars(select(AuditEvent.id))) + + # Hold both operations after their current-credential read and before the conditional + # claim UPDATE, so the loser must observe the winner's committed CAS change. + claim_barrier = threading.Barrier(2) + original_claim = ServingService._claim_credential + + def claim_at_cas(service: ServingService, credential: ServiceCredential) -> None: + claim_barrier.wait(timeout=30) + original_claim(service, credential) + + monkeypatch.setattr(ServingService, "_claim_credential", claim_at_cas) + race_credential_id = uuid.UUID(str(prior_overlap_id)) + expires = datetime.now(UTC) + timedelta(days=14) + + def contender( + action: str, + ) -> tuple[str, str, ServiceClientCreated | ServiceClientResponse | None]: + with Session(runtime) as session: + service = ServingService(session, Settings(_env_file=None), ManifestRegistry(), None) + try: + if action == "rotate": + result = service.rotate_client_credential( + client_id, + ServiceCredentialRotate( + expected_credential_id=race_credential_id, + overlap_seconds=900, + expires_at=expires, + ), + ) + else: + result = service.revoke_client_credential( + client_id, + ServiceCredentialRevoke( + expected_credential_id=race_credential_id, + confirmation="prior-client", + ), + ) + return action, "applied", result + except ServingError as error: + assert error.code == "CREDENTIAL_CHANGED" + return action, "conflict", None + + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(contender, competitors)) + assert sorted(outcome for _action, outcome, _result in outcomes) == [ + "applied", + "conflict", + ] + winner_action, _, winner_result = next( + outcome for outcome in outcomes if outcome[1] == "applied" + ) + with Session(runtime) as session: + new_events = list( + session.scalars( + select(AuditEvent).where( + AuditEvent.action.in_( + ["CLIENT_CREDENTIAL_ROTATED", "CLIENT_CREDENTIAL_REVOKED"] + ), + AuditEvent.id.not_in(existing_event_ids), + ) + ) + ) + assert len(new_events) == 1 + assert new_events[0].action == ( + "CLIENT_CREDENTIAL_ROTATED" + if winner_action == "rotate" + else "CLIENT_CREDENTIAL_REVOKED" + ) + credentials = list( + session.scalars( + select(ServiceCredential).where( + ServiceCredential.service_client_id == client_id + ) + ) + ) + current = session.get(ServiceCredential, race_credential_id) + original = session.get(ServiceCredential, credential_id) + assert current and original + assert current.superseded_at is not None + assert original.superseded_at is not None and original.revoked_at is not None + active = [ + item + for item in credentials + if item.superseded_at is None and item.revoked_at is None + ] + if winner_action == "rotate": + assert isinstance(winner_result, ServiceClientCreated) + winner_credential_id = uuid.UUID(str(winner_result.credential_id)) + assert len(active) == 1 and active[0].id == winner_credential_id + assert current.revoked_at is None + assert current.expires_at is not None + assert datetime.now(UTC) < current.expires_at <= prior_overlap_expires_at + assert current.superseded_at is not None + assert current.expires_at <= current.superseded_at + timedelta(seconds=900) + assert new_events[0].details["previous_credential_id"] == str( + race_credential_id + ) + assert new_events[0].details["credential_id"] == str(winner_credential_id) + assert new_events[0].details["overlap_seconds"] == 900 + else: + assert len(active) == 0 + assert current.revoked_at is not None + assert all(item.revoked_at is not None for item in credentials) + + with Session(runtime) as session: + service = ServingService(session, Settings(_env_file=None), ManifestRegistry(), None) + if winner_action == "rotate": + assert isinstance(winner_result, ServiceClientCreated) + assert service.authenticate( + "Bearer " + prior_overlap_secret, "rag.embedding@1" + ).id == client_id + assert service.authenticate( + "Bearer " + winner_result.credential, "rag.embedding@1" + ).id == client_id + with pytest.raises(ServingError, match="revoked"): + service.authenticate("Bearer " + secret, "rag.embedding@1") + else: + for retired_secret in (secret, prior_overlap_secret): + with pytest.raises(ServingError, match="revoked"): + service.authenticate("Bearer " + retired_secret, "rag.embedding@1") + with pytest.raises(RuntimeError, match="history exists"): + alembic.downgrade(config, "20260907_0025") + args[-1] = restored + _database_tool("pg_restore", [*args, "--exit-on-error"], owner.password, archive) + with restored_runtime.connect() as connection: + recovered = semantic_fingerprint(connection) + assert recovered["tables"] == source["tables"] + config.set_main_option("sqlalchemy.url", owner.set(database=restored).render_as_string(hide_password=False).replace("%", "%%")) + alembic.upgrade(config, "head") + with restored_runtime.connect() as connection: + recovered = semantic_fingerprint(connection) + comparison = fingerprint_compatibility(source, recovered, source_schema_revision="20260907_0025") + assert comparison.compatible and audit_fingerprint_differences_are_compatible(source, recovered, comparison) + finally: + runtime.dispose(); restored_runtime.dispose() + with admin.connect() as connection: + for name in (database, restored): + connection.execute(text(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')) + admin.dispose() diff --git a/backend/tests/test_project_python_client.py b/backend/tests/test_project_python_client.py new file mode 100644 index 0000000..6e7300b --- /dev/null +++ b/backend/tests/test_project_python_client.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import importlib.util +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.error import HTTPError + +import pytest + +spec = importlib.util.spec_from_file_location("modelforge_client", Path(__file__).resolve().parents[2] / "clients/python/modelforge_client.py") +assert spec and spec.loader +sdk = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sdk) + + +def test_python_client_scope_typed_errors_and_no_redirect_or_retry() -> None: + seen = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + seen.append((self.path, self.headers.get("Authorization"))) + self.send_response(200); self.end_headers(); self.wfile.write(b'{"connected":true}') + + def do_POST(self): + seen.append((self.path, self.headers.get("Authorization"))) + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if body.get("redirect"): + self.send_response(307); self.send_header("Location", "/must-not-follow"); self.end_headers() + elif body.get("flat_error"): + self.send_response(503) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + b'{"code":"LEGACY_ERROR","message":"Legacy error","correlation_id":"legacy-case"}' + ) + else: + self.send_response(503); self.send_header("Content-Type", "application/json"); self.end_headers() + self.wfile.write( + json.dumps( + { + "error": { + "code": "NO_ELIGIBLE_NODE", + "message": "No node", + "correlation_id": "sdk-case", + "details": { + "input": "private benchmark input that must remain redacted" + }, + } + } + ).encode() + ) + + def log_message(self, *_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True); worker.start() + token = "mf_" + "k" * 48 + try: + client = sdk.ModelForgeClient(f"http://127.0.0.1:{server.server_port}", token, "rag.embedding@1", allow_http=True, timeout=2) + assert client.health() == {"connected": True} + with pytest.raises(sdk.ModelForgeError) as failure: + client.invoke({"input": ["test"]}) + assert failure.value.code == "NO_ELIGIBLE_NODE" + assert str(failure.value) == "No node" + assert failure.value.status == 503 and failure.value.correlation_id == "sdk-case" + assert "private benchmark input" not in str(failure.value) + with pytest.raises(sdk.ModelForgeError) as legacy: + client.invoke({"flat_error": True}) + assert legacy.value.code == "LEGACY_ERROR" + assert str(legacy.value) == "Legacy error" + assert legacy.value.correlation_id == "legacy-case" + with pytest.raises(sdk.ModelForgeError) as redirected: + client.invoke({"redirect": True}) + assert redirected.value.status == 307 + assert len(seen) == 4 and all("must-not-follow" not in path for path, _ in seen) + assert all(header == f"Bearer {token}" for _, header in seen) + assert token not in repr(client) + repr(failure.value) + finally: + server.shutdown(); server.server_close(); worker.join(timeout=3) + + +@pytest.mark.parametrize("origin", ["http://lab.invalid", "https://user:password@lab.invalid", "https://lab.invalid/path", "https://lab.invalid/?q=key"]) +def test_python_client_rejects_unsafe_origin(origin: str) -> None: + with pytest.raises(ValueError): + sdk.ModelForgeClient(origin, "mf_" + "k" * 48, "rag.embedding@1") + + +def test_python_client_preserves_typed_error_when_error_body_times_out() -> None: + class StalledBody: + closed = False + + def read(self, _limit): + raise TimeoutError("private transport details") + + def close(self): + pass + + class Opener: + def open(self, _request, *, timeout): + raise HTTPError("https://lab.invalid", 503, "Unavailable", + {"X-Correlation-ID": "stalled-error"}, StalledBody()) + + client = sdk.ModelForgeClient("https://lab.invalid", "mf_" + "k" * 48, "rag.embedding@1") + client._opener = Opener() + with pytest.raises(sdk.ModelForgeError) as failure: + client.health() + assert failure.value.code == "TRANSPORT_ERROR" + assert failure.value.status == 503 + assert failure.value.correlation_id == "stalled-error" + assert "private transport details" not in str(failure.value) diff --git a/backend/tests/test_project_registry.py b/backend/tests/test_project_registry.py index 4382115..cb97acf 100644 --- a/backend/tests/test_project_registry.py +++ b/backend/tests/test_project_registry.py @@ -1,4 +1,5 @@ -from sqlalchemy import create_engine +import pytest +from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from modelforge_api.persistence.models import ( @@ -16,28 +17,120 @@ def test_project_registry_materializes_only_real_contracts_and_is_idempotent() - engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(engine) with Session(engine) as session: - capability = Capability(key="rag.embedding", description="test") - session.add(capability) - session.flush() - session.add( - CapabilityContract( - capability_id=capability.id, - version=1, - input_schema={}, - output_schema={}, - contract={}, - upgrade_class="requires_reindex", + manifests = ManifestRegistry() + enabled_capabilities = {"rag.embedding", "rag.reranking", "vision.embedding"} + for manifest in manifests.capabilities(): + if manifest.capability not in enabled_capabilities: + continue + capability = Capability(key=manifest.capability, description=manifest.description) + session.add(capability) + session.flush() + session.add( + CapabilityContract( + capability_id=capability.id, + version=manifest.version, + input_schema=manifest.input_schema, + output_schema=manifest.output_schema, + contract=manifest.model_dump(mode="json"), + upgrade_class=manifest.upgrade_class.value, + ) ) - ) session.commit() - first = sync_project_registry(session, ManifestRegistry()) - second = sync_project_registry(session, ManifestRegistry()) + first = sync_project_registry(session, manifests) + second = sync_project_registry(session, manifests) assert first.projects_created == 3 - assert first.bindings_created == 1 + assert first.bindings_created == 3 assert first.unavailable_contracts assert second.projects_created == 0 assert second.bindings_created == 0 assert session.query(Project).count() == 3 - assert session.query(ProjectBinding).count() == 1 + bindings = session.execute( + select(Project.key, Capability.key) + .join(ProjectBinding, ProjectBinding.project_id == Project.id) + .join( + CapabilityContract, + CapabilityContract.id == ProjectBinding.capability_contract_id, + ) + .join(Capability, Capability.id == CapabilityContract.capability_id) + ).all() + assert set(bindings) == { + ("examplerag", "rag.embedding"), + ("examplerag", "rag.reranking"), + ("examplevision", "vision.embedding"), + } + + +@pytest.mark.parametrize("declared_stability", ["planned", "blocked"]) +def test_project_registry_skips_unavailable_capabilities_and_preserves_operator_bindings( + declared_stability: str, monkeypatch: pytest.MonkeyPatch +) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as session: + manifests = ManifestRegistry() + capability_manifest = next( + item + for item in manifests.capabilities() + if item.capability == "assistant.general" and item.version == 1 + ) + project_manifest = next( + item for item in manifests.projects() if item.project.id == "example-ops" + ) + capability_manifest = capability_manifest.model_copy( + update={ + "estate": capability_manifest.estate.model_copy( + update={"stability": declared_stability} + ) + } + ) + monkeypatch.setattr(manifests, "capabilities", lambda: [capability_manifest]) + monkeypatch.setattr(manifests, "projects", lambda: [project_manifest]) + + capability = Capability( + key=capability_manifest.capability, description=capability_manifest.description + ) + session.add(capability) + session.flush() + contract = CapabilityContract( + capability_id=capability.id, + version=capability_manifest.version, + input_schema=capability_manifest.input_schema, + output_schema=capability_manifest.output_schema, + contract=capability_manifest.model_dump(mode="json"), + upgrade_class=capability_manifest.upgrade_class.value, + ) + session.add(contract) + session.commit() + + first = sync_project_registry(session, manifests) + + assert first.bindings_created == 0 + assert session.scalar(select(ProjectBinding.id)) is None + assert [ + ( + item.project_key, + item.capability, + item.contract_version, + item.declared_stability, + ) + for item in first.unavailable_bindings + ] == [("example-ops", "assistant.general", 1, declared_stability)] + + project = session.scalar(select(Project).where(Project.key == "example-ops")) + assert project is not None + operator_binding = ProjectBinding( + project_id=project.id, + capability_contract_id=contract.id, + channel="stable", + priority="interactive", + ) + session.add(operator_binding) + session.commit() + operator_binding_id = operator_binding.id + + second = sync_project_registry(session, manifests) + + assert second.bindings_created == 0 + assert session.scalar(select(ProjectBinding.id)) == operator_binding_id diff --git a/backend/tests/test_rc_candidate_freeze.py b/backend/tests/test_rc_candidate_freeze.py new file mode 100644 index 0000000..df00684 --- /dev/null +++ b/backend/tests/test_rc_candidate_freeze.py @@ -0,0 +1,216 @@ +"""Unit contracts for the transportable, exact RC candidate image set.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import subprocess +import sys +import tarfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +RELEASE_BUILD = ROOT / "scripts" / "release_build.py" + + +def _release_build_module(): + spec = importlib.util.spec_from_file_location("rc_candidate_release_build", RELEASE_BUILD) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_public_source_manifest(root: Path, module, source_revision: str) -> dict[str, object]: + files = [] + for relative_path in module.SOURCE_DOCKERFILES: + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + payload = ( + f'LABEL {module.OCI_SOURCE_LABEL}="{module.PUBLIC_SOURCE_REPOSITORY}"\n' + ).encode() + path.write_bytes(payload) + files.append( + { + "path": relative_path, + "bytes": len(payload), + "sha256": hashlib.sha256(payload).hexdigest(), + } + ) + manifest: dict[str, object] = { + "schemaVersion": 1, + "sourceRevision": source_revision, + "sourceRepository": module.PUBLIC_SOURCE_REPOSITORY, + "files": files, + } + (root / "PUBLIC_SOURCE_MANIFEST.json").write_text(json.dumps(manifest), encoding="utf-8") + return manifest + + +def test_release_command_failure_keeps_the_docker_error_tail( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _release_build_module() + failure = subprocess.CompletedProcess( + ["docker", "build"], 73, stdout="", stderr="progress\n" * 300 + "final Docker error" + ) + monkeypatch.setattr(module.subprocess, "run", lambda *_args, **_kwargs: failure) + + with pytest.raises(RuntimeError) as error: + module.run("docker", "build") + + assert "final Docker error" in str(error.value) + assert "failed (73)" in str(error.value) + assert "progress\n" * 300 not in str(error.value) + + +def _add_file(archive: tarfile.TarFile, name: str, payload: bytes) -> None: + info = tarfile.TarInfo(name) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + + +def _image_records(module) -> dict[str, dict[str, object]]: + records: dict[str, dict[str, object]] = {} + for index, name in enumerate(module.IMAGES): + records[name] = { + "tag": f"{name}:1.3.0-rc.1", + "architecture": "amd64", + "labels": { + "org.opencontainers.image.version": "1.3.0-rc.1", + "org.opencontainers.image.revision": "a" * 40, + }, + "rootfs_layers": [f"sha256:{index + 1:064x}"], + } + return records + + +def _write_bundle(path: Path, records: dict[str, dict[str, object]]) -> None: + manifest: list[dict[str, object]] = [] + with tarfile.open(path, "w") as archive: + for index, (_name, record) in enumerate(records.items()): + config = f"{index:064x}.json" + layer = f"{index:064x}/layer.tar" + payload = { + "architecture": record["architecture"], + "config": {"Labels": record["labels"]}, + "rootfs": {"diff_ids": record["rootfs_layers"]}, + } + _add_file(archive, config, json.dumps(payload).encode()) + _add_file(archive, layer, b"layer") + manifest.append({"Config": config, "RepoTags": [record["tag"]], "Layers": [layer]}) + _add_file(archive, "manifest.json", json.dumps(manifest).encode()) + + +def test_image_bundle_binds_exact_tags_labels_and_layer_sets(tmp_path: Path) -> None: + module = _release_build_module() + records = _image_records(module) + bundle = tmp_path / "candidate-images.tar" + _write_bundle(bundle, records) + + verified = module.verify_image_bundle(bundle, records) + + assert set(verified) == set(module.IMAGES) + assert verified["modelforge-api"]["tag"] == "modelforge-api:1.3.0-rc.1" + + +def test_image_bundle_rejects_a_changed_filesystem_layer_set(tmp_path: Path) -> None: + module = _release_build_module() + records = _image_records(module) + bundle = tmp_path / "candidate-images.tar" + _write_bundle(bundle, records) + records["modelforge-api"]["rootfs_layers"] = ["sha256:" + "f" * 64] + + with pytest.raises(ValueError, match="layer set"): + module.verify_image_bundle(bundle, records) + + +def test_parentless_export_requires_an_exact_canonical_source_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + module = _release_build_module() + build_commit = "b" * 40 + canonical_commit = "a" * 40 + + monkeypatch.setattr(module, "ROOT", tmp_path) + monkeypatch.setattr( + module, + "run", + lambda *args, **kwargs: { + ("git", "rev-parse", "HEAD"): build_commit, + ("git", "status", "--porcelain"): "", + ("git", "describe", "--tags", "--always"): "candidate", + ("git", "remote", "get-url", "origin"): "", + ("git", "rev-parse", "--abbrev-ref", "HEAD"): "main", + }.get(args, ""), + ) + + with pytest.raises(SystemExit, match="unbound source-label override"): + module.source_identity(False, canonical_commit) + + manifest = _write_public_source_manifest(tmp_path, module, canonical_commit) + + with pytest.raises(SystemExit, match="requires --canonical-source-commit"): + module.source_identity(False) + + identity = module.source_identity(False, canonical_commit) + + assert identity["commit"] == canonical_commit + assert identity["build_commit"] == build_commit + assert identity["public_source_manifest_sha256"] + assert identity["source_repository"] == module.PUBLIC_SOURCE_REPOSITORY + + manifest_path = tmp_path / "PUBLIC_SOURCE_MANIFEST.json" + manifest["sourceRepository"] = "https://attacker.invalid/forged" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with pytest.raises(SystemExit, match="approved sanitized identity"): + module.source_identity(False, canonical_commit) + + manifest["sourceRepository"] = module.PUBLIC_SOURCE_REPOSITORY + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + dockerfile = tmp_path / module.SOURCE_DOCKERFILES[0] + dockerfile.write_text("LABEL org.opencontainers.image.source=\"https://attacker.invalid\"\n") + with pytest.raises(SystemExit, match="does not bind the current"): + module.source_identity(False, canonical_commit) + + +def test_private_release_identity_uses_the_canonical_https_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _release_build_module() + commit = "b" * 40 + monkeypatch.setattr(module, "ROOT", ROOT) + monkeypatch.setattr( + module, + "run", + lambda *args, **kwargs: { + ("git", "rev-parse", "HEAD"): commit, + ("git", "status", "--porcelain"): "", + ("git", "describe", "--tags", "--always"): "candidate", + ("git", "remote", "get-url", "origin"): "ssh://private.example/repo.git", + ("git", "rev-parse", "--abbrev-ref", "HEAD"): "main", + }.get(args, ""), + ) + + identity = module.source_identity(False) + + assert identity["source_repository"] == module.CANONICAL_SOURCE_REPOSITORY + + +def test_release_builder_checks_built_image_source_labels() -> None: + module = _release_build_module() + source = module.PUBLIC_SOURCE_REPOSITORY + records = { + "modelforge-api": {"labels": {module.OCI_SOURCE_LABEL: source}}, + "modelforge-web": {"labels": {module.OCI_SOURCE_LABEL: source}}, + } + + module._assert_image_source_identity(records, source) + records["modelforge-web"]["labels"][module.OCI_SOURCE_LABEL] = "https://attacker.invalid" + with pytest.raises(SystemExit, match="source label does not match source identity"): + module._assert_image_source_identity(records, source) diff --git a/backend/tests/test_rc_failure_node.py b/backend/tests/test_rc_failure_node.py new file mode 100644 index 0000000..c96efee --- /dev/null +++ b/backend/tests/test_rc_failure_node.py @@ -0,0 +1,124 @@ +"""Deterministic node and NVML failure injection for RC-QUAL-03. + +These tests exercise the serving boundary without a live GPU. The isolated +candidate still needs the corresponding real-stack fault evidence. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.domain.serving import ( + EmbeddingInvokeRequest, + PlacementPlanRequest, + ServiceClientCreate, +) +from modelforge_api.persistence.models import ( + Base, + GatewayRequest, + ServingGpuLease, + ServingJob, +) +from modelforge_api.services.serving import ServingError +from tests.test_serving import approve_and_promote, setup_m5 + + +@pytest.fixture +def session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as value: + yield value + engine.dispose() + + +def _count(session: Session, model: type[GatewayRequest | ServingGpuLease | ServingJob]) -> int: + return session.scalar(select(func.count()).select_from(model)) or 0 + + +def test_stale_node_refuses_route_and_placement_without_allocating_work( + session: Session, +) -> None: + service, candidate, node = setup_m5(session) + _approval, promoted = approve_and_promote(service, candidate.id) + client = service.create_client( + ServiceClientCreate(name="node-loss", allowed_capabilities=["rag.embedding@1"]) + ) + authenticated = service.authenticate(f"Bearer {client.credential}", "rag.embedding@1") + counts = tuple( + _count(session, model) for model in (GatewayRequest, ServingGpuLease, ServingJob) + ) + + node.liveness_state = "stale" + node.last_heartbeat_at = datetime.now(UTC) - timedelta(minutes=5) + session.commit() + + plan = service.dry_run_placement(promoted.id, PlacementPlanRequest(priority="production")) + assert plan.verdict == "REJECT_HEALTH" + assert plan.reason_codes == ["NODE_UNAVAILABLE"] + assert plan.evictions == [] + with pytest.raises(ServingError) as caught: + service.invoke(EmbeddingInvokeRequest(input="must stay local"), authenticated) + assert caught.value.status_code == 503 + assert caught.value.code == "NO_ELIGIBLE_NODE" + assert ( + tuple(_count(session, model) for model in (GatewayRequest, ServingGpuLease, ServingJob)) + == counts + ) + + +@pytest.mark.parametrize("telemetry_failure", ["missing", "stale"]) +def test_nvml_unknown_refuses_invoke_and_cleans_request_without_worker_fallback( + session: Session, + telemetry_failure: str, +) -> None: + service, candidate, _node = setup_m5(session) + _approval, promoted = approve_and_promote(service, candidate.id) + client = service.create_client( + ServiceClientCreate( + name=f"nvml-{telemetry_failure}", allowed_capabilities=["rag.embedding@1"] + ) + ) + authenticated = service.authenticate(f"Bearer {client.credential}", "rag.embedding@1") + jobs_before = _count(session, ServingJob) + leases_before = _count(session, ServingGpuLease) + telemetry = service.repo.telemetry(promoted.accelerator_id) + assert telemetry is not None + if telemetry_failure == "missing": + telemetry.used_vram_bytes = None + telemetry.free_vram_bytes = None + else: + telemetry.observed_at = datetime.now(UTC) - timedelta(hours=1) + session.commit() + + budget = service.scheduler_overview()[0] + assert budget.attribution_confidence == "UNKNOWN" + assert budget.schedulable_free_vram_bytes == 0 + plan = service.dry_run_placement(promoted.id, PlacementPlanRequest(priority="production")) + assert plan.verdict == "REJECT_HEALTH" + assert plan.reason_codes == ["SCHEDULER_STATE_STALE"] + assert plan.evictions == [] + + with pytest.raises(ServingError) as caught: + service.invoke( + EmbeddingInvokeRequest(input="never infer on guessed capacity"), authenticated + ) + assert caught.value.status_code == 503 + assert caught.value.code == "SCHEDULER_STATE_STALE" + request = session.scalar(select(GatewayRequest).order_by(GatewayRequest.created_at.desc())) + assert request is not None + assert request.status == "failed" + assert request.failure_code == "SCHEDULER_STATE_STALE" + assert request.finished_at is not None + assert request.compute_node_id == promoted.compute_node_id + assert _count(session, ServingGpuLease) == leases_before + assert _count(session, ServingJob) == jobs_before diff --git a/backend/tests/test_rc_failure_storage.py b/backend/tests/test_rc_failure_storage.py new file mode 100644 index 0000000..5647122 --- /dev/null +++ b/backend/tests/test_rc_failure_storage.py @@ -0,0 +1,73 @@ +"""Deterministic storage fault injection for the authenticated embedding gateway.""" + +from __future__ import annotations + +import redis +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.domain.serving import EmbeddingInvokeRequest, ServiceClientCreate +from modelforge_api.persistence.models import Base, GatewayRequest, ServingGpuLease, ServingJob +from modelforge_api.services.serving import ServingError +from tests.test_serving import InlineWorkerServingService, approve_and_promote, setup_m5 + + +def test_redis_payload_write_outage_fails_closed_and_releases_gateway_lease() -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as session: + base, candidate, _node = setup_m5(session) + approve_and_promote(base, candidate.id) + service = InlineWorkerServingService( + session, base.settings, base.manifests, base.payload_store + ) + created = service.create_client( + ServiceClientCreate( + name="storage-fault-client", + allowed_capabilities=["rag.embedding@1"], + requests_per_minute=10, + ) + ) + client = service.authenticate(f"Bearer {created.credential}", "rag.embedding@1") + payload_store = service.payload_store + original_put = payload_store.put + + def unavailable(*_args: object, **_kwargs: object) -> str: + raise redis.ConnectionError("private redis host and credential") + + payload_store.put = unavailable + try: + try: + service.invoke(EmbeddingInvokeRequest(input="sensitive test input"), client) + except ServingError as error: + assert error.status_code == 503 + assert error.code == "PAYLOAD_STORE_UNAVAILABLE" + assert "private" not in str(error) + else: + raise AssertionError("Redis outage must prevent dispatch") + finally: + payload_store.put = original_put + + request = session.scalars(select(GatewayRequest)).one() + lease = session.scalars(select(ServingGpuLease)).one() + assert request.status == "failed" + assert request.failure_code == "PAYLOAD_STORE_UNAVAILABLE" + assert request.finished_at is not None + assert request.decision_evidence["input_persisted"] is False + assert lease.state == "failed" + assert lease.failure_code == "PAYLOAD_STORE_UNAVAILABLE" + assert lease.released_at is not None + assert ( + session.scalars(select(ServingJob).where(ServingJob.operation == "invoke")).all() == [] + ) + assert payload_store.values == {} + + # Recovery must use normal serving, without a stale lease or hidden fallback. + recovered = service.invoke(EmbeddingInvokeRequest(input="after recovery"), client) + assert recovered.dimension == 1024 + assert session.scalars(select(GatewayRequest)).all()[-1].status == "completed" diff --git a/backend/tests/test_rc_failure_storage_modalities.py b/backend/tests/test_rc_failure_storage_modalities.py new file mode 100644 index 0000000..6ecbda8 --- /dev/null +++ b/backend/tests/test_rc_failure_storage_modalities.py @@ -0,0 +1,108 @@ +"""Redis write outages fail closed on non-text Gateway invocation routes.""" + +from __future__ import annotations + +import pytest +import redis +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.routes.serving import get_serving_service +from modelforge_api.domain.serving import CapabilityExperimentCreate, ServiceClientCreate +from modelforge_api.main import app +from modelforge_api.persistence.models import Base, GatewayRequest, ServingGpuLease, ServingJob +from modelforge_api.settings import get_settings +from tests.test_serving import InlineWorkerServingService, setup_m5 + + +@pytest.mark.parametrize( + ("capability", "body"), + [ + ( + "rag.reranking", + { + "query": "private query", + "documents": [{"id": "one", "text": "private document"}], + "top_n": 1, + }, + ), + ("vision.embedding", {"items": [{"text": "private visual query"}]}), + ], +) +def test_payload_write_outage_fails_closed_for_non_text_routes( + capability: str, body: dict[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as session: + base, candidate, _node = setup_m5(session) + probe = base.repo.probe(candidate.runtime_probe_id) + assert probe is not None + if capability == "rag.reranking": + probe.inference_result = { + "status": "passed", + "output_type": "ranked_scores", + "count": 1, + "finite": True, + "latency_ms": 30.0, + } + probe.runtime_facts = {**probe.runtime_facts, "adapter": "qwen3_reranker"} + session.commit() + base.create_experiment( + candidate.id, + CapabilityExperimentCreate( + route_key=f"storage-fault-{capability.replace('.', '-')}", + execution_approval_id=probe.execution_approval_id, + capability=capability, + purpose="Test the exact local Gateway route during a Redis payload write outage.", + ), + ) + service = InlineWorkerServingService( + session, base.settings, base.manifests, base.payload_store + ) + created = service.create_client( + ServiceClientCreate( + name=f"storage-fault-{capability.replace('.', '-')}", + allowed_capabilities=[f"{capability}@1"], + requests_per_minute=10, + ) + ) + + def unavailable(*_args: object, **_kwargs: object) -> str: + raise redis.ConnectionError("private redis host and credential") + + monkeypatch.setattr(service.payload_store, "put", unavailable) + app.dependency_overrides[get_settings] = lambda: service.settings + app.dependency_overrides[get_serving_service] = lambda: service + try: + response = TestClient(app).post( + f"/api/v1/capabilities/{capability}@1/invoke", + headers={"Authorization": f"Bearer {created.credential}"}, + json=body, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 503 + assert response.json()["error"]["code"] == "PAYLOAD_STORE_UNAVAILABLE" + assert "private redis host" not in response.text + request = session.scalars(select(GatewayRequest)).one() + lease = session.scalars(select(ServingGpuLease)).one() + assert request.status == "failed" + assert request.failure_code == "PAYLOAD_STORE_UNAVAILABLE" + assert request.finished_at is not None + assert request.decision_evidence["input_persisted"] is False + assert "private" not in str(request.decision_evidence) + assert lease.state == "failed" + assert lease.failure_code == "PAYLOAD_STORE_UNAVAILABLE" + assert lease.released_at is not None + assert not session.scalars(select(ServingJob).where(ServingJob.operation == "invoke")).all() + assert service.payload_store.values == {} + residency = service.repo.residency(request.capability_deployment_id) + assert residency is not None and residency.active_requests == 0 diff --git a/backend/tests/test_rc_readiness_program.py b/backend/tests/test_rc_readiness_program.py new file mode 100644 index 0000000..977622d --- /dev/null +++ b/backend/tests/test_rc_readiness_program.py @@ -0,0 +1,138 @@ +"""The RC ledger gate must fail closed on ambiguous plans or incomplete evidence.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("rc_readiness", ROOT / "scripts/rc_readiness.py") +assert SPEC is not None and SPEC.loader is not None +readiness = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(readiness) + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + for relative in (readiness.CONFIG, readiness.LEDGER, readiness.ROADMAP): + target = tmp_path / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text((ROOT / relative).read_text(encoding="utf-8"), encoding="utf-8") + return tmp_path + + +def mutate_plan(repository: Path, change) -> None: + path = repository / readiness.CONFIG + plan = json.loads(path.read_text(encoding="utf-8")) + change(plan) + path.write_text(json.dumps(plan), encoding="utf-8") + + +def test_actual_repository_is_valid_but_incomplete() -> None: + result = readiness.validate(ROOT) + plan = json.loads((ROOT / readiness.CONFIG).read_text(encoding="utf-8")) + assert result["packet_count"] == len(plan["packets"]) + assert result["required_gap_count"] > result["verified_count"] > 0 + assert result["outstanding_ids"] + assert result["all_required_rows_verified"] is False + assert result["topological_order"].index("P06") < result["topological_order"].index("P03") + assert "release_ready" not in result + + +def test_repository_path_guard_rejects_escape_before_read(repository: Path) -> None: + with pytest.raises(readiness.InvalidPlan, match="escapes root"): + readiness._read(repository, "../outside.md") + + +def test_status_documentation_is_not_parsed_as_issue_rows(repository: Path) -> None: + before = readiness.validate(repository) + path = repository / readiness.LEDGER + with path.open("a", encoding="utf-8") as output: + output.write("\n| Classification | UNKNOWN |\nProse RC-FAKE-01 is not an issue row.\n") + assert readiness.validate(repository) == before + + +@pytest.mark.parametrize("change", [ + lambda p: p.update(schema_version=True), + lambda p: p.update(schema_version=2), + lambda p: p.update(extra=True), + lambda p: p.pop("ledger"), + lambda p: p.update(target_version="stable"), + lambda p: p.update(baseline_commit="main"), + lambda p: p.update(ledger="../../outside.md"), + lambda p: p.update(roadmap="C:/outside.md"), + lambda p: p.update(packets=[]), + lambda p: p["packets"].append(p["packets"][0]), + lambda p: p["packets"][0].update(id="bad"), + lambda p: p["packets"][0]["depends_on"].append("P99"), + lambda p: p["packets"][0]["depends_on"].append("P15"), + lambda p: p["packets"][0]["depends_on"].extend(["P01", "P01"]), + lambda p: p["packets"][0]["gaps"].pop(), + lambda p: p["packets"][0]["gaps"].append("RC-UNKNOWN-01"), + lambda p: p["packets"][0]["gaps"].append(p["packets"][1]["gaps"][0]), + lambda p: p["deferred_gaps"].append(p["deferred_gaps"][0]), + lambda p: p["deferred_gaps"].append(p["packets"][0]["gaps"].pop()), + lambda p: p["packets"][0]["gaps"].append(p["deferred_gaps"].pop()), +]) +def test_invalid_plans_are_rejected(repository: Path, change) -> None: + mutate_plan(repository, change) + with pytest.raises(readiness.InvalidPlan): + readiness.validate(repository) + + +@pytest.mark.parametrize("text", ["{", '{"schema_version":1,"schema_version":1}', + '{"packets":[{"id":"P00","id":"P01"}]}']) +def test_malformed_and_duplicate_json_fields(repository: Path, text: str) -> None: + (repository / readiness.CONFIG).write_text(text, encoding="utf-8") + with pytest.raises(ValueError): + readiness.validate(repository) + + +@pytest.mark.parametrize("relative", [readiness.CONFIG, readiness.LEDGER, readiness.ROADMAP]) +def test_missing_inputs_fail(repository: Path, relative: str) -> None: + (repository / relative).unlink() + with pytest.raises(OSError): + readiness.validate(repository) + + +@pytest.mark.parametrize("mode", ["duplicate", "empty", "unknown_status", "false_verified", + "deferral", "classification", "malformed"]) +def test_invalid_ledger_rows_fail(repository: Path, mode: str) -> None: + path = repository / readiness.LEDGER + text = path.read_text(encoding="utf-8") + row = next(line for line in text.splitlines() if line.startswith("| RC-")) + changed = { + "duplicate": text + "\n" + row, + "empty": "# No rows", + "unknown_status": text.replace("| VERIFIED |", "| APPROVED |", 1), + "false_verified": text.replace("| VERIFIED |", "| VERIFIED — pending |", 1), + "deferral": text.replace("| VERIFIED |", "| DEFERRED |", 1), + "classification": text.replace(row, row.replace(row.split("|")[4], " UNKNOWN ", 1)), + "malformed": text.replace(row, row.rstrip("|"), 1), + }[mode] + path.write_text(changed, encoding="utf-8") + with pytest.raises(readiness.InvalidPlan): + readiness.validate(repository) + + +def test_cli_exit_codes_and_full_verification(repository: Path, monkeypatch, capsys) -> None: + original = readiness.validate + monkeypatch.setattr(readiness, "validate", lambda: original(repository)) + assert readiness.main([]) == 0 + assert readiness.main(["--require-verified"]) == 1 + path = repository / readiness.LEDGER + lines = path.read_text(encoding="utf-8").splitlines() + path.write_text("\n".join( + line.rsplit("|", 2)[0] + "| VERIFIED |" + if line.startswith("| RC-") and "| POST-RC |" not in line else line + for line in lines + ), encoding="utf-8") + assert readiness.main(["--require-verified"]) == 0 + result = json.loads(capsys.readouterr().out.splitlines()[-1]) + assert result["all_required_rows_verified"] is True + assert result["outstanding_ids"] == [] + assert result["verified_count"] == result["required_gap_count"] + path.unlink() + assert readiness.main([]) == 2 + assert json.loads(capsys.readouterr().out)["all_required_rows_verified"] is False diff --git a/backend/tests/test_recovery_api_m15.py b/backend/tests/test_recovery_api_m15.py index bd26428..491a997 100644 --- a/backend/tests/test_recovery_api_m15.py +++ b/backend/tests/test_recovery_api_m15.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import uuid from pathlib import Path from typing import Any @@ -33,6 +34,7 @@ RECOVERY_ROUTES = ( ("GET", "/api/v1/admin/recovery/restore-operations"), ("GET", "/api/v1/admin/recovery/artifact-recoveries"), ("GET", "/api/v1/admin/recovery/fingerprint"), + ("GET", "/api/v1/admin/recovery/retention/preview"), ("POST", "/api/v1/admin/recovery/retention/run"), ) @@ -105,6 +107,34 @@ def test_dashboard_reports_measured_state_and_never_invents_a_backup(recovery: A assert payload["verified_backup_count"] == 0 +def test_retention_preview_returns_exact_ids_without_backup_or_secret_metadata( + recovery: Any, tmp_path: Path +) -> None: + session, _service, _settings = recovery + _subject, record = seed_backup( + session, tmp_path, backup_id="m15-retention-api-preview" + ) + + response = client.get("/api/v1/admin/recovery/retention/preview", headers=ADMIN) + + assert response.status_code == 200 + payload = response.json() + assert payload["policy_key"] == "control-plane.database" + assert payload["minimum_verified_backups"] == 2 + assert payload["retained_backup_ids"] == [record.backup_id] + assert payload["expiring_backup_ids"] == [] + assert len(payload["decisions"]) == 1 + assert payload["decisions"][0]["backup_id"] == record.backup_id + assert payload["decisions"][0]["current_state"] == "CREATED" + assert payload["decisions"][0]["decision"] == "RETAIN" + assert payload["decisions"][0]["reason"] == "RETENTION_NOT_REACHED" + assert payload["decisions"][0]["expires_at"] is not None + serialized = json.dumps(payload).lower() + assert "destination_root" not in payload + assert "encryption_key_id" not in payload + assert "secret" not in serialized + + def test_policies_and_assets_expose_the_full_classification(recovery: Any) -> None: policies = client.get("/api/v1/admin/recovery/policies", headers=ADMIN).json() assert {item["key"] for item in policies} >= { diff --git a/backend/tests/test_recovery_audit_integrity_rc.py b/backend/tests/test_recovery_audit_integrity_rc.py index 19180f4..92af2ad 100644 --- a/backend/tests/test_recovery_audit_integrity_rc.py +++ b/backend/tests/test_recovery_audit_integrity_rc.py @@ -38,10 +38,21 @@ from modelforge_api.services.audit import ( ) from modelforge_api.services.recovery import RecoveryError, RecoveryService from modelforge_api.services.recovery_fingerprint import ( + ALERT_NOTIFICATION_TABLES, + CREATIVE_TABLES, CURRENT_TRUTH_TABLES, FINGERPRINT_VERSION, + LAB_EXECUTION_TABLES, LEGACY_FINGERPRINT_VERSION, + MANAGED_ACTIVATION_TABLE, + MANAGED_IDENTITY_TABLES, MAX_ROWS_PER_TABLE, + MODEL_CHANGE_TABLES, + MODEL_UPDATE_TABLES, + PORTFOLIO_TABLES, + SECRET_CURRENT_TABLES, + SECRET_TABLES, + SELECTED_COMPARISON_TABLES, _canonical_row, _table_digest, audit_fingerprint_differences_are_compatible, @@ -334,6 +345,31 @@ def test_valid_resume_checks_chain_before_returning_already_applied( assert engine.operations.index("query_rows") < engine.operations.index("scalar") +@pytest.mark.parametrize("table_exists", [False, True]) +def test_reconciliation_clears_only_present_model_watch_claims( + session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, table_exists: bool, +) -> None: + subject = _service(session, tmp_path) + sql_seen: list[str] = [] + def scalar(target: object, sql: str) -> str: + sql_seen.append(sql) + if "table_name='model_update_watches'" in sql: + return "1" if table_exists else "0" + if "select count(*) from model_update_watches" in sql: + return "2" + return "" if "string_agg" in sql else "0" + monkeypatch.setattr(subject.engine, "scalar", scalar) + monkeypatch.setattr(subject, "_verify_restored_audit_chain", lambda target: {}) + monkeypatch.setattr(subject, "_append_restored_audit_marker", lambda *args, **kwargs: None) + record = SimpleNamespace(id=uuid.uuid4()) + plan = SimpleNamespace(database_destination="postgresql://modelforge:test@postgres:5432/restored", + backup_set=SimpleNamespace(backup_id="watch-recovery")) + result = subject._reconcile_restored(record, plan) + assert ("update model_update_watches set check_token=NULL, check_until=NULL" in sql_seen) is table_exists + assert result["cleared_current_truth"].get("model_update_watch_claims", 0) == (2 if table_exists else 0) + assert not any("delete from model_update" in sql for sql in sql_seen) + + def test_corruption_is_an_independent_ready_gate_before_fingerprints( session: Session, tmp_path: Path ) -> None: @@ -506,7 +542,8 @@ def _fingerprint( *, include_checkpoint: bool, ) -> dict[str, object]: - table_names = set(classified_tables()) + # These fixtures model schema 0022/0024, before online identities and secrets. + table_names = set(classified_tables()) - ALERT_NOTIFICATION_TABLES - PORTFOLIO_TABLES - MODEL_CHANGE_TABLES - CREATIVE_TABLES - SELECTED_COMPARISON_TABLES - MODEL_UPDATE_TABLES - SECRET_TABLES - MANAGED_IDENTITY_TABLES - LAB_EXECUTION_TABLES - {"configuration_state", "configuration_revisions", "project_onboarding"} if not include_checkpoint: table_names.remove("audit_chain_heads") tables: dict[str, object] = { @@ -536,7 +573,7 @@ def _fingerprint( "tables": tables, "digest": hashlib.sha256(version.encode()).hexdigest(), "groups": {}, - "physical_tables": sorted(table_names | CURRENT_TRUTH_TABLES), + "physical_tables": sorted(table_names | (CURRENT_TRUTH_TABLES - SECRET_CURRENT_TABLES - {MANAGED_ACTIVATION_TABLE})), "unexpected_tables": [], } @@ -548,7 +585,9 @@ def _with_legitimate_reconciliation_delta( restored["version"] = FINGERPRINT_VERSION restored["digest"] = "f" * 64 restored["unexpected_tables"] = [] - restored["physical_tables"] = sorted(classified_tables() | CURRENT_TRUTH_TABLES) + restored["physical_tables"] = sorted( + (classified_tables() - ALERT_NOTIFICATION_TABLES - PORTFOLIO_TABLES - MODEL_CHANGE_TABLES - CREATIVE_TABLES - SELECTED_COMPARISON_TABLES - MODEL_UPDATE_TABLES - SECRET_TABLES - MANAGED_IDENTITY_TABLES - LAB_EXECUTION_TABLES - {"configuration_state", "configuration_revisions", "project_onboarding"}) | (CURRENT_TRUTH_TABLES - SECRET_CURRENT_TABLES - {MANAGED_ACTIVATION_TABLE}) + ) tables = restored["tables"] assert isinstance(tables, dict) tables["audit_events"] = { diff --git a/backend/tests/test_recovery_audit_order_postgres.py b/backend/tests/test_recovery_audit_order_postgres.py new file mode 100644 index 0000000..f7ed51e --- /dev/null +++ b/backend/tests/test_recovery_audit_order_postgres.py @@ -0,0 +1,57 @@ +"""Real restored audit pagination must preserve numeric sequence ordering.""" + +from __future__ import annotations + +import importlib +import os +from pathlib import Path + +from sqlalchemy.orm import Session + +from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.recovery import RecoveryService +from modelforge_api.services.recovery_postgres import parse_target +from modelforge_api.settings import Settings +from tests.test_managed_identities_postgres import ( + IdentityDatabase, + ManagedPostgresTools, + identity_database, +) + +__all__ = ["identity_database"] + + +def test_restored_audit_numeric_order_crosses_page_boundary( + identity_database: IdentityDatabase, tmp_path: Path, +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + event_count = 512 # Production iteration reads 500 rows per page. + with Session(database.runtime) as session: + writer = AuditWriter(session, "operator", "audit-pagination-qualification") + expected_ids = [] + for ordinal in range(1, event_count + 1): + event = writer.write( + "qualification.audit_pagination", "recovery", None, + {"ordinal": ordinal, "source": "isolated regression fixture"}, + ) + assert event.sequence == ordinal + expected_ids.append(str(event.id)) + session.commit() + + archive = tmp_path / "audit-pagination.dump" + database.archive(archive) + database.archive(archive, restore=True) + target = parse_target(database.restored_owner.render_as_string(hide_password=False)) + with Session(database.runtime) as session: + recovery = RecoveryService(session, Settings(_env_file=None, env="test")) + recovery.engine = ManagedPostgresTools( + base_environment=dict(os.environ), timeout_seconds=60, + ) + events = list(recovery._restored_audit_events(target)) + assert [event.sequence for event in events] == list(range(1, event_count + 1)) + assert [event.id for event in events] == expected_ids + assert len({event.id for event in events}) == event_count + checkpoint = recovery._verify_restored_audit_chain(target) + assert checkpoint.event_count == checkpoint.last_sequence == event_count + assert checkpoint.last_event_hash == events[-1].event_hash diff --git a/backend/tests/test_recovery_m15.py b/backend/tests/test_recovery_m15.py index 95f7191..56c5f1a 100644 --- a/backend/tests/test_recovery_m15.py +++ b/backend/tests/test_recovery_m15.py @@ -6,13 +6,15 @@ import base64 import hashlib import inspect import json +import os +import stat import uuid from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any import pytest -from sqlalchemy import create_engine, func, select +from sqlalchemy import create_engine, event, func, select from sqlalchemy.orm import Session from modelforge_api.domain.recovery import ( @@ -45,6 +47,8 @@ from modelforge_api.persistence.models import ( ModelRevision, RecoveryAssetRecord, RecoveryPolicyRevision, + RestoreOperation, + RestorePlan, StorageRoot, UpstreamSnapshot, ) @@ -328,6 +332,7 @@ def seed_backup( encrypted: bool = False, payload: bytes = b"PGDMP-fake-custom-dump-payload", milestone: str | None = None, + legal_hold: bool = False, ) -> tuple[RecoveryService, BackupSet]: """Create a VERIFIED-shaped backup on disk without invoking PostgreSQL tooling.""" @@ -403,6 +408,7 @@ def seed_backup( encryption_key_id="modelforge-backup-key-1" if encrypted else None, verification_details={}, milestone=milestone, + legal_hold=legal_hold, reason="unit test fixture", created_by="test", started_at=datetime.now(UTC), @@ -554,6 +560,50 @@ def test_an_encrypted_backup_verified_with_the_wrong_key_fails_closed( assert not any(path.name.startswith("database.dump.enc.verify-") for path in directory.iterdir()) +def test_encrypted_verification_stages_only_on_restore_volume( + session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + subject, record = seed_backup( + session, tmp_path, backup_id="m15-read-only-source", encrypted=True + ) + observed: list[Path] = [] + + def inspect_dump(path: Path) -> int: + observed.append(path) + assert path.parent == (tmp_path / "restore").resolve() + assert path.read_bytes() == b"PGDMP-fake-custom-dump-payload" + if os.name == "posix": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + return 1 + + monkeypatch.setattr(subject.engine, "list_dump_contents", inspect_dump) + result = subject.verify_backup(record.id) + + assert result.state is BackupState.VERIFIED + assert len(observed) == 1 + assert not observed[0].exists() + assert not list((tmp_path / "restore").iterdir()) + assert sorted(path.name for path in (tmp_path / "backups" / "m15-read-only-source").iterdir()) == [ + "database.dump.enc", "manifest.json" + ] + + +def test_encrypted_verification_refuses_staging_inside_backup_root( + session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + subject, record = seed_backup( + session, tmp_path, backup_id="m15-overlapping-roots", encrypted=True + ) + subject.settings = settings_for(tmp_path, backup_restore_root=tmp_path / "backups" / "staging") + monkeypatch.setattr(subject.engine, "list_dump_contents", lambda _payload: 1) + + result = subject.verify_backup(record.id) + + assert result.state is BackupState.FAILED + assert result.failure_code == RecoveryFailureCode.PATH_NOT_ALLOWED.value + assert not list((tmp_path / "backups" / "staging").iterdir()) + + def test_an_encrypted_backup_without_a_key_reports_key_unavailable( session: Session, tmp_path: Path ) -> None: @@ -605,6 +655,12 @@ def test_retention_never_expires_the_last_verified_backup( record.expires_at = datetime.now(UTC) - timedelta(days=1) session.commit() + preview = subject.preview_retention() + assert preview.retained_backup_ids == [record.backup_id] + assert preview.expiring_backup_ids == [] + assert preview.decisions[0].reason == "MINIMUM_VERIFIED_BACKUPS" + assert subject.backup(record.id).state is BackupState.VERIFIED + result = subject.apply_retention() assert result["expired"] == 0 assert subject.backup(record.id).state is BackupState.VERIFIED @@ -637,6 +693,178 @@ def test_retention_expires_surplus_backups_but_keeps_the_policy_minimum( assert states["m15-retention-0"] is BackupState.EXPIRED +def test_retention_preview_matches_apply_and_does_not_commit_or_write_audit( + session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + observed_at = datetime(2026, 9, 23, 12, 0, tzinfo=UTC) + records: list[BackupSet] = [] + for backup_id, milestone, legal_hold in ( + ("m15-retention-expire", None, False), + ("m15-retention-hold", None, True), + ("m15-retention-milestone", "rc-baseline", False), + ("m15-retention-minimum-a", None, False), + ("m15-retention-minimum-b", None, False), + ("m15-retention-created", None, False), + ): + subject, record = seed_backup( + session, + tmp_path, + backup_id=backup_id, + milestone=milestone, + legal_hold=legal_hold, + ) + records.append(record) + monkeypatch.setattr(subject.engine, "list_dump_contents", lambda _payload: 1) + for index, record in enumerate(records[:-1]): + subject.verify_backup(record.id) + record.verified_at = observed_at - timedelta(days=len(records) - index) + record.expires_at = observed_at - timedelta(days=1) + records[-1].expires_at = observed_at - timedelta(days=1) + session.commit() + + states_before = {item.backup_id: item.state for item in records} + audit_count_before = session.scalar(select(func.count()).select_from(AuditEvent)) + pending_policy = RecoveryPolicyRevision( + key="control-plane.preview-pending-test", + revision=1, + name="Pending preview policy", + asset_class="AUTHORITATIVE", + backup_method="POSTGRES_LOGICAL_CUSTOM", + retention_days=1, + minimum_verified_backups=1, + rpo_seconds=60, + rto_target_seconds=60, + restore_verification="FULL_RESTORE", + encryption_required=True, + external_dependency=False, + rehydration_allowed=False, + secret_class=None, + rationale="A pending row detects accidental autoflush in the read-only preview.", + active=True, + fingerprint="c" * 64, + created_by="test", + ) + session.add(pending_policy) + commits: list[None] = [] + + def after_commit(_session: Session) -> None: + commits.append(None) + + event.listen(session, "after_commit", after_commit) + try: + preview = subject.preview_retention(observed_at) + finally: + event.remove(session, "after_commit", after_commit) + + assert preview.observed_at == observed_at + assert preview.minimum_verified_backups == 2 + assert preview.retained_backup_ids == [ + "m15-retention-hold", + "m15-retention-milestone", + "m15-retention-minimum-a", + "m15-retention-minimum-b", + ] + assert preview.expiring_backup_ids == ["m15-retention-created", "m15-retention-expire"] + reasons = {item.backup_id: item.reason for item in preview.decisions} + assert reasons["m15-retention-hold"] == "LEGAL_HOLD" + assert reasons["m15-retention-milestone"] == "MILESTONE" + assert reasons["m15-retention-minimum-a"] == "MINIMUM_VERIFIED_BACKUPS" + assert {item.backup_id: item.state for item in records} == states_before + assert pending_policy in session.new + session.expunge(pending_policy) + assert session.scalar(select(func.count()).select_from(AuditEvent)) == audit_count_before + assert commits == [] + assert not session.new and not session.dirty and not session.deleted + + applied = subject.apply_retention(observed_at) + assert applied == {"expired": 2, "protected": 4, "skipped_active_restore": 0} + expired = session.scalars( + select(AuditEvent).where(AuditEvent.action == "BACKUP_EXPIRED") + ) + assert {item.details["backup_id"] for item in expired} == set(preview.expiring_backup_ids) + assert { + item.backup_id for item in records if item.state == BackupState.EXPIRED.value + } == set(preview.expiring_backup_ids) + + +def test_retention_preview_reports_active_restore_and_apply_skips_without_policy_seeding( + session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + subject, record = seed_backup(session, tmp_path, backup_id="m15-retention-active") + monkeypatch.setattr(subject.engine, "list_dump_contents", lambda _payload: 1) + subject.verify_backup(record.id) + record.expires_at = datetime.now(UTC) - timedelta(days=1) + + plan = RestorePlan( + backup_set_id=record.id, + mode="VALIDATION", + state="PREFLIGHT_PASSED", + target_environment="ISOLATED", + target_label="active-retention-preview", + database_destination="postgresql+psycopg://modelforge:secret@postgres:5432/mf_restore", + artifact_strategy="NONE", + secret_strategy="MANUAL", # noqa: S106 - recovery strategy label is not a secret + node_strategy="NONE", + preflight={}, + validation_requirements={}, + fingerprint="a" * 64, + reason="active restore retention guard test", + created_by="test", + ) + session.add(plan) + session.flush() + session.add( + RestoreOperation( + plan_id=plan.id, + backup_set_id=record.id, + state="RESTORING_DATABASE", + attempt=1, + idempotency_key="b" * 64, + preflight_result={}, + phase_durations={}, + source_fingerprint={}, + restored_fingerprint={}, + fingerprint_diff={}, + validation_result={}, + ) + ) + session.commit() + + preview = subject.preview_retention() + assert preview.active_restore_in_progress is True + assert preview.retained_backup_ids == [record.backup_id] + assert preview.expiring_backup_ids == [] + assert preview.decisions[0].reason == "ACTIVE_RESTORE_IN_PROGRESS" + + policy = session.scalar( + select(RecoveryPolicyRevision).where( + RecoveryPolicyRevision.key == "control-plane.database", + RecoveryPolicyRevision.active.is_(True), + ) + ) + assert policy is not None + policy.active = False + session.commit() + + with pytest.raises(RecoveryError) as error: + subject.preview_retention() + assert error.value.code == "recovery_policy_missing" + + # Historically the active-restore guard returned before looking up or seeding a policy. + assert subject.apply_retention() == { + "expired": 0, + "protected": 1, + "skipped_active_restore": 1, + } + assert session.scalar( + select(func.count()).select_from(RecoveryPolicyRevision).where( + RecoveryPolicyRevision.key == "control-plane.database", + RecoveryPolicyRevision.active.is_(True), + ) + ) == 0 + assert record.state == BackupState.VERIFIED.value + + def test_milestone_and_legal_hold_backups_are_never_expired( session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1057,7 +1285,7 @@ def test_the_alembic_tree_is_found_from_the_repository_layout() -> None: assert root is not None and (root / "versions").is_dir() revisions = _known_alembic_revisions() assert revisions[0] == "20260824_0001" - assert revisions[-1] == "20260830_0024" + assert revisions[-1] == "20260924_0039" assert len(revisions) == len(set(revisions)) diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py index 227e886..83b91ed 100644 --- a/backend/tests/test_registry.py +++ b/backend/tests/test_registry.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest from fastapi.testclient import TestClient +from http_contract_fakes import isolated_http_contract # noqa: F401 - opt-in pytest fixture from pydantic import SecretStr from sqlalchemy import create_engine, select from sqlalchemy.orm import Session @@ -33,6 +34,8 @@ from modelforge_api.services.registry import ( ) from modelforge_api.settings import Settings, get_settings +pytestmark = pytest.mark.usefixtures("isolated_http_contract") + def _test_operator_credential() -> str: return "registry-test-operator" diff --git a/backend/tests/test_release_contract_m17.py b/backend/tests/test_release_contract_m17.py index b380cb2..7f0d1b1 100644 --- a/backend/tests/test_release_contract_m17.py +++ b/backend/tests/test_release_contract_m17.py @@ -28,8 +28,10 @@ from modelforge_api.domain.release import ( TARGET_SCHEMA_REVISION, Compatibility, SemanticVersion, + _canonicalize_packaged_version, agent_protocol_compatibility, build_identity, + release_channel_for, schema_compatibility, upgrade_required, ) @@ -44,9 +46,6 @@ 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 @@ -58,8 +57,6 @@ def test_the_version_file_is_the_single_source_of_truth() -> None: ["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}" @@ -85,40 +82,76 @@ def test_every_component_module_matches_the_version_file(module: str) -> None: assert match.group(1) == PRODUCT_VERSION -def test_the_release_version_is_semantic_and_not_a_prerelease() -> None: +def test_the_release_version_is_the_explicit_rc_prerelease() -> None: parsed = SemanticVersion.parse(PRODUCT_VERSION) - assert not parsed.is_prerelease + assert parsed.is_prerelease + assert parsed.prerelease == "rc.1" + assert RELEASE_CHANNEL == "rc" 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_release_channel_is_derived_from_a_semantic_version() -> None: + assert release_channel_for("1.3.0") == "stable" + assert release_channel_for("1.3.0-rc.2") == "rc" + assert release_channel_for("1.3.0-rc.preview") == "prerelease" + assert release_channel_for("1.3.0-preview.2") == "prerelease" + + +@pytest.mark.parametrize( + ("metadata_version", "expected"), + [ + ("1.3.0", "1.3.0"), + ("1.3.0rc1", "1.3.0-rc.1"), + ("1.3.0rc01", "1.3.0rc01"), + ("1.3.0a1", "1.3.0a1"), + ], +) +def test_installed_package_metadata_only_canonicalizes_numbered_rc_semver( + metadata_version: str, expected: str +) -> None: + assert _canonicalize_packaged_version(metadata_version) == expected + + +@pytest.mark.parametrize("bad", ["1.0", "1.0.0.0", "v1.0.0", "one.0.0", "1.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 == "20260924_0039" 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 "20260923_0037" in SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS assert "20260827_0021" not in SUPPORTED_SCHEMA_REVISIONS + assert "20260923_0037" 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 PRODUCT_VERSION == "1.3.0-rc.1" assert MINIMUM_UPGRADE_SOURCE == "v1.0.0" assert SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS == ( "20260827_0021", "20260828_0022", "20260830_0023", "20260830_0024", + "20260907_0025", + "20260908_0026", + "20260908_0027", + "20260908_0028", + "20260909_0029", + "20260909_0030", + "20260909_0031", + "20260909_0032", + "20260909_0033", + "20260910_0034", + "20260911_0035", + "20260923_0036", + "20260923_0037", + "20260924_0038", + "20260924_0039", ) @@ -137,7 +170,19 @@ def test_v1_1_documents_the_destructive_downgrade_boundary_truthfully() -> None: @pytest.mark.parametrize( ("revision", "expected"), [ - ("20260830_0024", Compatibility.COMPATIBLE), + ("20260923_0036", Compatibility.TOO_OLD), + ("20260923_0037", Compatibility.TOO_OLD), + ("20260924_0038", Compatibility.TOO_OLD), + ("20260924_0039", Compatibility.COMPATIBLE), + ("20260911_0035", Compatibility.TOO_OLD), + ("20260910_0034", Compatibility.TOO_OLD), + ("20260909_0033", Compatibility.TOO_OLD), + ("20260909_0032", Compatibility.TOO_OLD), + ("20260908_0028", Compatibility.TOO_OLD), + ("20260908_0027", Compatibility.TOO_OLD), + ("20260908_0026", Compatibility.TOO_OLD), + ("20260907_0025", Compatibility.TOO_OLD), + ("20260830_0024", Compatibility.TOO_OLD), ("20260830_0023", Compatibility.TOO_OLD), ("20260828_0022", Compatibility.TOO_OLD), ("20260827_0021", Compatibility.TOO_OLD), @@ -182,9 +227,6 @@ def test_the_declared_protocol_matches_the_wire_constant() -> None: 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 @@ -197,8 +239,6 @@ def test_build_identity_reports_absent_values_as_null_rather_than_guessing() -> @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 @@ -211,9 +251,6 @@ def test_build_identity_carries_a_real_commit_when_given_one() -> None: assert "source_commit" in identity.as_dict() -# --------------------------------------------------------------------------- configuration - - def _production(**overrides: object) -> Settings: base: dict[str, object] = { "env": "production", @@ -342,8 +379,6 @@ def test_production_refuses_to_start_while_development_defaults_survive(tmp_path 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, @@ -352,7 +387,7 @@ def test_development_reports_the_same_problems_without_refusing_to_start(tmp_pat backup_root=tmp_path, ) report = enforce_startup(settings) - assert report.ok or report.problems is not None # never raises outside production + assert report.ok or report.problems is not None def test_the_minimum_postgres_major_is_stated() -> None: @@ -360,12 +395,6 @@ def test_the_minimum_postgres_major_is_stated() -> None: 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 diff --git a/backend/tests/test_release_provenance_v121.py b/backend/tests/test_release_provenance_v121.py index ad2ccdf..520bc78 100644 --- a/backend/tests/test_release_provenance_v121.py +++ b/backend/tests/test_release_provenance_v121.py @@ -36,8 +36,9 @@ RELEASE_BUILD = ROOT / "scripts" / "release_build.py" FRONTEND_DOCKERFILE = ROOT / "frontend" / "Dockerfile" NODE_AGENT_DOCKERFILE = ROOT / "node-agent" / "Dockerfile" -#: Every image the release builder publishes. The Runtime Worker is a first-class private runtime -#: artifact even though it exposes no public listener. +#: Every image the release builder publishes. The Runtime Worker is a first-class release image: +#: the remote GPU projection may still build locally for development, but an RC installs its exact +#: immutable tag rather than reconstructing it from source. RELEASE_IMAGES = ( "modelforge-api", "modelforge-web", @@ -149,7 +150,7 @@ def test_the_console_csp_is_derived_from_the_same_origin_argument() -> None: dockerfile = FRONTEND_DOCKERFILE.read_text(encoding="utf-8") assert 'API_ORIGIN=$(printf \'%s\' "${VITE_API_BASE_URL}" | cut -d/ -f1-3)' in dockerfile # The build fails rather than shipping a policy with an unsubstituted placeholder. - assert 'grep -q "connect-src \'self\' ${API_ORIGIN};"' in dockerfile + assert 'grep -q "connect-src ${API_ORIGIN};"' in dockerfile def test_the_console_csp_is_never_widened_to_a_wildcard() -> None: @@ -157,7 +158,7 @@ def test_the_console_csp_is_never_widened_to_a_wildcard() -> None: connect = [line for line in template.splitlines() if "connect-src" in line] assert connect, "the template must define connect-src" for line in connect: - assert "connect-src 'self' __API_ORIGIN__" in line + assert "connect-src __API_ORIGIN__" in line assert "*" not in line.split("connect-src", 1)[1].split(";")[0] @@ -228,6 +229,16 @@ def test_the_node_agent_compose_build_passes_the_release_identity() -> None: assert args["MODELFORGE_BUILT_AT"] == "${MODELFORGE_BUILT_AT:-}" +def test_the_runtime_worker_compose_projection_accepts_an_exact_release_image() -> None: + worker = _compose("docker-compose.runtime-worker.yml")["services"]["runtime-worker"] + assert "MODELFORGE_RUNTIME_WORKER_IMAGE" in worker["image"] + assert "latest" not in worker["image"] + args = worker["build"]["args"] + assert args["MODELFORGE_VERSION"] == "${MODELFORGE_VERSION:-0.0.0}" + assert args["MODELFORGE_COMMIT"] == "${MODELFORGE_COMMIT:-}" + assert args["MODELFORGE_BUILT_AT"] == "${MODELFORGE_BUILT_AT:-}" + + def test_every_composed_release_image_passes_the_release_identity() -> None: """Whatever builds a published image must stamp it; otherwise the tag is the only identity.""" @@ -252,14 +263,6 @@ def test_the_node_agent_image_reference_is_versioned_and_never_floating() -> Non assert ":latest" not in reference -def test_the_runtime_worker_image_reference_is_versioned_and_never_floating() -> None: - service = _compose("docker-compose.runtime-worker.yml")["services"]["runtime-worker"] - reference = service["image"] - assert "${MODELFORGE_RUNTIME_WORKER_IMAGE:-" in reference - assert "${MODELFORGE_VERSION:-local}" in reference - assert ":latest" not in reference - - def test_the_agent_version_derives_from_the_repository_version_file() -> None: """Single-source versioning: VERSION drives every packaged manifest. diff --git a/backend/tests/test_secret_slots.py b/backend/tests/test_secret_slots.py new file mode 100644 index 0000000..d78bb1c --- /dev/null +++ b/backend/tests/test_secret_slots.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import base64 +import uuid +from datetime import UTC, datetime, timedelta + +import httpx +import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr +from sqlalchemy import create_engine, delete, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.api.authorization import Principal, PrincipalRole +from modelforge_api.db import get_session +from modelforge_api.domain.secret_slots import ( + SLOT, + SecretSlotRevoke, + SecretSlotSet, + SecretSlotVersionRequest, +) +from modelforge_api.main import app +from modelforge_api.persistence.models import ( + AuditEvent, + Base, + SecretSlotActivation, + SecretSlotTest, + SecretSlotVersion, +) +from modelforge_api.services import secret_slots as module +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.configuration_changes import ConfigurationService +from modelforge_api.settings import Settings, get_settings + +ADMIN = Principal("recovery-admin", PrincipalRole.ADMIN, "legacy_admin_token") +TOKEN = "hf_disposable_unit_test_only" # noqa: S105 - synthetic offline test data +REASON = "Reviewed credential lifecycle" + + +@pytest.fixture +def session(): + engine = create_engine("sqlite://", poolclass=StaticPool, + connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + with Session(engine) as connection: + yield connection + engine.dispose() + + +@pytest.fixture +def settings(): + return Settings(_env_file=None, env="test", hf_token="hf_deployment_test_only", # noqa: S106 + secret_slot_root_key=base64.b64encode(bytes(range(32))).decode()) + + +def service(session, settings, principal=ADMIN): + return module.SecretSlotService(session, settings, principal, "slot-test-42") + + +def candidate(subject, value=TOKEN): + return subject.set(SecretSlotSet(expected_revision=subject.metadata().revision, + reason=REASON, value=value)) + + +def request(metadata): + return SecretSlotVersionRequest(expected_revision=metadata.revision, + version=metadata.candidate_version, reason=REASON) + + +def test_full_lifecycle_encryption_and_no_fallback(session, settings, monkeypatch): + subject = service(session, settings) + assert module.resolve_huggingface_token(session, settings) == settings.hf_token.get_secret_value() + first = candidate(subject) + assert first.state == "pending" and first.active_version is None + row = session.get(SecretSlotVersion, 1) + assert TOKEN not in row.secret_ciphertext + with pytest.raises(module.SecretSlotError, match="explicitly activated"): + module.resolve_huggingface_token(session, settings) + with pytest.raises(module.SecretSlotError, match="successful test"): + subject.activate(request(first)) + monkeypatch.setattr(module, "test_huggingface_token", lambda token: (token == TOKEN, "HF_AUTHENTICATED")) + tested = subject.test(request(first)) + active = subject.activate(request(tested)) + assert active.state == "active" + assert module.resolve_huggingface_token(session, settings) == TOKEN + rotation = candidate(subject, "hf_replacement_test_only") + assert module.resolve_huggingface_token(session, settings) == TOKEN + monkeypatch.setattr(module, "test_huggingface_token", lambda token: (False, "HF_AUTHENTICATION_REJECTED")) + failed = subject.test(request(rotation)) + assert failed.versions[0].test_status == "failed" and failed.active_version == 1 + revoked = subject.revoke(SecretSlotRevoke(expected_revision=failed.revision, + confirm_slot=SLOT, reason=REASON)) + assert revoked.state == "revoked" + with pytest.raises(module.SecretSlotError): + module.resolve_huggingface_token(session, settings) + exported = ConfigurationService(session, settings, AuditContext("operator", "test", "c")).export() + evidence = subject.catalog().model_dump_json() + exported.model_dump_json() + evidence += str(list(session.scalars(select(AuditEvent.details)))) + assert SLOT in exported.secret_slots + assert TOKEN not in evidence and row.secret_ciphertext not in evidence + + +@pytest.mark.parametrize("role", [PrincipalRole.VIEWER, PrincipalRole.OPERATOR]) +def test_non_admin_cannot_modify(session, settings, role): + subject = service(session, settings, Principal("person", role, "named_operator_key")) + assert subject.catalog().slot.source == "deployment" + with pytest.raises(module.SecretSlotError) as denied: + candidate(subject) + assert denied.value.status_code == 403 + assert subject.metadata().revision == 0 + + +def test_test_result_cannot_bless_concurrent_replacement(session, settings, monkeypatch): + subject = service(session, settings) + first = candidate(subject) + def changed(token): + candidate(subject, "hf_newer_candidate") + return True, "HF_AUTHENTICATED" + monkeypatch.setattr(module, "test_huggingface_token", changed) + with pytest.raises(module.SecretSlotError) as conflict: + subject.test(request(first)) + assert conflict.value.code == "SECRET_SLOT_VERSION_CONFLICT" + assert subject.metadata().versions[0].test_status == "untested" + + +def test_root_key_binding_and_recovery_quarantine(session, settings, monkeypatch): + subject = service(session, settings) + first = candidate(subject) + monkeypatch.setattr(module, "test_huggingface_token", lambda token: (True, "HF_AUTHENTICATED")) + active = subject.activate(request(subject.test(request(first)))) + for replacement in (None, SecretStr(base64.b64encode(b"x" * 32).decode())): + changed = settings.model_copy(update={"secret_slot_root_key": replacement}) + with pytest.raises(module.SecretSlotError): + module.resolve_huggingface_token(session, changed) + wrong_id = settings.model_copy(update={"secret_slot_root_key_id": "different-key"}) + with pytest.raises(module.SecretSlotError): + module.resolve_huggingface_token(session, wrong_id) + # Supported recovery clears these exact current-truth tables; real restore is PG-qualified. + session.execute(delete(SecretSlotActivation)) + session.execute(delete(SecretSlotTest)) + session.commit() + assert subject.metadata().state == "quarantined" + with pytest.raises(module.SecretSlotError): + module.resolve_huggingface_token(session, settings) + with pytest.raises(module.SecretSlotError): + subject.activate(request(active)) + refreshed = subject.test(request(subject.metadata())) + subject.activate(request(refreshed)) + assert module.resolve_huggingface_token(session, settings) == TOKEN + + +def test_expired_evidence_and_audit_failure_do_not_activate(session, settings, monkeypatch): + subject = service(session, settings) + first = candidate(subject) + monkeypatch.setattr(module, "test_huggingface_token", lambda token: (True, "HF_AUTHENTICATED")) + tested = subject.test(request(first)) + session.get(SecretSlotTest, 1).tested_at = datetime.now(UTC) - timedelta(minutes=6) + session.commit() + with pytest.raises(module.SecretSlotError): + subject.activate(request(tested)) + tested = subject.test(request(tested)) + def unavailable(*args, **kwargs): + raise RuntimeError("audit unavailable") + monkeypatch.setattr(subject.audit, "write", unavailable) + with pytest.raises(RuntimeError): + subject.activate(request(tested)) + assert subject.metadata().active_version is None + assert subject.metadata().revision == tested.revision + + +class Body(httpx.SyncByteStream): + def __init__(self, chunks): + self.chunks = chunks + def __iter__(self): + yield from self.chunks + + +@pytest.mark.parametrize("status,headers,chunks,expected", [ + (200, {}, [b'{"name":"local-test"}'], (True, "HF_AUTHENTICATED")), + (401, {}, [], (False, "HF_AUTHENTICATION_REJECTED")), + (302, {"location": "https://untrusted.invalid"}, [], (False, "HF_PROVIDER_UNAVAILABLE")), + (200, {"content-encoding": "gzip"}, [], (False, "HF_RESPONSE_INVALID")), + (200, {}, [b"x" * 65537], (False, "HF_RESPONSE_INVALID")), + (200, {}, [b'{}'], (False, "HF_RESPONSE_INVALID")), + (200, {}, [b"[" * 2000 + b"0" + b"]" * 2000], (False, "HF_RESPONSE_INVALID")), +]) +def test_provider_check_is_fixed_bounded_and_redacted(monkeypatch, status, headers, chunks, expected): + actual_client = httpx.Client + calls = [] + def send(request): + calls.append(request) + return httpx.Response(status, headers=headers, stream=Body(chunks)) + def factory(**kwargs): + assert kwargs["follow_redirects"] is False and kwargs["trust_env"] is False + return actual_client(transport=httpx.MockTransport(send), **kwargs) + monkeypatch.setattr(module.httpx, "Client", factory) + assert module.test_huggingface_token(TOKEN) == expected + assert len(calls) == 1 and str(calls[0].url) == "https://huggingface.co/api/whoami-v2" + assert calls[0].headers["authorization"] == f"Bearer {TOKEN}" + + +def test_provider_stream_has_total_deadline(monkeypatch): + actual_client = httpx.Client + def send(request): + return httpx.Response(200, stream=Body([b'{"name":"local-test"}'])) + monkeypatch.setattr(module.httpx, "Client", lambda **kwargs: actual_client( + transport=httpx.MockTransport(send), **kwargs)) + moments = iter([0.0, 11.0]) + monkeypatch.setattr(module.time, "monotonic", lambda: next(moments)) + assert module.test_huggingface_token(TOKEN) == (False, "HF_PROVIDER_TIMEOUT") + + +def test_http_write_only_validation_and_no_store(session, settings): + settings = settings.model_copy(update={"operator_api_key": SecretStr("local-anchor")}) + prior = dict(app.dependency_overrides) + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_session] = lambda: session + try: + client = TestClient(app) + url = "/api/v1/configuration/secret-slots" + assert client.get(url).status_code == 401 + headers = {"X-ModelForge-Admin-Token": "local-anchor"} + response = client.post(url + "/huggingface_control_plane/set", headers=headers, + json={"expected_revision": 0, "reason": REASON, "value": TOKEN}) + assert response.status_code == 200, response.text + assert response.headers["cache-control"] == "no-store" + assert TOKEN not in response.text + invalid = client.post(url + "/huggingface_control_plane/set", headers=headers, + json={"expected_revision": 1, "reason": TOKEN, "value": TOKEN + " "}) + assert invalid.status_code == 422 and TOKEN not in invalid.text + metadata = client.get(url, headers=headers) + assert metadata.status_code == 200 and TOKEN not in metadata.text + finally: + app.dependency_overrides.clear() + app.dependency_overrides.update(prior) + + +def test_both_control_plane_consumers_resolve_active_snapshot(session, settings, monkeypatch): + from modelforge_api.api.routes.acquisition import get_acquisition_service + from modelforge_api.api.routes.agent import get_agent_acquisition_service + from modelforge_api.services.registry import RegistryNotFound + + subject = service(session, settings) + first = candidate(subject) + for consumer in (get_acquisition_service, get_agent_acquisition_service): + local_service = consumer(session, settings) + # Read-only persisted history and node progress must not require upstream credentials. + with pytest.raises(RegistryNotFound, match="model revision not found"): + local_service.artifact_sets(uuid.uuid4()) + with pytest.raises(module.SecretSlotError): + local_service.provider.resolve() + monkeypatch.setattr(module, "test_huggingface_token", lambda token: (True, "HF_AUTHENTICATED")) + subject.activate(request(subject.test(request(first)))) + for consumer in (get_acquisition_service, get_agent_acquisition_service): + resolved = consumer(session, settings) + assert resolved.provider.resolve().token == TOKEN + assert resolved.credential_configured is True + + +def test_revoked_managed_admin_cannot_start_external_test(session, settings, monkeypatch): + from modelforge_api.domain.managed_identity import ManagedIdentityCreate, ManagedIdentityRevoke + from modelforge_api.services.operator_identities import ( + ManagedIdentityService, + authenticate_managed_operator, + ) + + manager = ManagedIdentityService(session, ADMIN, "identity-check") + issued = manager.create(ManagedIdentityCreate(slug="alpha", role="admin", + expires_in_days=1, expected_revision=0, reason=REASON)) + manager.create(ManagedIdentityCreate(slug="beta", role="admin", + expires_in_days=1, expected_revision=1, reason=REASON)) + principal = authenticate_managed_operator(session, issued.one_time_key) + session.commit() + first = candidate(service(session, settings)) + manager.revoke("alpha", ManagedIdentityRevoke(expected_version=1, + confirm_subject="managed:alpha", reason=REASON)) + def forbidden(token): + pytest.fail("Revoked administrator must not trigger provider I/O") + monkeypatch.setattr(module, "test_huggingface_token", forbidden) + with pytest.raises(module.SecretSlotError) as rejected: + service(session, settings, principal).test(request(first)) + assert rejected.value.status_code == 403 diff --git a/backend/tests/test_secret_slots_persistence.py b/backend/tests/test_secret_slots_persistence.py new file mode 100644 index 0000000..b41230a --- /dev/null +++ b/backend/tests/test_secret_slots_persistence.py @@ -0,0 +1,101 @@ +# ruff: noqa: S106 - isolated synthetic persistence fixtures +"""Secret history fingerprint and migration contracts without external credentials.""" +import copy +import importlib.util +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import Base, SecretSlotState, SecretSlotVersion +from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.recovery_fingerprint import ( + SECRET_SCHEMA, + SECRET_TABLES, + FingerprintCompatibility, + _without_creative, + _without_lab_execution, + _without_model_changes, + _without_model_updates, + _without_secrets, + _without_selected_comparisons, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + unclassified_tables, +) +from modelforge_api.services.recovery_fingerprint import ( + fingerprint_session as _current_fingerprint_session, +) + + +def fingerprint_session(session: Session): + return _without_lab_execution(_without_model_changes(_current_fingerprint_session(session))) + + +def test_secret_history_is_strict_except_verified_recovery_marker() -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + assert not unclassified_tables() + session.add(SecretSlotVersion(version=1, key_id="qualification", secret_ciphertext="opaque", + secret_nonce="nonce", created_at=datetime.now(UTC))) + session.commit() + before = _without_creative(_without_model_updates(_without_selected_comparisons(fingerprint_session(session)))) + assert before["tables"]["platform_secret_versions"]["redacted_columns"] == ["secret_ciphertext", "secret_nonce"] + AuditWriter(session, "operator", "qualification").write("RECOVERY_RECONCILIATION_COMPLETED", "restore", None, {"restored": True}) + session.commit() + after = _without_creative(_without_model_updates(_without_selected_comparisons(fingerprint_session(session)))) + contract = fingerprint_compatibility(before, after, source_schema_revision=SECRET_SCHEMA) + assert contract.mode == "ALERT_NOTIFICATION_UPGRADE|PORTFOLIO_UPGRADE|SECRET_STRICT" + assert audit_fingerprint_differences_are_compatible(before, after, contract) + unrelated_marker = FingerprintCompatibility( + True, + f"UNRELATED|{contract.mode}", + contract.allow_audit_checkpoint_introduction, + contract.reason, + ) + assert not audit_fingerprint_differences_are_compatible(before, after, unrelated_marker) + for table in SECRET_TABLES: + altered = copy.deepcopy(after) + altered["tables"][table]["digest"] = "0" * 64 + assert not audit_fingerprint_differences_are_compatible(before, altered, contract) + engine.dispose() + + +def test_prior_schema_requires_exact_empty_secret_introduction() -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + after = _without_creative(_without_model_updates(_without_selected_comparisons(fingerprint_session(session)))) + before = _without_secrets(after) + contract = fingerprint_compatibility(before, after, source_schema_revision="20260908_0027") + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(before, after, contract) + for table in SECRET_TABLES: + altered = copy.deepcopy(after) + altered["tables"][table]["digest"] = "0" * 64 + assert not fingerprint_compatibility(before, altered, source_schema_revision="20260908_0027").compatible + engine.dispose() + + +def test_secret_migration_seed_empty_downgrade_and_history_refusal() -> None: + path = Path(__file__).resolve().parents[1] / "alembic/versions/20260908_0028_platform_secret_slots.py" + spec = importlib.util.spec_from_file_location("secret_migration", path) + assert spec and spec.loader + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + engine = create_engine("sqlite://") + with engine.begin() as connection, Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + assert connection.scalar(select(SecretSlotState.revision)) == 0 + migration.downgrade() + migration.upgrade() + connection.execute(SecretSlotVersion.__table__.insert().values(version=1, key_id="qualification", + secret_ciphertext="opaque", secret_nonce="nonce", created_at=datetime.now(UTC))) + with pytest.raises(RuntimeError, match="history exists"): + migration.downgrade() + engine.dispose() diff --git a/backend/tests/test_secret_slots_postgres.py b/backend/tests/test_secret_slots_postgres.py new file mode 100644 index 0000000..4bddcbc --- /dev/null +++ b/backend/tests/test_secret_slots_postgres.py @@ -0,0 +1,195 @@ +# ruff: noqa: S106 - isolated synthetic persistence fixtures +"""Managed PostgreSQL qualification for encrypted secret lifecycle and restore admission.""" +import base64 +import importlib +import os +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pydantic import SecretStr +from sqlalchemy import select, text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.orm import Session + +from modelforge_api.domain.release import TARGET_SCHEMA_REVISION +from modelforge_api.domain.secret_slots import SecretSlotSet, SecretSlotVersionRequest +from modelforge_api.persistence.models import SecretSlotState +from modelforge_api.services import secret_slots +from modelforge_api.services.recovery_fingerprint import ( + SECRET_TABLES, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from modelforge_api.settings import Settings +from tests.test_managed_identities_postgres import ( + EXTERNAL_ADMIN, + IdentityDatabase, + ManagedPostgresTools, + identity_database, +) + +__all__ = ["identity_database"] +PRIOR = "20260908_0027" +CURRENT = TARGET_SCHEMA_REVISION +REASON = "Isolated secret lifecycle qualification" + + +def _settings() -> Settings: + return Settings(_env_file=None, env="test", secret_slot_root_key=SecretStr(base64.b64encode(b"q" * 32).decode()), + secret_slot_root_key_id="qualification", alembic_directory=Path(__file__).resolve().parents[1] / "alembic") + + +def _service(session: Session) -> secret_slots.SecretSlotService: + return secret_slots.SecretSlotService(session, _settings(), EXTERNAL_ADMIN, "secret-pg-qualification") + + +def _set(session: Session, revision: int = 0): + return _service(session).set(SecretSlotSet(expected_revision=revision, reason=REASON, value=SecretStr("hf_qualification_only"))) + + +def test_secret_upgrade_dump_restore_and_immutable_ciphertext_grants(identity_database: IdentityDatabase, tmp_path: Path) -> None: + database = identity_database + alembic = importlib.import_module("alembic.command") + alembic.upgrade(database.migration(), PRIOR) + with database.runtime.connect() as connection: + source = semantic_fingerprint(connection) + archive = tmp_path / "pre-secret.dump" + database.archive(archive) + alembic.upgrade(database.migration(), "head") + with database.runtime.connect() as connection: + assert connection.scalar(text("SELECT version_num FROM alembic_version")) == CURRENT + restored = semantic_fingerprint(connection) + contract = fingerprint_compatibility(source, restored, source_schema_revision=PRIOR) + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + alembic.downgrade(database.migration(), PRIOR) + alembic.upgrade(database.migration(), "head") + with Session(database.runtime) as session: + _set(session) + with database.runtime.connect() as connection: + for sql in ( + "UPDATE platform_secret_versions SET secret_ciphertext='changed'", + "UPDATE platform_secret_versions SET secret_nonce='changed'", + "UPDATE platform_secret_versions SET key_id='changed'", + "UPDATE platform_secret_versions SET version=2", + "UPDATE platform_secret_versions SET created_at=now()", + "DELETE FROM platform_secret_versions", "TRUNCATE platform_secret_versions CASCADE", + "DELETE FROM platform_secret_state", "ALTER TABLE platform_secret_versions ADD COLUMN forbidden integer", + ): + with pytest.raises(DBAPIError): + connection.execute(text(sql)) + connection.rollback() + connection.execute(text("UPDATE platform_secret_versions SET revoked_at=now()")) + connection.commit() + with pytest.raises(RuntimeError, match="history exists"): + alembic.downgrade(database.migration(), PRIOR) + database.archive(archive, restore=True) + alembic.upgrade(database.migration(restored=True), "head") + with database.restored_runtime.connect() as connection: + restored = semantic_fingerprint(connection) + assert audit_fingerprint_differences_are_compatible(source, restored, fingerprint_compatibility(source, restored, source_schema_revision=PRIOR)) + + +def test_secret_concurrent_set_has_one_cas_winner(identity_database: IdentityDatabase) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + barrier = threading.Barrier(2) + def attempt() -> str: + with Session(database.runtime) as session: + barrier.wait(timeout=10) + try: + _set(session) + return "success" + except secret_slots.SecretSlotError as error: + return error.code + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: attempt(), range(2))) + assert results.count("success") == 1 + assert len(set(results)) == 2 + with database.runtime.connect() as connection: + assert connection.scalar(select(SecretSlotState.revision)) == 1 + assert connection.scalar(text("SELECT count(*) FROM platform_secret_versions")) == 1 + + +def test_supported_restore_clears_secret_test_and_activation_until_retest(identity_database: IdentityDatabase, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from modelforge_api.services.recovery import RecoveryError, RecoveryService + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + monkeypatch.setattr(secret_slots, "test_huggingface_token", lambda token: (True, "HF_AUTHENTICATED")) + with Session(database.runtime) as session: + _set(session) + _service(session).test(SecretSlotVersionRequest(version=1, expected_revision=1, reason=REASON)) + _service(session).activate(SecretSlotVersionRequest(version=1, expected_revision=2, reason=REASON)) + assert secret_slots.resolve_huggingface_token(session, _settings()) == "hf_qualification_only" + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "secret.dump" + database.archive(archive) + database.archive(archive, restore=True) + with Session(database.runtime) as session: + recovery = RecoveryService(session, _settings()) + recovery.engine = ManagedPostgresTools(base_environment=dict(os.environ), timeout_seconds=60) + record = SimpleNamespace(id=uuid.uuid4(), source_fingerprint=source) + plan = SimpleNamespace(database_destination=database.restored_owner.render_as_string(hide_password=False), + backup_set=SimpleNamespace(backup_id="secret-qualification", schema_revision=CURRENT, completed_at=datetime.now(UTC)), validation_requirements={}) + with pytest.raises(RecoveryError) as refused: + recovery._validate_restored(record, plan) + assert refused.value.details["gate"]["current_truth_reconciled"] is False + session.rollback() + result = recovery._reconcile_restored(record, plan) + session.commit() + for table in ("platform_secret_tests", "platform_secret_activations"): + assert result["cleared_current_truth"][table] == 1 + ready = recovery._validate_restored(record, plan) + assert ready["gate"]["current_truth_reconciled"] is True + session.commit() + with Session(database.restored_runtime) as session: + restored = semantic_fingerprint(session.connection()) + for table in SECRET_TABLES: + assert restored["tables"][table] == source["tables"][table] + with pytest.raises(secret_slots.SecretSlotError): + secret_slots.resolve_huggingface_token(session, _settings()) + session.rollback() + with pytest.raises(secret_slots.SecretSlotError) as refused_activation: + _service(session).activate(SecretSlotVersionRequest(version=1, expected_revision=3, reason=REASON)) + assert refused_activation.value.code == "SECRET_SLOT_TEST_REQUIRED" + _service(session).test(SecretSlotVersionRequest(version=1, expected_revision=3, reason=REASON)) + _service(session).activate(SecretSlotVersionRequest(version=1, expected_revision=4, reason=REASON)) + assert secret_slots.resolve_huggingface_token(session, _settings()) == "hf_qualification_only" + +def test_secret_network_result_cannot_bless_concurrently_replaced_candidate( + identity_database: IdentityDatabase, monkeypatch: pytest.MonkeyPatch, +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), "head") + with Session(database.runtime) as session: + _set(session) + entered, resume = threading.Event(), threading.Event() + def provider(_token: str) -> tuple[bool, str]: + entered.set() + assert resume.wait(timeout=20) + return True, "HF_AUTHENTICATED" + monkeypatch.setattr(secret_slots, "test_huggingface_token", provider) + def attempt() -> str: + with Session(database.runtime) as session: + try: + _service(session).test(SecretSlotVersionRequest(version=1, expected_revision=1, reason=REASON)) + return "success" + except secret_slots.SecretSlotError as error: + return error.code + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(attempt) + try: + assert entered.wait(timeout=20) + with Session(database.runtime) as session: + _set(session, 1) + finally: + resume.set() + assert future.result(timeout=20) != "success" + with database.runtime.connect() as connection: + assert connection.scalar(text("SELECT count(*) FROM platform_secret_tests WHERE passed")) == 0 + assert connection.scalar(select(SecretSlotState.revision)) == 2 diff --git a/backend/tests/test_security_gate_rc_metadata.py b/backend/tests/test_security_gate_rc_metadata.py new file mode 100644 index 0000000..68f37c9 --- /dev/null +++ b/backend/tests/test_security_gate_rc_metadata.py @@ -0,0 +1,32 @@ +"""Guard the release security gate's exact RC metadata and runbook URL rules.""" + +from __future__ import annotations + +import runpy +from pathlib import Path + +import pytest + +GATE = runpy.run_path(str(Path(__file__).resolve().parents[2] / "scripts" / "security_gate.py")) + + +def test_first_party_rc_wheel_version_matches_only_the_same_release() -> None: + matches = GATE["_matches_first_party_version"] + + assert matches("1.3.0-rc.1", "1.3.0-rc.1") + assert matches("1.3.0rc1", "1.3.0-rc.1") + assert not matches("1.3.0rc2", "1.3.0-rc.1") + assert not matches("1.3.1rc1", "1.3.0-rc.1") + assert not matches(None, "1.3.0-rc.1") + + +def test_web_bundle_allows_only_repository_runbook_context() -> None: + validate = GATE["_validate_non_api_origin_contexts"] + input_bytes = ( + b"https://git.example.com/example/modelforge/" + b"src/branch/master/docs/operations/RUNBOOK_NODE_OFFLINE.md" + ) + + validate(input_bytes) + with pytest.raises(GATE["GateInputError"]): + validate(b"https://git.example.com/api/v1/tokens") diff --git a/backend/tests/test_selected_comparison_api.py b/backend/tests/test_selected_comparison_api.py new file mode 100644 index 0000000..4c1aa52 --- /dev/null +++ b/backend/tests/test_selected_comparison_api.py @@ -0,0 +1,129 @@ +"""Exercise credential separation and secret-safe validation through real middleware.""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from modelforge_api.api.authorization import AccessBoundary, access_boundary_for_request +from modelforge_api.db import get_session +from modelforge_api.domain.serving import ServiceClientCreate +from modelforge_api.main import app +from tests.test_operator_authorization import ( + ADMIN, + ALLOWED_ORIGIN, + MachineBoundaryHarness, + machine_boundary_harness, +) +from tests.test_selected_comparison_postgres import _valid_selection + +__all__ = ["machine_boundary_harness"] +PREFIX = "/api/v1/selected-model-comparisons" + + +@pytest.mark.parametrize("outcome", ["failed", "cancelled"]) +def test_authenticated_operator_and_designated_consumer_journey( + machine_boundary_harness: MachineBoundaryHarness, outcome: str, +) -> None: + serving = machine_boundary_harness.capability_service + session = serving.session + session.execute(text("PRAGMA foreign_keys=ON")) + assert session.scalar(text("PRAGMA foreign_keys")) == 1 + request, _unused_client = _valid_selection(session) + issued = serving.create_client(ServiceClientCreate( + name="comparison-http-consumer", allowed_capabilities=["rag.embedding@1"], + project_key="selected-comparison-fixture", project_binding_id=request.project_binding_id, + integration_environment="production", purpose="Offline authenticated comparison qualification", + )) + request = request.model_copy(update={"consumer_client_id": issued.id}) + app.dependency_overrides[get_session] = lambda: session + operator = {**ADMIN, "Origin": ALLOWED_ORIGIN} + consumer = {"Authorization": f"Bearer {issued.credential}", "Origin": ALLOWED_ORIGIN} + client = TestClient(app) + created = client.post(PREFIX, headers=operator, json=request.model_dump(mode="json")) + assert created.status_code == 201, created.text + comparison_id = created.json()["id"] + path = f"{PREFIX}/{comparison_id}" + assert created.json()["status"] == "pending" + assigned = client.get(f"{PREFIX}/assigned", headers=consumer) + assert assigned.status_code == 200, assigned.text + assert [item["id"] for item in assigned.json()["items"]] == [comparison_id] + claimed = client.post(f"{path}/claim", headers=consumer) + assert claimed.status_code == 200, claimed.text + assert claimed.headers["Cache-Control"] == "no-store" + assert claimed.json()["comparison"]["status"] == "running" + assert len(claimed.json()["cases"]) == 1 + token = claimed.json()["claim_token"] + progress = client.post(f"{path}/progress", headers=consumer, json={ + "claim_token": token, "current_completed_cases": 1, "candidate_completed_cases": 0, + }) + assert progress.status_code == 200, progress.text + assert progress.json()["current_completed_cases"] == 1 + if outcome == "cancelled": + cancelled = client.post(f"{path}/cancel", headers=operator, + json={"expected_version": progress.json()["version"]}) + assert cancelled.status_code == 200, cancelled.text + assert cancelled.json()["status"] == "cancel_requested" + finished = client.post(f"{path}/finish", headers=consumer, json={ + "claim_token": token, "outcome": outcome, + "error_code": "FIXTURE_CONSUMER_FAILURE" if outcome == "failed" else None, + }) + assert finished.status_code == 200, finished.text + assert finished.json()["status"] == outcome + assert finished.json()["production_promotion_performed"] is False + assert finished.json()["finished_at"] is not None + history = client.get(PREFIX, headers=operator) + assert history.status_code == 200 + assert history.json()["items"][0]["status"] == outcome + detail = client.get(path, headers=operator) + assert detail.status_code == 200 + assert detail.json()["consumer_client_id"] == str(issued.id) + assert token not in history.text and token not in detail.text + assert issued.credential not in history.text + assert client.get(f"{PREFIX}/assigned", headers=consumer).json()["items"] == [] + assert session.execute(text("PRAGMA foreign_key_check")).all() == [] + + +@pytest.mark.parametrize("action", ["claim", "progress", "result", "finish"]) +def test_operator_key_cannot_become_comparison_consumer( + machine_boundary_harness: MachineBoundaryHarness, action: str, +) -> None: + response = TestClient(app).post(f"{PREFIX}/{uuid4()}/{action}", headers={ + **ADMIN, "Origin": ALLOWED_ORIGIN, + }, json={"claim_token": "private-test-claim-that-must-not-echo"}) + assert response.status_code == 401 + assert "private-test-claim" not in response.text + + +@pytest.mark.parametrize("action", ["progress", "result", "finish"]) +def test_consumer_validation_does_not_echo_secret_or_extra_values( + machine_boundary_harness: MachineBoundaryHarness, action: str, +) -> None: + canary = "private-claim-and-payload-canary-123456789" + response = TestClient(app).post(f"{PREFIX}/{uuid4()}/{action}", headers={ + "Authorization": f"Bearer {machine_boundary_harness.capability_credential}", + "Origin": ALLOWED_ORIGIN, + }, json={"claim_token": canary, "unexpected": {canary: canary}}) + assert response.status_code == 422 + assert canary not in response.text + errors = response.json()["error"]["details"]["errors"] + assert errors and all(set(error) == {"type", "msg"} for error in errors) + + +@pytest.mark.parametrize("path", [PREFIX, f"{PREFIX}/{uuid4()}/cancel"]) +def test_project_credential_cannot_plan_or_cancel_operator_comparison( + machine_boundary_harness: MachineBoundaryHarness, path: str, +) -> None: + response = TestClient(app).post(path, headers={ + "Authorization": f"Bearer {machine_boundary_harness.capability_credential}", + "Origin": ALLOWED_ORIGIN, + }, json={}) + assert response.status_code == 401 + + +def test_only_exact_assigned_read_is_a_consumer_route() -> None: + assert access_boundary_for_request("GET", f"{PREFIX}/assigned") is AccessBoundary.CAPABILITY_CLIENT + assert access_boundary_for_request("GET", PREFIX) is AccessBoundary.CONTROL_PLANE + assert access_boundary_for_request("GET", f"{PREFIX}/{uuid4()}") is AccessBoundary.CONTROL_PLANE + assert access_boundary_for_request("POST", f"{PREFIX}/{uuid4()}/cancel") is AccessBoundary.CONTROL_PLANE diff --git a/backend/tests/test_selected_comparison_postgres.py b/backend/tests/test_selected_comparison_postgres.py new file mode 100644 index 0000000..c55ec6b --- /dev/null +++ b/backend/tests/test_selected_comparison_postgres.py @@ -0,0 +1,261 @@ +"""Owner/runtime PostgreSQL qualification using a complete offline foreign-key graph.""" +from __future__ import annotations + +import importlib +import os +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import create_engine, event, inspect, select, text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.orm import Session + +from modelforge_api.domain.evaluation import ( + EvaluationCaseCreate, + EvaluationRevisionCreate, + EvaluationSuiteCreate, +) +from modelforge_api.domain.selected_model_comparison import ComparisonCreate +from modelforge_api.domain.serving import CapabilityExperimentCreate +from modelforge_api.persistence.models import ( + AuditEvent, + Base, + CapabilityDeployment, + Project, + ProjectBinding, + SelectedModelComparison, + ServiceClient, +) +from modelforge_api.services.audit import AuditContext, AuditWriter +from modelforge_api.services.evaluation import EvaluationError, EvaluationService +from modelforge_api.services.recovery import RecoveryError, RecoveryService +from modelforge_api.services.recovery_fingerprint import ( + SELECTED_COMPARISON_RESTORE_ERROR, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, + semantic_fingerprint, +) +from modelforge_api.services.selected_model_comparison import ( + SelectedModelComparisonService, + _digest, +) +from modelforge_api.settings import Settings +from tests.test_managed_identities_postgres import ( + IdentityDatabase, + ManagedPostgresTools, + identity_database, +) +from tests.test_serving import approve_and_promote, setup_m5 + +__all__ = ["identity_database"] +PRIOR = "20260909_0032" +CURRENT = "20260911_0035" + + +def _service(session: Session) -> SelectedModelComparisonService: + return SelectedModelComparisonService(session, AuditContext("operator", "comparison-pg-fixture")) + + +def _valid_selection(session: Session) -> tuple[ComparisonCreate, UUID]: + # These helpers persist explicit offline runtime evidence; they never contact a provider or GPU. + serving, candidate, _node = setup_m5(session) + _approval, stable = approve_and_promote(serving, candidate.id) + probe = serving.repo.probe(candidate.runtime_probe_id) + assert probe is not None + experiment = serving.create_experiment(candidate.id, CapabilityExperimentCreate( + route_key="selected-comparison-fixture", execution_approval_id=probe.execution_approval_id, + purpose="Isolated PostgreSQL graph qualification; synthetic evidence only", + )) + stable_row = session.get(CapabilityDeployment, stable.id) + assert stable_row is not None + project = Project(key="selected-comparison-fixture", name="Comparison qualification", description="Offline fixture") + session.add(project) + session.flush() + binding = ProjectBinding(project_id=project.id, capability_contract_id=stable_row.capability_contract_id, + channel="stable", priority="production") + session.add(binding) + session.flush() + client = ServiceClient(name="selected-comparison-fixture", project_binding_id=binding.id, + allowed_capabilities=["rag.embedding@1"]) + session.add(client) + session.commit() + suite = EvaluationService(session).create_suite(EvaluationSuiteCreate( + project_id=project.id, key="selected-comparison-fixture", name="Reviewed fixture", + description="Synthetic owner-reviewed case; no consumer certification claim", + revision=EvaluationRevisionCreate(revision="v1", dataset_revision="fixture-v1", + retrieval_settings={"top_k": 10}, cases=[EvaluationCaseCreate( + case_key="fixture-question", query="Where is the fixture answer?", relevant_chunk_ids=[uuid4()], + label_provenance={"source": "owner_review", "corpus_revision": "a" * 64}, + critical=True, review_status="reviewed", + )]), + )) + assert suite.latest_revision_id is not None + return ComparisonCreate(project_binding_id=binding.id, consumer_client_id=client.id, + suite_revision_id=suite.latest_revision_id, current_deployment_id=stable.id, + candidate_deployment_id=experiment.capability_deployment_id, candidate_route_id=experiment.id, + corpus_sha256="a" * 64, retrieval_config_sha256=_digest({"top_k": 10}), + current_index_ref="fixture-current-exact", candidate_index_ref="fixture-candidate-exact", + idempotency_key="fixture-one"), client.id + + +def test_offline_selection_fixture_has_complete_foreign_keys() -> None: + engine = create_engine("sqlite://") + @event.listens_for(engine, "connect") + def foreign_keys(connection, _record): + connection.execute("PRAGMA foreign_keys=ON") + try: + Base.metadata.create_all(engine) + with Session(engine) as session: + assert session.scalar(text("PRAGMA foreign_keys")) == 1 + request, client_id = _valid_selection(session) + created = _service(session).create(request) + client = session.get(ServiceClient, client_id) + assert client is not None + assert _service(session).claim(created.id, client).comparison.status == "running" + assert session.execute(text("PRAGMA foreign_key_check")).all() == [] + finally: + engine.dispose() + + +def test_0033_upgrade_prior_dump_restore_and_runtime_permissions( + identity_database: IdentityDatabase, tmp_path: Path, +) -> None: + database = identity_database + alembic = importlib.import_module("alembic.command") + alembic.upgrade(database.migration(), PRIOR) + with Session(database.runtime) as session: + assert not inspect(session.connection()).has_table("selected_model_comparisons") + AuditWriter(session, "operator", "prior-release").write("qualification.baseline", "comparison", None, {}) + session.commit() + before = semantic_fingerprint(session.connection()) + archive = tmp_path / "prior-comparisons.dump" + database.archive(archive) + alembic.upgrade(database.migration(), CURRENT) + with database.runtime.connect() as connection: + assert inspect(connection).has_table("selected_model_comparisons") + assert connection.scalar(text("SELECT count(*) FROM selected_model_comparisons")) == 0 + after = semantic_fingerprint(connection) + contract = fingerprint_compatibility(before, after, source_schema_revision=PRIOR) + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(before, after, contract) + alembic.downgrade(database.migration(), PRIOR) + alembic.upgrade(database.migration(), CURRENT) + with Session(database.runtime) as session: + request, _client_id = _valid_selection(session) + _service(session).create(request) + constraints = inspect(session.connection()).get_foreign_keys("selected_model_comparisons") + assert {item["constrained_columns"][0] for item in constraints} >= { + "project_binding_id", "consumer_client_id", "suite_revision_id", "current_deployment_id", + "candidate_deployment_id", "candidate_route_id", + } + assert session.scalar(text("SELECT count(*) FROM pg_constraint WHERE conrelid='selected_model_comparisons'::regclass AND contype='f' AND NOT convalidated")) == 0 + with database.runtime.connect() as connection: + for statement in ( + "DELETE FROM selected_model_comparisons", + "TRUNCATE selected_model_comparisons CASCADE", + "ALTER TABLE selected_model_comparisons ADD COLUMN forbidden integer", + ): + with pytest.raises(DBAPIError): + connection.execute(text(statement)) + connection.rollback() + with pytest.raises(RuntimeError, match="history exists"): + alembic.downgrade(database.migration(), PRIOR) + database.archive(archive, restore=True) + alembic.upgrade(database.migration(restored=True), CURRENT) + with database.restored_runtime.connect() as connection: + restored = semantic_fingerprint(connection) + contract = fingerprint_compatibility(before, restored, source_schema_revision=PRIOR) + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(before, restored, contract) + + +def test_two_postgres_sessions_claim_exactly_once_with_one_audit_event(identity_database: IdentityDatabase) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), CURRENT) + with Session(database.runtime) as session: + request, client_id = _valid_selection(session) + comparison_id = _service(session).create(request).id + barrier = threading.Barrier(2) + def contender(_number: int) -> str: + with Session(database.runtime) as session: + client = session.get(ServiceClient, client_id) + assert client is not None + session.commit() + barrier.wait(timeout=30) + try: + claimed = _service(session).claim(comparison_id, client) + assert claimed.comparison.status == "running" + return "claimed" + except EvaluationError as error: + assert error.status_code == 409 + return "conflict" + with ThreadPoolExecutor(max_workers=2) as pool: + assert sorted(pool.map(contender, [0, 1])) == ["claimed", "conflict"] + with Session(database.runtime) as session: + row = session.get(SelectedModelComparison, comparison_id) + assert row is not None and row.status == "running" and row.version == 2 + assert row.claim_token_hash and row.claim_expires_at + events = list(session.scalars(select(AuditEvent).where(AuditEvent.action == "SELECTED_MODEL_COMPARISON_CLAIMED"))) + assert len(events) == 1 + + +def test_populated_postgres_restore_interrupts_claim_and_rejects_resurrection( + identity_database: IdentityDatabase, tmp_path: Path, +) -> None: + database = identity_database + importlib.import_module("alembic.command").upgrade(database.migration(), CURRENT) + with Session(database.runtime) as session: + request, client_id = _valid_selection(session) + comparison_id = _service(session).create(request).id + client = session.get(ServiceClient, client_id) + assert client is not None + claimed = _service(session).claim(comparison_id, client) + source = semantic_fingerprint(session.connection()) + archive = tmp_path / "active-comparison.dump" + database.archive(archive) + database.archive(archive, restore=True) + # The real restore migrates the destination before reconciliation. Keep the + # backup at the selected-comparison revision, then exercise recovery at head. + importlib.import_module("alembic.command").upgrade( + database.migration(restored=True), "head" + ) + owner = database.restored_owner + with Session(database.runtime) as session: + recovery = RecoveryService(session, Settings(_env_file=None, env="test", + alembic_directory=Path(__file__).resolve().parents[1] / "alembic")) + recovery.engine = ManagedPostgresTools(base_environment=dict(os.environ), timeout_seconds=60) + record = SimpleNamespace(id=uuid4(), source_fingerprint=source) + plan = SimpleNamespace(database_destination=owner.render_as_string(hide_password=False), + backup_set=SimpleNamespace(backup_id="selected-comparison-qualification", schema_revision=CURRENT, + completed_at=datetime.now(UTC)), validation_requirements={}) + with pytest.raises(RecoveryError) as unready: + recovery._validate_restored(record, plan) + assert "gate" in unready.value.details, (unready.value.code, str(unready.value), unready.value.details) + assert unready.value.details["gate"]["current_truth_reconciled"] is False + session.rollback() + result = recovery._reconcile_restored(record, plan) + session.commit() + assert result["interrupted_selected_comparisons"] == 1 + validated = recovery._validate_restored(record, plan) + assert all(validated["gate"].values()) + session.commit() + assert recovery._reconcile_restored(record, plan)["reconciliation"] == "ALREADY_APPLIED" + with Session(database.restored_runtime) as session: + row = session.get(SelectedModelComparison, comparison_id) + assert row is not None and row.status == "interrupted" + assert row.version == claimed.comparison.version + 1 + assert row.error_code == SELECTED_COMPARISON_RESTORE_ERROR + assert row.claim_token_hash is None and row.claim_expires_at is None + assert row.finished_at is None + restored = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(source, restored, source_schema_revision=CURRENT) + assert audit_fingerprint_differences_are_compatible(source, restored, contract) + client = session.get(ServiceClient, client_id) + assert client is not None + with pytest.raises(EvaluationError): + _service(session).claim(comparison_id, client) diff --git a/backend/tests/test_selected_comparison_recovery.py b/backend/tests/test_selected_comparison_recovery.py new file mode 100644 index 0000000..280a1ea --- /dev/null +++ b/backend/tests/test_selected_comparison_recovery.py @@ -0,0 +1,170 @@ +"""Comparison history remains exact while restored claims become unusable.""" +from __future__ import annotations + +import copy +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import Base, SelectedModelComparison +from modelforge_api.services.audit import AuditWriter +from modelforge_api.services.recovery import RecoveryService +from modelforge_api.services.recovery_fingerprint import ( + SELECTED_COMPARISON_RESTORE_ERROR, + SELECTED_COMPARISON_SCHEMA, + _without_lab_execution, + _without_model_changes, + _without_portfolio, + _without_selected_comparisons, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, +) +from modelforge_api.services.recovery_fingerprint import ( + semantic_fingerprint as _current_semantic_fingerprint, +) +from modelforge_api.services.recovery_postgres import PostgresTarget +from modelforge_api.settings import Settings + +TARGET = PostgresTarget("localhost", 5432, "fixture", "fixture", None) + + +def semantic_fingerprint(connection): + return _without_lab_execution( + _without_portfolio(_without_model_changes(_current_semantic_fingerprint(connection))) + ) + + +class SQLiteRecoveryTarget: + """Execute the actual bounded reconciliation SQL; emulate only PostgreSQL table discovery.""" + def __init__(self, session: Session): + self.session = session + + def scalar(self, _target: PostgresTarget, sql: str) -> str: + if "information_schema.tables" in sql: + return "1" + result = self.session.execute(text(sql)) + return str(result.scalar() or 0) if result.returns_rows else "" + + +@pytest.fixture +def fixture(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + AuditWriter(session, "operator", "test").write("fixture.created", "test", None, {}) + session.commit() + service = RecoveryService(session, Settings(_env_file=None)) + service.engine = SQLiteRecoveryTarget(session) # type: ignore[assignment] + yield session, service + engine.dispose() + + +def comparison(session: Session, status: str) -> SelectedModelComparison: + moment = datetime(2026, 9, 9, 12, tzinfo=UTC) + item = SelectedModelComparison( + project_binding_id=uuid4(), consumer_client_id=uuid4(), suite_revision_id=uuid4(), + current_deployment_id=uuid4(), candidate_deployment_id=uuid4(), candidate_route_id=uuid4(), + idempotency_key=str(uuid4()), request_digest="a" * 64, actor_id="fixture-operator", + pins={"baseline": "a" * 40, "candidate": "b" * 40, "case_ids": ["one", "two"]}, + status=status, version=4, expected_cases=2, current_completed_cases=1, + candidate_completed_cases=0, claim_token_hash="c" * 64, + claim_expires_at=moment + timedelta(minutes=5), claimed_at=moment, + deadline_at=moment + timedelta(minutes=10), cancel_requested_at=moment if status == "cancel_requested" else None, + finished_at=moment if status in {"completed", "failed", "cancelled", "interrupted"} else None, + baseline_run_id=uuid4(), candidate_run_id=uuid4(), evaluation_comparison_id=uuid4(), + result_digest="d" * 64, error_code="prior-evidence", created_at=moment, updated_at=moment, + ) + session.add(item) + session.commit() + return item + + +@pytest.mark.parametrize("status", ["pending", "running", "cancel_requested", "completed", "failed", "cancelled", "interrupted"]) +def test_restore_projects_only_active_state_and_never_replays_claims(fixture, status): + session, service = fixture + item = comparison(session, status) + before = semantic_fingerprint(session.connection()) + previous = {name: getattr(item, name) for name in ( + "pins", "current_completed_cases", "candidate_completed_cases", "baseline_run_id", + "candidate_run_id", "evaluation_comparison_id", "result_digest", "created_at", "updated_at", + "claimed_at", "finished_at", "deadline_at", "cancel_requested_at", + )} + active = status in {"pending", "running", "cancel_requested"} + assert service._selected_comparison_residuals(TARGET) == 1 + assert service._reconcile_selected_comparisons(TARGET) == {"interrupted": int(active), "claims_cleared": 1} + session.expire_all() + assert item.status == ("interrupted" if active else status) + assert item.version == (5 if active else 4) + assert item.error_code == (SELECTED_COMPARISON_RESTORE_ERROR if active else "prior-evidence") + assert item.claim_token_hash is None and item.claim_expires_at is None + assert {name: getattr(item, name) for name in previous} == previous + restored = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(before, restored, source_schema_revision=SELECTED_COMPARISON_SCHEMA) + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(before, restored, contract) + assert service._selected_comparison_residuals(TARGET) == 0 + assert service._reconcile_selected_comparisons(TARGET) == {"interrupted": 0, "claims_cleared": 0} + assert semantic_fingerprint(session.connection()) == restored + + +def test_active_rows_cannot_pass_restore_proof_without_reconciliation(fixture): + session, _ = fixture + comparison(session, "running") + before = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(before, before, source_schema_revision=SELECTED_COMPARISON_SCHEMA) + assert contract.compatible + assert not audit_fingerprint_differences_are_compatible(before, before, contract) + + +def test_claim_secrets_and_expiry_are_excluded_but_claim_history_is_not(fixture): + session, _ = fixture + item = comparison(session, "running") + before = semantic_fingerprint(session.connection()) + entry = before["tables"]["selected_model_comparisons"] + assert entry["redacted_columns"] == ["claim_token_hash"] + session.execute(text("update selected_model_comparisons set claim_token_hash=:token,claim_expires_at=NULL"), {"token": "e" * 64}) + assert semantic_fingerprint(session.connection()) == before + session.execute(text("update selected_model_comparisons set claimed_at=NULL")) + assert semantic_fingerprint(session.connection())["tables"]["selected_model_comparisons"]["digest"] != entry["digest"] + assert item.id is not None + + +@pytest.mark.parametrize("mutation", ["pins", "result", "delete", "terminal_status", "progress"]) +def test_restore_rejects_changed_pins_results_missing_history_or_progress(fixture, mutation): + session, service = fixture + comparison(session, "completed") + before = semantic_fingerprint(session.connection()) + service._reconcile_selected_comparisons(TARGET) + queries = { + "pins": "update selected_model_comparisons set pins='{}'", + "result": "update selected_model_comparisons set result_digest=NULL", + "delete": "delete from selected_model_comparisons", + "terminal_status": "update selected_model_comparisons set status='failed'", + "progress": "update selected_model_comparisons set current_completed_cases=0", + } + session.execute(text(queries[mutation])) + after = semantic_fingerprint(session.connection()) + contract = fingerprint_compatibility(before, after, source_schema_revision=SELECTED_COMPARISON_SCHEMA) + assert not audit_fingerprint_differences_are_compatible(before, after, contract) + + +def test_0032_upgrade_allows_only_empty_comparison_table_and_requires_current_history(fixture): + session, _ = fixture + empty = semantic_fingerprint(session.connection()) + prior = _without_selected_comparisons(empty) + contract = fingerprint_compatibility(prior, empty, source_schema_revision="20260909_0032") + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(prior, empty, contract) + comparison(session, "completed") + populated = semantic_fingerprint(session.connection()) + assert not fingerprint_compatibility(prior, populated, source_schema_revision="20260909_0032").compatible + missing = copy.deepcopy(populated) + missing["tables"].pop("selected_model_comparisons") + assert not fingerprint_compatibility(populated, missing, source_schema_revision=SELECTED_COMPARISON_SCHEMA).compatible + missing_projection = copy.deepcopy(populated) + missing_projection["tables"]["selected_model_comparisons"].pop("restore_projection") + contract = fingerprint_compatibility(populated, missing_projection, source_schema_revision=SELECTED_COMPARISON_SCHEMA) + assert not audit_fingerprint_differences_are_compatible(populated, missing_projection, contract) diff --git a/backend/tests/test_selected_model_comparison.py b/backend/tests/test_selected_model_comparison.py new file mode 100644 index 0000000..730e40e --- /dev/null +++ b/backend/tests/test_selected_model_comparison.py @@ -0,0 +1,739 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, func, select, update +from sqlalchemy.orm import Session + +from modelforge_api.domain.evaluation import ( + EvaluationCaseCreate, + EvaluationCaseResultCreate, + EvaluationRevisionCreate, + EvaluationRunComplete, + EvaluationSuiteCreate, + RankedResult, +) +from modelforge_api.domain.selected_model_comparison import ( + ComparisonCancel, + ComparisonCreate, + ComparisonFinish, + ComparisonProgress, + ComparisonResult, +) +from modelforge_api.persistence.models import ( + ArtifactSet, + AuditEvent, + Base, + Capability, + CapabilityContract, + CapabilityDeployment, + CapabilityExperimentRoute, + DeploymentCandidate, + EmbeddingSpace, + EvaluationCase, + EvaluationComparison, + EvaluationRun, + GatewayRequest, + Model, + ModelRevision, + Project, + ProjectBinding, + RuntimeProfile, + SelectedModelComparison, + ServiceClient, +) +from modelforge_api.services.audit import AuditContext +from modelforge_api.services.evaluation import EvaluationError, EvaluationService +from modelforge_api.services.selected_model_comparison import ( + SelectedModelComparisonService, + _digest, +) + + +@pytest.fixture +def setup(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + + def add(value): + session.add(value) + session.flush() + return value + + project = add(Project(key="owner", name="Owner", description="fixture")) + capability = add(Capability(key="rag.embedding", description="Embeddings")) + contract = add( + CapabilityContract( + capability_id=capability.id, + version=1, + input_schema={}, + output_schema={}, + contract={}, + upgrade_class="requires_reindex", + ) + ) + binding = add( + ProjectBinding( + project_id=project.id, + capability_contract_id=contract.id, + channel="stable", + priority="production", + ) + ) + client = add( + ServiceClient( + name="designated", + allowed_capabilities=["rag.embedding@1"], + project_binding_id=binding.id, + ) + ) + model = add( + Model( + key="chosen", + display_name="Chosen", + upstream_provider="huggingface", + upstream_source="org/chosen", + ) + ) + deployments = [] + for index, variant in enumerate(("stable", "experiment", "experiment-current")): + channel = "stable" if index == 0 else "experiment" + revision = add( + ModelRevision( + model_id=model.id, + upstream_revision=variant, + resolved_commit_sha=str(index + 1) * 40, + ) + ) + artifact = add( + ArtifactSet( + revision_id=revision.id, + snapshot_id=uuid4(), + variant_key=variant, + label=variant, + selection_reason="fixture", + immutable_at=datetime.now(UTC), + ) + ) + profile = add( + RuntimeProfile( + name=variant, + artifact_set_id=artifact.id, + runtime_type="sentence_transformers", + runtime_version="fixture", + artifact_sha256=str(index + 1) * 64, + fingerprint=str(index + 3) * 64, + immutable_at=datetime.now(UTC), + ) + ) + candidate = add( + DeploymentCandidate( + artifact_set_id=artifact.id, + runtime_profile_id=profile.id, + compute_node_id=uuid4(), + compatibility_assessment_id=uuid4(), + runtime_probe_id=uuid4(), + ) + ) + space = add( + EmbeddingSpace( + capability_contract_id=contract.id, + artifact_set_id=artifact.id, + runtime_profile_id=profile.id, + identity_digest=str(index + 5) * 64, + dimension=4, + normalized=True, + identity_facts={}, + ) + ) + deployments.append( + add( + CapabilityDeployment( + capability_contract_id=contract.id, + deployment_candidate_id=candidate.id, + embedding_space_id=space.id, + artifact_set_id=artifact.id, + runtime_profile_id=profile.id, + compute_node_id=candidate.compute_node_id, + accelerator_id=uuid4(), + channel=channel, + status="stable" if index == 0 else "candidate", + production=index == 0, + config_fingerprint=str(index + 7) * 64, + provenance={}, + ) + ) + ) + route = add( + CapabilityExperimentRoute( + route_key="selected-candidate", + capability_contract_id=contract.id, + capability_deployment_id=deployments[1].id, + purpose="fixture", + evidence={}, + ) + ) + add( + CapabilityExperimentRoute( + route_key="selected-current-experiment", + capability_contract_id=contract.id, + capability_deployment_id=deployments[2].id, + purpose="fixture current experiment", + evidence={}, + ) + ) + session.commit() + relevant = uuid4() + suite = EvaluationService(session).create_suite( + EvaluationSuiteCreate( + project_id=project.id, + key="reviewed", + name="Reviewed", + description="fixture", + revision=EvaluationRevisionCreate( + revision="v1", + dataset_revision="fixture-v1", + retrieval_settings={"top_k": 10}, + cases=[ + EvaluationCaseCreate( + case_key="one", + query="Where is the answer?", + relevant_chunk_ids=[relevant], + label_provenance={ + "source": "owner_review", + "corpus_revision": "a" * 64, + }, + critical=True, + review_status="reviewed", + ) + ], + ), + ) + ) + service = SelectedModelComparisonService(session, AuditContext("operator", "fixture")) + request = ComparisonCreate( + project_binding_id=binding.id, + consumer_client_id=client.id, + suite_revision_id=suite.latest_revision_id, + current_deployment_id=deployments[0].id, + candidate_deployment_id=deployments[1].id, + candidate_route_id=route.id, + corpus_sha256="a" * 64, + retrieval_config_sha256=_digest({"top_k": 10}), + current_index_ref="current-exact", + candidate_index_ref="candidate-exact", + idempotency_key="one", + ) + yield session, service, request, client, relevant + engine.dispose() + + +def _claimed(setup, selected_request=None): + session, service, request, client, relevant = setup + if selected_request is not None: + request = selected_request + created = service.create(request) + claim = service.claim(created.id, client) + case = session.scalar( + select(EvaluationCase).where(EvaluationCase.suite_revision_id == request.suite_revision_id) + ) + gateways = [] + for deployment_id in (request.current_deployment_id, request.candidate_deployment_id): + gateway = GatewayRequest( + request_id=uuid4(), + service_client_id=client.id, + capability_key="rag.embedding", + capability_version=1, + capability_deployment_id=deployment_id, + status="completed", + priority="production", + input_sha256=_digest([case.query]), + input_count=1, + created_at=datetime.now(UTC), + finished_at=datetime.now(UTC), + decision_evidence={}, + ) + session.add(gateway) + gateways.append(gateway) + session.commit() + leg = EvaluationRunComplete( + results=[ + EvaluationCaseResultCreate( + case_id=case.id, + ranked_results=[RankedResult(chunk_id=relevant, score=1)], + latency_ms=2, + ) + ] + ) + result = ComparisonResult( + claim_token=claim.claim_token, + pins_digest=claim.comparison.pins_digest, + consumer_revision_sha256="c" * 64, + current=leg, + candidate=leg, + current_gateway_requests={case.id: gateways[0].request_id}, + candidate_gateway_requests={case.id: gateways[1].request_id}, + ) + return claim, result, gateways + + +def test_real_pair_recomputes_metrics_and_retries_without_duplicate_evidence(setup): + session, service, request, client, _ = setup + claim, result, _ = _claimed(setup) + assert ( + claim.comparison.pins["current"]["dimension"] + == claim.comparison.pins["candidate"]["dimension"] + ) + assert claim.comparison.pins["current"]["dimension"] > 0 + completed = service.result(claim.comparison.id, result, client) + assert completed.status == "completed" + comparison = session.get(EvaluationComparison, completed.evaluation_comparison_id) + assert comparison.comparability == "comparable" + assert comparison.promotion_eligibility == "eligible" + assert completed.evaluation_summary.eligibility == "eligible" + assert completed.evaluation_summary.critical_regressions == 0 + assert comparison.eligibility_evidence["promotion_performed"] is False + assert service.result(completed.id, result, client).id == completed.id + assert session.scalar(select(func.count()).select_from(EvaluationRun)) == 2 + assert session.scalar(select(func.count()).select_from(EvaluationComparison)) == 1 + assert session.get(CapabilityDeployment, request.candidate_deployment_id).production is False + history = service.get(completed.id).model_dump_json() + assert claim.claim_token not in history and "claim_token_hash" not in history + assert all( + claim.claim_token not in str(event.details) for event in session.scalars(select(AuditEvent)) + ) + + +@pytest.mark.parametrize( + "field,value", [("corpus_sha256", "d" * 64), ("current_index_ref", "candidate-exact")] +) +def test_rejects_mismatched_corpus_and_shared_vector_index(setup, field, value): + _, service, request, _, _ = setup + with pytest.raises(EvaluationError): + service.create(request.model_copy(update={field: value})) + + +def test_idempotency_active_bound_and_exact_consumer(setup): + session, service, request, client, _ = setup + created = service.create(request) + legacy_digest = _digest(request.model_dump(mode="json", exclude={"current_route_id"})) + assert session.get(SelectedModelComparison, created.id).request_digest == legacy_digest + assert service.create(request).id == created.id + with pytest.raises(EvaluationError): + service.create(request.model_copy(update={"candidate_index_ref": "different"})) + with pytest.raises(EvaluationError): + service.create(request.model_copy(update={"idempotency_key": "second"})) + stranger = ServiceClient( + name="other", + project_binding_id=client.project_binding_id, + allowed_capabilities=["rag.embedding@1"], + ) + session.add(stranger) + session.commit() + assert service.assigned(stranger).items == [] + with pytest.raises(EvaluationError): + service.claim(created.id, stranger) + assert service.assigned(client).items[0].id == created.id + + +def test_selected_experiment_pair_requires_both_exact_routes_and_is_idempotent(setup): + session, service, request, client, _ = setup + current_route = session.scalar( + select(CapabilityExperimentRoute).where( + CapabilityExperimentRoute.route_key == "selected-current-experiment" + ) + ) + selected = request.model_copy( + update={ + "current_deployment_id": current_route.capability_deployment_id, + "current_route_id": current_route.id, + "idempotency_key": "two-experiments", + } + ) + + created = service.create(selected) + assert service.create(selected).id == created.id + with pytest.raises(EvaluationError, match="comparison idempotency conflict"): + service.create(selected.model_copy(update={"current_route_id": request.candidate_route_id})) + assert created.pins["current_route_id"] == str(current_route.id) + assert created.pins["current_route_key"] == current_route.route_key + assert created.pins["candidate_route_id"] == str(request.candidate_route_id) + identity_pins = { + "config_fingerprint", + "artifact_set_id", + "revision_id", + "upstream_commit_sha", + "runtime_profile_id", + "runtime_fingerprint", + "artifact_sha256", + "embedding_space_id", + "embedding_space_digest", + "dimension", + "compute_node_id", + "accelerator_id", + } + assert identity_pins <= set(created.pins["current"]) + assert identity_pins <= set(created.pins["candidate"]) + assert ( + created.pins["current"]["embedding_space_id"] + != created.pins["candidate"]["embedding_space_id"] + ) + claim, result, _ = _claimed(setup, selected) + completed = service.result(created.id, result, client) + assert claim.comparison.id == created.id + assert completed.status == "completed" + assert completed.pins["current_route_id"] == str(current_route.id) + current_route.status = "inactive" + session.commit() + assert service.result(created.id, result, client).id == completed.id + + +@pytest.mark.parametrize( + "mutation", + ["production_current", "wrong_current_route", "inactive_current_route", "same_route"], +) +def test_selected_experiment_pair_rejects_non_experiment_or_mismatched_current_route( + setup, mutation +): + session, service, request, _, _ = setup + current_route = session.scalar( + select(CapabilityExperimentRoute).where( + CapabilityExperimentRoute.route_key == "selected-current-experiment" + ) + ) + selected = request.model_copy( + update={ + "current_deployment_id": current_route.capability_deployment_id, + "current_route_id": current_route.id, + "idempotency_key": f"invalid-{mutation}", + } + ) + if mutation == "production_current": + selected = selected.model_copy( + update={"current_deployment_id": request.current_deployment_id} + ) + elif mutation == "wrong_current_route": + selected = selected.model_copy(update={"current_route_id": request.candidate_route_id}) + elif mutation == "inactive_current_route": + current_route.status = "inactive" + session.commit() + else: + selected = selected.model_copy(update={"candidate_route_id": current_route.id}) + + with pytest.raises(EvaluationError): + service.create(selected) + assert session.scalar(select(func.count()).select_from(SelectedModelComparison)) == 0 + + +def test_current_experiment_route_key_is_revalidated_before_claim_and_result(setup): + session, service, request, client, _ = setup + current_route = session.scalar( + select(CapabilityExperimentRoute).where( + CapabilityExperimentRoute.route_key == "selected-current-experiment" + ) + ) + selected = request.model_copy( + update={ + "current_deployment_id": current_route.capability_deployment_id, + "current_route_id": current_route.id, + "idempotency_key": "route-revalidation", + } + ) + created = service.create(selected) + current_route.route_key = "changed-before-claim" + session.commit() + with pytest.raises(EvaluationError, match="comparison pins changed"): + service.claim(created.id, client) + + current_route.route_key = "selected-current-experiment" + session.commit() + claim, result, _ = _claimed(setup, selected) + assert claim.comparison.id == created.id + current_route.route_key = "changed-before-result" + session.commit() + with pytest.raises(EvaluationError, match="comparison pins changed"): + service.result(created.id, result, client) + + +@pytest.mark.parametrize("phase", ["claim", "result"]) +@pytest.mark.parametrize("mutation", ["inactive", "remapped"]) +def test_current_experiment_route_deactivation_or_remap_fails_closed(setup, phase, mutation): + session, service, request, client, _ = setup + current_route = session.scalar( + select(CapabilityExperimentRoute).where( + CapabilityExperimentRoute.route_key == "selected-current-experiment" + ) + ) + selected = request.model_copy( + update={ + "current_deployment_id": current_route.capability_deployment_id, + "current_route_id": current_route.id, + "idempotency_key": f"route-{phase}-{mutation}", + } + ) + + if phase == "claim": + comparison = service.create(selected) + claim = None + result = None + else: + claim, result, _ = _claimed(setup, selected) + comparison = claim.comparison + + if mutation == "inactive": + current_route.status = "inactive" + else: + current_route.capability_deployment_id = selected.candidate_deployment_id + session.commit() + + with pytest.raises(EvaluationError): + if phase == "claim": + service.claim(comparison.id, client) + else: + assert claim is not None and result is not None + service.result(comparison.id, result, client) + assert session.scalar(select(func.count()).select_from(EvaluationRun)) == 0 + + +def test_expiry_never_renews_or_reclaims(setup): + session, service, _, client, _ = setup + claim, result, _ = _claimed(setup) + row = session.get(SelectedModelComparison, claim.comparison.id) + row.claim_expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + with pytest.raises(EvaluationError): + service.result(row.id, result, client) + assert service.get(row.id).status == "interrupted" + assert row.claim_token_hash is None + with pytest.raises(EvaluationError): + service.claim(row.id, client) + + +def test_cancel_is_request_until_consumer_acknowledges(setup): + _, service, _, client, _ = setup + claim, result, _ = _claimed(setup) + response = service.cancel( + claim.comparison.id, ComparisonCancel(expected_version=claim.comparison.version) + ) + assert response.status == "cancel_requested" and response.finished_at is None + with pytest.raises(EvaluationError): + service.result(response.id, result, client) + completed = service.finish( + response.id, ComparisonFinish(claim_token=claim.claim_token, outcome="cancelled"), client + ) + assert completed.status == "cancelled" + + +@pytest.mark.parametrize( + "mutation", + ["historical", "wrong_target", "wrong_client", "wrong_query", "not_completed", "duplicate"], +) +def test_gateway_provenance_rejects_unrelated_or_reused_requests(setup, mutation): + session, service, request, client, _ = setup + claim, result, gateways = _claimed(setup) + if mutation == "historical": + gateways[0].created_at = datetime.now(UTC) - timedelta(hours=1) + elif mutation == "wrong_target": + gateways[0].capability_deployment_id = request.candidate_deployment_id + elif mutation == "wrong_client": + gateways[0].service_client_id = uuid4() + elif mutation == "wrong_query": + gateways[0].input_sha256 = "d" * 64 + elif mutation == "not_completed": + gateways[0].status = "failed" + else: + result.candidate_gateway_requests = result.current_gateway_requests + session.commit() + with pytest.raises(EvaluationError): + service.result(claim.comparison.id, result, client) + assert session.scalar(select(func.count()).select_from(EvaluationRun)) == 0 + + +def test_result_failure_rolls_back_both_runs_and_keeps_claim(setup): + session, service, _, client, _ = setup + claim, result, _ = _claimed(setup) + with ( + patch.object(EvaluationService, "compare", side_effect=RuntimeError("test interruption")), + pytest.raises(RuntimeError), + ): + service.result(claim.comparison.id, result, client) + assert session.scalar(select(func.count()).select_from(EvaluationRun)) == 0 + assert service.get(claim.comparison.id).status == "running" + assert service.result(claim.comparison.id, result, client).status == "completed" + + +def test_progress_is_monotonic_and_claim_cannot_be_stolen(setup): + _, service, _, client, _ = setup + claim, _, _ = _claimed(setup) + with pytest.raises(EvaluationError): + service.claim(claim.comparison.id, client) + progress = ComparisonProgress( + claim_token=claim.claim_token, current_completed_cases=1, candidate_completed_cases=0 + ) + service.progress(claim.comparison.id, progress, client) + with pytest.raises(EvaluationError): + service.progress( + claim.comparison.id, progress.model_copy(update={"current_completed_cases": 0}), client + ) + with pytest.raises(EvaluationError): + service.progress( + claim.comparison.id, + progress.model_copy(update={"candidate_completed_cases": 2}), + client, + ) + + +@pytest.mark.parametrize( + "mutation", + [ + "wrong_project", + "unreviewed", + "missing_corpus", + "wrong_contract", + "same_target", + "inactive_route", + "space_mismatch", + ], +) +def test_create_rejects_incoherent_selected_project_provenance(setup, mutation): + session, service, request, _, _ = setup + if mutation == "wrong_project": + from modelforge_api.persistence.models import EvaluationSuite, EvaluationSuiteRevision + + revision = session.get(EvaluationSuiteRevision, request.suite_revision_id) + session.get(EvaluationSuite, revision.suite_id).project_id = uuid4() + elif mutation in {"unreviewed", "missing_corpus"}: + case = session.scalar(select(EvaluationCase)) + # Simulate imported corrupt provenance, bypassing the normal immutable ORM guard. + if mutation == "unreviewed": + session.execute( + update(EvaluationCase) + .where(EvaluationCase.id == case.id) + .values(review_status="draft") + ) + else: + session.execute( + update(EvaluationCase) + .where(EvaluationCase.id == case.id) + .values(label_provenance={"source": "no reviewed corpus"}) + ) + elif mutation == "same_target": + request = request.model_copy( + update={"candidate_deployment_id": request.current_deployment_id} + ) + elif mutation == "inactive_route": + session.get(CapabilityExperimentRoute, request.candidate_route_id).status = "inactive" + else: + deployment = session.get(CapabilityDeployment, request.candidate_deployment_id) + if mutation == "wrong_contract": + deployment.capability_contract_id = uuid4() + else: + session.execute( + update(EmbeddingSpace) + .where(EmbeddingSpace.id == deployment.embedding_space_id) + .values(runtime_profile_id=uuid4()) + ) + session.commit() + with pytest.raises(EvaluationError): + service.create(request) + assert session.scalar(select(func.count()).select_from(SelectedModelComparison)) == 0 + + +def test_claim_and_result_revalidate_pins_and_client_revocation(setup): + session, service, request, client, _ = setup + created = service.create(request) + deployment = session.get(CapabilityDeployment, request.candidate_deployment_id) + original = deployment.config_fingerprint + deployment.config_fingerprint = "d" * 64 + session.commit() + with pytest.raises(EvaluationError): + service.claim(created.id, client) + deployment.config_fingerprint = original + session.commit() + claim, result, _ = _claimed(setup) + client.disabled_at = datetime.now(UTC) + session.commit() + with pytest.raises(EvaluationError): + service.result(claim.comparison.id, result, client) + client.disabled_at = None + deployment.config_fingerprint = "d" * 64 + session.commit() + with pytest.raises(EvaluationError): + service.result(claim.comparison.id, result, client) + + +def test_failed_case_cannot_qualify_and_does_not_require_gateway_success(setup): + session, service, _, client, _ = setup + claim, result, _ = _claimed(setup) + failed = result.current.results[0].model_copy( + update={"error_code": "UNAVAILABLE", "ranked_results": []} + ) + result.current = EvaluationRunComplete(results=[failed]) + result.current_gateway_requests = {} + completed = service.result(claim.comparison.id, result, client) + assert ( + session.get(EvaluationComparison, completed.evaluation_comparison_id).promotion_eligibility + == "not_eligible" + ) + + +def test_missing_case_or_mapping_and_changed_retry_cannot_publish(setup): + session, service, _, client, _ = setup + claim, result, _ = _claimed(setup) + original = result.current_gateway_requests + result.current_gateway_requests = {} + with pytest.raises(EvaluationError): + service.result(claim.comparison.id, result, client) + result.current_gateway_requests = original + completed = service.result(claim.comparison.id, result, client) + result.consumer_revision_sha256 = "d" * 64 + with pytest.raises(EvaluationError): + service.result(completed.id, result, client) + assert session.scalar(select(func.count()).select_from(EvaluationRun)) == 2 + + +@pytest.mark.parametrize("reader", ["list", "assigned", "create"]) +def test_expiry_is_reconciled_by_history_assignment_and_next_intent_without_early_commit( + setup, reader +): + session, service, request, client, _ = setup + claim, _, _ = _claimed(setup) + row = session.get(SelectedModelComparison, claim.comparison.id) + row.claim_expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + with patch.object(session, "commit", wraps=session.commit) as commits: + if reader == "list": + assert service.list().items[0].status == "interrupted" + elif reader == "assigned": + assert service.assigned(client).items[0].status == "interrupted" + else: + following = service.create( + request.model_copy(update={"idempotency_key": "explicit-new-intent"}) + ) + assert following.status == "pending" and following.id != row.id + assert commits.call_count == 1 + assert session.get(SelectedModelComparison, row.id).status == "interrupted" + + +def test_candidate_must_use_the_gateway_experiment_channel(setup): + session, service, request, _, _ = setup + candidate = session.get(CapabilityDeployment, request.candidate_deployment_id) + candidate.channel = "lab" + session.commit() + with pytest.raises(EvaluationError, match="comparison selection invalid"): + service.create(request) + assert session.scalar(select(func.count()).select_from(SelectedModelComparison)) == 0 + + +def test_summary_fails_closed_when_linked_comparison_is_missing(setup): + session, service, _, client, _ = setup + claim, result, _ = _claimed(setup) + completed = service.result(claim.comparison.id, result, client) + session.get(SelectedModelComparison, completed.id).evaluation_comparison_id = uuid4() + session.commit() + with pytest.raises(EvaluationError): + service.get(completed.id) diff --git a/backend/tests/test_selected_replacement.py b/backend/tests/test_selected_replacement.py new file mode 100644 index 0000000..9878f5a --- /dev/null +++ b/backend/tests/test_selected_replacement.py @@ -0,0 +1,145 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy import event, select + +from modelforge_api.persistence.models import ( + CapabilityContract, + ProductionExecutionApproval, + Project, + ProjectBinding, + SelectedModelComparison, +) +from modelforge_api.services.lifecycle import LifecycleError +from modelforge_api.services.selected_replacement import SelectedReplacementService +from tests.test_lifecycle_serving import serving_pair + +__all__ = ["serving_pair"] + + +def _preview(pair): + return SelectedReplacementService(pair[0]).preview(next(iter(pair[4].comparison_ids.values()))) + + +def test_preview_derives_exact_selection_without_writes_or_satisfied_claim(serving_pair): + session, _, current, candidate, selection, _ = serving_pair + statements = [] + + def observe(_conn, _cursor, statement, _parameters, _context, _many): + statements.append(statement.lstrip().split(None, 1)[0].upper()) + + event.listen(session.get_bind(), "before_cursor_execute", observe) + try: + result = _preview(serving_pair) + finally: + event.remove(session.get_bind(), "before_cursor_execute", observe) + assert result.selection == selection + assert result.route_generation == session.get(CapabilityContract, current.capability_contract_id).serving_generation + assert result.same_embedding_space and result.affected_bindings[0].eligible + assert result.production_approvals[0].id == selection.production_approval_id + assert [item.code for item in result.blockers] == ["PRODUCTION_VALIDATION_REVIEW_REQUIRED"] + assert result.production_promotion_performed is False + assert result.model_dump().get("production_validation") is None + assert not set(statements) & {"INSERT", "UPDATE", "DELETE", "TRUNCATE"} + assert current.status == "stable" and candidate.production is False + assert not session.new and not session.dirty and not session.deleted + + +def test_preview_lists_other_affected_project_as_blocker(serving_pair): + session, _, current, _, _, _ = serving_pair + project = Project(key="unreviewed", name="Unreviewed", description="test") + session.add(project) + session.flush() + binding = ProjectBinding(project_id=project.id, capability_contract_id=current.capability_contract_id, + channel="stable", priority="production") + session.add(binding) + session.commit() + result = _preview(serving_pair) + assert len(result.affected_bindings) == 2 + assert result.selection is None + assert any(item.code == "BINDING_COMPARISON_REQUIRED" and item.project_binding_id == binding.id for item in result.blockers) + + +@pytest.mark.parametrize("mutation,expected", [ + ("incomplete", "COMPARISON_NOT_COMPLETED"), + ("different-space", "SERVING_REINDEX_REQUIRED"), + ("revoked", "PRODUCTION_APPROVAL_REQUIRED"), + ("changed-route", "SERVING_SELECTION_INVALID"), +]) +def test_preview_blocks_missing_execution_identity_or_authority(serving_pair, mutation, expected): + session, serving, current, candidate, selection, _ = serving_pair + if mutation == "incomplete": + session.get(SelectedModelComparison, next(iter(selection.comparison_ids.values()))).status = "interrupted" + elif mutation == "different-space": + candidate.embedding_space_id = None + elif mutation == "revoked": + serving.repo.approval(selection.production_approval_id).status = "revoked" + else: + current.status = "draining" + session.commit() + result = _preview(serving_pair) + assert result.selection is None + assert expected in {item.code for item in result.blockers} + + +def test_missing_comparison_is_not_found(serving_pair): + with pytest.raises(LifecycleError) as error: + SelectedReplacementService(serving_pair[0]).preview(uuid4()) + assert error.value.status_code == 404 + + +def test_latest_negative_project_fit_remains_visible_blocker(serving_pair): + from tests.test_lifecycle_serving import _fit + + _fit(serving_pair, recommendation="KEEP_LAB", production_validation="DEFERRED_EXTERNAL_VALIDATION") + result = _preview(serving_pair) + assert result.selection is None + assert any(item.code == "SERVING_PROJECT_FIT_BLOCKED" for item in result.blockers) + assert serving_pair[0].scalar(select(CapabilityContract.serving_generation)) == result.route_generation + + +def test_stale_historical_approval_does_not_hide_valid_choice(serving_pair): + session, _, _, _, selection, _ = serving_pair + valid = session.get(ProductionExecutionApproval, selection.production_approval_id) + stale = ProductionExecutionApproval(**{ + column.name: getattr(valid, column.name) + for column in ProductionExecutionApproval.__table__.columns + if column.name not in {"id", "evidence_fingerprint"} + }, evidence_fingerprint="f" * 64) + session.add(stale) + session.commit() + result = _preview(serving_pair) + assert result.selection == selection + assert [item.id for item in result.production_approvals] == [valid.id] + assert [item.code for item in result.blockers] == ["PRODUCTION_VALIDATION_REVIEW_REQUIRED"] + + +def test_actual_app_preview_requires_operator_auth_and_disables_cache(serving_pair, monkeypatch): + from fastapi.testclient import TestClient + from pydantic import SecretStr + + from modelforge_api.db import get_session + from modelforge_api.main import app + from modelforge_api.settings import Settings, get_settings + + token = "preview-offline-operator-test" # noqa: S105 - isolated test credential + configured = Settings(_env_file=None, operator_api_key=SecretStr(token)) + previous = dict(app.dependency_overrides) + # SQLite fixture connections are thread-bound; qualify the real service above, + # then exercise middleware, dependencies and response serialization in TestClient. + result = _preview(serving_pair) + monkeypatch.setattr(SelectedReplacementService, "preview", lambda self, comparison_id: result) + app.dependency_overrides[get_session] = lambda: serving_pair[0] + app.dependency_overrides[get_settings] = lambda: configured + path = f"/api/v1/selected-model-comparisons/{next(iter(serving_pair[4].comparison_ids.values()))}/replacement-preview" + try: + client = TestClient(app) + assert client.get(path).status_code == 401 + response = client.get(path, headers={"X-ModelForge-Admin-Token": token}) + assert response.status_code == 200, response.text + assert response.headers["Cache-Control"] == "no-store" + assert response.json()["selection"]["candidate_deployment_id"] == str(serving_pair[3].id) + assert response.json()["production_promotion_performed"] is False + finally: + app.dependency_overrides.clear() + app.dependency_overrides.update(previous) diff --git a/backend/tests/test_self_check.py b/backend/tests/test_self_check.py new file mode 100644 index 0000000..bc53637 --- /dev/null +++ b/backend/tests/test_self_check.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient +from pydantic import SecretStr +from sqlalchemy import create_engine, event, text +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from modelforge_api.db import get_session +from modelforge_api.domain.release import TARGET_SCHEMA_REVISION +from modelforge_api.domain.self_check import SelfCheckStatus +from modelforge_api.main import app +from modelforge_api.persistence.models import ( + Accelerator, + Base, + ComputeNode, + ManagedOperatorCredential, + ManagedOperatorCredentialActivation, + ManagedOperatorIdentity, +) +from modelforge_api.services.manifest_registry import get_manifest_registry +from modelforge_api.services.self_check import ( + _configuration_check, + _credentials_check, + _database_check, + _manifest_check, + _node_check, + _redis_check, + _scheduler_check, + _storage_check, + run_self_check, +) +from modelforge_api.settings import Settings, get_settings + +OPERATOR_TOKEN = "self-check-test-operator-key-long-enough" # noqa: S105 - synthetic test credential +DATABASE_SECRET = "postgresql+psycopg://runtime:db-do-not-leak@db:5432/modelforge" # noqa: S105 +REDIS_SECRET = "redis-do-not-leak" # noqa: S105 - synthetic test credential + + +class FakeCandidates: + def __init__(self) -> None: + self.candidates = [object()] + + +class FakePolicies: + def model_dump(self) -> dict[str, object]: + return { + "scheduling": object(), + "security": object(), + "promotion": object(), + "retention": object(), + } + + +class FakeRegistry: + def capabilities(self) -> dict[str, object]: + return {"a": object()} + + def projects(self) -> dict[str, object]: + return {"p": object()} + + def candidates(self) -> FakeCandidates: + return FakeCandidates() + + def benchmarks(self) -> dict[str, object]: + return {"b": object()} + + def policies(self) -> FakePolicies: + return FakePolicies() + + +def _settings(tmp_path: Path, *, env: str = "test") -> Settings: + artifact = tmp_path / "artifacts" + quarantine = tmp_path / "quarantine" + backup = tmp_path / "backups" + for path in (artifact, quarantine, backup): + path.mkdir(exist_ok=True) + return Settings( + _env_file=None, + env=env, + operator_api_key=SecretStr(OPERATOR_TOKEN), + database_url=DATABASE_SECRET, + redis_url=f"redis://cache:{REDIS_SECRET}@redis.internal:6379/0", + artifact_root=str(artifact), + quarantine_root=str(quarantine), + backup_root=backup, + ) + + +def _session() -> Session: + engine = create_engine("sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + with engine.begin() as connection: + connection.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")) + connection.execute( + text("INSERT INTO alembic_version(version_num) VALUES (:revision)"), + {"revision": TARGET_SCHEMA_REVISION}, + ) + return Session(engine) + + +class RedisOk: + def ping(self) -> bool: + return True + + def close(self) -> None: + return None + + +class RedisDown: + def ping(self) -> bool: + raise ConnectionError("synthetic Redis outage") + + def close(self) -> None: + return None + + +def test_storage_probe_is_passive_and_reports_capacity(tmp_path: Path) -> None: + settings = _settings(tmp_path) + before = { + path: set(Path(path).iterdir()) + for path in (settings.artifact_root, settings.quarantine_root, settings.backup_root) + } + + result = _storage_check(settings) + + assert result.status is SelfCheckStatus.PASS + assert all( + item["writability"] == "not_probed_read_only" for item in result.evidence.values() + ) + assert all(item["free_bytes"] >= 0 for item in result.evidence.values()) + after = {path: set(Path(path).iterdir()) for path in before} + assert after == before + + +def test_storage_capacity_read_failure_remains_unknown( + tmp_path: Path, monkeypatch: Any +) -> None: + settings = _settings(tmp_path) + + def unavailable(_path: Path) -> object: + raise PermissionError("mount details must not be returned") + + monkeypatch.setattr("modelforge_api.services.self_check.shutil.disk_usage", unavailable) + result = _storage_check(settings) + + assert result.status is SelfCheckStatus.UNKNOWN + assert "mount details must not be returned" not in result.model_dump_json() + + +def test_storage_stat_permission_failure_remains_unknown(tmp_path: Path, monkeypatch: Any) -> None: + settings = _settings(tmp_path) + + class UnreadablePath: + def __init__(self, value: object) -> None: + self.value = value + + def stat(self) -> object: + raise PermissionError("private storage path details must not be returned") + + def __str__(self) -> str: + return str(self.value) + + monkeypatch.setattr("modelforge_api.services.self_check.Path", UnreadablePath) + result = _storage_check(settings) + + assert result.status is SelfCheckStatus.UNKNOWN + assert "private storage path details must not be returned" not in result.model_dump_json() + + +def test_database_check_fails_closed_on_unknown_schema() -> None: + session = _session() + try: + session.execute(text("UPDATE alembic_version SET version_num='20991231_9999'")) + session.commit() + result = _database_check(session) + finally: + session.close() + + assert result.status is SelfCheckStatus.FAIL + assert result.evidence["compatibility"] == "TOO_NEW" + + +def test_database_check_keeps_unreadable_state_unknown() -> None: + class UnavailableSession: + def execute(self, _statement: object) -> object: + raise ConnectionError("database URL must not be returned") + + def rollback(self) -> None: + return None + + result = _database_check(UnavailableSession()) # type: ignore[arg-type] + + assert result.status is SelfCheckStatus.UNKNOWN + assert result.detail == "ConnectionError" + assert "database URL must not be returned" not in result.model_dump_json() + + +def test_manifest_access_failure_is_unknown_but_missing_manifest_is_failure() -> None: + class UnreadableRegistry: + def candidates(self) -> object: + raise PermissionError("private manifest path details must not be returned") + + class MissingRegistry: + def candidates(self) -> object: + raise FileNotFoundError("missing manifest") + + unreadable = _manifest_check(UnreadableRegistry()) # type: ignore[arg-type] + missing = _manifest_check(MissingRegistry()) # type: ignore[arg-type] + + assert unreadable.status is SelfCheckStatus.UNKNOWN + assert "private manifest path details must not be returned" not in unreadable.model_dump_json() + assert missing.status is SelfCheckStatus.FAIL + + +def test_scheduler_inputs_remain_unknown_when_database_cannot_be_read() -> None: + class UnavailableSession: + def scalar(self, _statement: object) -> object: + raise ConnectionError("scheduler database details must not be returned") + + def rollback(self) -> None: + return None + + settings = Settings(_env_file=None) + result = _scheduler_check( + UnavailableSession(), settings, datetime.now(UTC) # type: ignore[arg-type] + ) + + assert result.status is SelfCheckStatus.UNKNOWN + assert result.detail == "ConnectionError" + assert "scheduler database details must not be returned" not in result.model_dump_json() + + +def test_scheduler_inputs_remain_unknown_without_fresh_accelerator_telemetry( + tmp_path: Path, +) -> None: + settings = _settings(tmp_path) + session = _session() + now = datetime(2026, 9, 6, tzinfo=UTC) + try: + node = ComputeNode( + key="scheduler-node", + hostname="scheduler-node", + display_name="Scheduler node", + identity_source="test", + ) + session.add(node) + session.flush() + session.add( + Accelerator( + compute_node_id=node.id, + device_index=0, + device_uuid="GPU-scheduler-test", + name="Test accelerator", + total_vram_bytes=8_000_000_000, + status="active", + ) + ) + session.commit() + + result = _scheduler_check(session, settings, now) + finally: + session.close() + + assert result.status is SelfCheckStatus.UNKNOWN + assert result.evidence["active_accelerators"] == 1 + assert result.evidence["telemetry_missing"] == 1 + + +def test_redis_probe_is_typed_and_never_echoes_the_connection_url( + tmp_path: Path, monkeypatch: Any +) -> None: + settings = _settings(tmp_path) + monkeypatch.setattr( + "modelforge_api.services.self_check.redis.Redis.from_url", + lambda *_args, **_kwargs: RedisDown(), + ) + + result = _redis_check(settings) + + assert result.status is SelfCheckStatus.FAIL + assert REDIS_SECRET not in result.model_dump_json() + assert result.detail == "ConnectionError" + + +def test_node_freshness_uses_existing_stale_and_offline_thresholds(tmp_path: Path) -> None: + settings = _settings(tmp_path) + session = _session() + now = datetime(2026, 9, 6, tzinfo=UTC) + try: + fresh = ComputeNode( + key="fresh", + hostname="fresh", + display_name="Fresh", + identity_source="test", + last_heartbeat_at=now + - timedelta(seconds=max(1, settings.node_stale_after_seconds // 2)), + ) + stale = ComputeNode( + key="stale", + hostname="stale", + display_name="Stale", + identity_source="test", + last_heartbeat_at=now - timedelta(seconds=settings.node_stale_after_seconds + 1), + ) + session.add_all([fresh, stale]) + session.commit() + + result = _node_check(session, settings, now) + finally: + session.close() + + assert result.status is SelfCheckStatus.WARN + assert result.evidence["stale_nodes"] == ["Stale"] + assert result.evidence["offline_nodes"] == [] + + +def test_node_without_any_heartbeat_evidence_is_unknown(tmp_path: Path) -> None: + settings = _settings(tmp_path) + session = _session() + try: + session.add( + ComputeNode( + key="unobserved", + hostname="unobserved", + display_name="Unobserved", + identity_source="test", + ) + ) + session.commit() + result = _node_check(session, settings, datetime(2026, 9, 6, tzinfo=UTC)) + finally: + session.close() + + assert result.status is SelfCheckStatus.UNKNOWN + assert result.evidence["heartbeat_unknown"] == ["Unobserved"] + + +def test_configuration_check_reports_missing_names_not_secret_values(tmp_path: Path) -> None: + settings = _settings(tmp_path, env="production") + + result = _configuration_check(settings) + + assert result.status is SelfCheckStatus.FAIL + serialized = result.model_dump_json() + assert OPERATOR_TOKEN not in serialized + assert DATABASE_SECRET not in serialized + assert REDIS_SECRET not in serialized + assert "missing_settings" in result.evidence + + +def test_credentials_check_reports_only_safe_metadata(tmp_path: Path) -> None: + settings = _settings(tmp_path) + settings.operator_api_key = None + session = _session() + now = datetime(2026, 9, 6, tzinfo=UTC) + credential_id = uuid.uuid4() + try: + session.add( + ManagedOperatorIdentity( + slug="admin", + role="admin", + enabled=True, + version=1, + created_at=now, + ) + ) + session.add( + ManagedOperatorCredential( + id=credential_id, + identity_slug="admin", + token_sha256="a" * 64, + expires_at=now + timedelta(days=30), + created_at=now, + ) + ) + session.add( + ManagedOperatorCredentialActivation( + credential_id=credential_id, + activated_at=now, + ) + ) + session.commit() + result = _credentials_check(session, settings, now) + finally: + session.close() + + assert result.status is SelfCheckStatus.PASS + assert result.evidence["managed_usable_admin_credentials"] == 1 + assert result.evidence["administrator_metadata_present"] is True + serialized = result.model_dump_json() + assert "a" * 64 not in serialized + assert "token_sha256" not in serialized + assert OPERATOR_TOKEN not in serialized + assert DATABASE_SECRET not in serialized + + +def test_credentials_check_warns_for_expiring_managed_metadata(tmp_path: Path) -> None: + settings = _settings(tmp_path) + session = _session() + now = datetime(2026, 9, 6, tzinfo=UTC) + try: + session.add( + ManagedOperatorIdentity( + slug="admin", + role="admin", + enabled=True, + version=1, + created_at=now, + ) + ) + session.add( + ManagedOperatorCredential( + id=uuid.uuid4(), + identity_slug="admin", + token_sha256="b" * 64, + expires_at=now + timedelta(days=3), + created_at=now, + ) + ) + session.commit() + result = _credentials_check(session, settings, now) + finally: + session.close() + + assert result.status is SelfCheckStatus.WARN + assert result.evidence["managed_expiring_within_7_days"] == 1 + assert "b" * 64 not in result.model_dump_json() + + +def test_credential_metadata_read_failure_remains_unknown(tmp_path: Path) -> None: + class UnavailableSession: + def execute(self, _statement: object) -> object: + raise ConnectionError("credential database details must not be returned") + + def rollback(self) -> None: + return None + + settings = _settings(tmp_path) + result = _credentials_check( + UnavailableSession(), settings, datetime.now(UTC) # type: ignore[arg-type] + ) + + assert result.status is SelfCheckStatus.UNKNOWN + assert result.detail == "ConnectionError" + serialized = result.model_dump_json() + assert "credential database details must not be returned" not in serialized + assert OPERATOR_TOKEN not in serialized + assert DATABASE_SECRET not in serialized + + +def test_credential_metadata_without_an_admin_source_fails_closed(tmp_path: Path) -> None: + settings = _settings(tmp_path) + settings.operator_api_key = None + viewer_token = "mfop_" + "v" * 43 + operator_token = "mfop_" + "o" * 43 + settings.operator_identities = SecretStr( + json.dumps( + [ + { + "credential_id": role, + "subject": role, + "role": role, + "token_sha256": hashlib.sha256(token.encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(days=30)).isoformat(), + } + for role, token in ( + ("viewer", viewer_token), + ("operator", operator_token), + ) + ] + ) + ) + session = _session() + try: + result = _credentials_check(session, settings, datetime.now(UTC)) + finally: + session.close() + + assert result.status is SelfCheckStatus.FAIL + assert result.evidence["administrator_metadata_present"] is False + serialized = result.model_dump_json() + assert viewer_token not in serialized + assert operator_token not in serialized + + +def test_self_check_endpoint_is_admin_authenticated_read_only_and_secret_safe( + tmp_path: Path, monkeypatch: Any +) -> None: + settings = _settings(tmp_path) + session = _session() + monkeypatch.setattr( + "modelforge_api.services.self_check.redis.Redis.from_url", + lambda *_args, **_kwargs: RedisOk(), + ) + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_manifest_registry] = lambda: FakeRegistry() + engine = session.get_bind() + mutations: list[str] = [] + + def reject_mutation( + _connection: Any, + _cursor: Any, + statement: str, + _parameters: Any, + _context: Any, + _executemany: bool, + ) -> None: + operation = statement.lstrip().split(maxsplit=1)[0].upper() + if operation in {"INSERT", "UPDATE", "DELETE", "REPLACE", "CREATE", "DROP", "ALTER"}: + mutations.append(operation) + + event.listen(engine, "before_cursor_execute", reject_mutation) + client = TestClient(app) + try: + anonymous = client.get("/api/v1/admin/operations/self-check") + assert anonymous.status_code == 401 + + response = client.get( + "/api/v1/admin/operations/self-check", + headers={"X-ModelForge-Admin-Token": OPERATOR_TOKEN}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["read_only"] is True + assert payload["overall"] == "degraded" + assert {check["key"] for check in payload["checks"]} == { + "api", + "database", + "redis", + "manifests", + "storage", + "nodes", + "scheduler", + "deployments", + "backup", + "credentials", + "configuration", + "release", + } + assert all(isinstance(check["next_action"], dict) for check in payload["checks"]) + assert all(check["next_action"]["code"] for check in payload["checks"]) + assert ( + next(check for check in payload["checks"] if check["key"] == "scheduler")["status"] + == "unknown" + ) + api_check = next(check for check in payload["checks"] if check["key"] == "api") + assert api_check["status"] == "pass" + assert api_check["next_action"]["code"] == "none_required" + serialized = response.text + assert OPERATOR_TOKEN not in serialized + assert DATABASE_SECRET not in serialized + assert REDIS_SECRET not in serialized + + settings.operator_identities = SecretStr( + json.dumps( + [ + { + "credential_id": f"self-check-{role}", + "subject": f"self-check-{role}", + "role": role, + "token_sha256": hashlib.sha256(token.encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(days=30)).isoformat(), + } + for role, token in ( + ("viewer", "mfop_" + "v" * 43), + ("operator", "mfop_" + "o" * 43), + ) + ] + ) + ) + for token in ("mfop_" + "v" * 43, "mfop_" + "o" * 43): + denied = client.get( + "/api/v1/admin/operations/self-check", + headers={"X-ModelForge-Admin-Token": token}, + ) + assert denied.status_code == 403 + for token in ("mfsvc_project-scoped-token", "mfnode_node-agent-token"): + denied = client.get( + "/api/v1/admin/operations/self-check", + headers={"X-ModelForge-Admin-Token": token}, + ) + assert denied.status_code == 401 + finally: + client.close() + app.dependency_overrides.clear() + event.remove(engine, "before_cursor_execute", reject_mutation) + session.close() + assert mutations == [] + + +def test_aggregate_becomes_unhealthy_when_a_required_dependency_fails( + tmp_path: Path, monkeypatch: Any +) -> None: + settings = _settings(tmp_path) + session = _session() + monkeypatch.setattr( + "modelforge_api.services.self_check.redis.Redis.from_url", + lambda *_args, **_kwargs: RedisDown(), + ) + try: + report = run_self_check(session, settings, FakeRegistry()) # type: ignore[arg-type] + finally: + session.close() + + assert report.overall == "unhealthy" + redis_check = next(check for check in report.checks if check.key == "redis") + assert redis_check.status is SelfCheckStatus.FAIL diff --git a/backend/tests/test_serving.py b/backend/tests/test_serving.py index 81fdb5d..2d4a8e8 100644 --- a/backend/tests/test_serving.py +++ b/backend/tests/test_serving.py @@ -9,7 +9,7 @@ from unittest.mock import Mock import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine, select, update +from sqlalchemy import create_engine, event, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -19,6 +19,7 @@ from modelforge_api.domain.runtime import AgentRuntimeProbeComplete from modelforge_api.domain.serving import ( AgentResidentState, AgentServingJobComplete, + AgentServingJobFailure, AgentServingStateReport, CapabilityExperimentCreate, CapabilityPromotionCreate, @@ -41,6 +42,7 @@ from modelforge_api.persistence.models import ( ComputeNode, EmbeddingSpace, GatewayRequest, + SchedulerAcceleratorState, ServiceCredential, ServingGpuLease, ServingJob, @@ -543,7 +545,111 @@ def test_scheduler_accounts_external_usage_reserve_and_workstation_rejection( assert "NODE_NOT_PRODUCTION_ELIGIBLE" in rejected["reasons"] +@pytest.mark.parametrize( + "pressure_boundary", + [False, True], + ids=["first-read", "recovery-stable-window"], +) +def test_scheduler_get_projects_expiry_and_pressure_without_dml_or_commit( + session: Session, + pressure_boundary: bool, +) -> None: + base, candidate, _node = setup_m5(session) + _approval, deployment = approve_and_promote(base, candidate.id) + baseline = base.scheduler_overview(commit=False, update_pressure=False)[0] + + now = datetime.now(UTC) + since = now - timedelta(seconds=base.settings.scheduler_pressure_stable_seconds + 1) + pressure = ( + SchedulerAcceleratorState( + accelerator_id=deployment.accelerator_id, + pressure_state="HIGH", + recovery_candidate="NORMAL", + recovery_candidate_since=since, + pressure_changed_at=since - timedelta(minutes=1), + last_observed_at=since, + ) + if pressure_boundary + else None + ) + expired_lease = ServingGpuLease( + accelerator_id=deployment.accelerator_id, + capability_deployment_id=deployment.id, + request_id=uuid.uuid4(), + reserved_vram_bytes=4 * 1024**3, + priority="production", + state="active", + owner="scheduler-read-only-test", + expires_at=now - timedelta(milliseconds=1), + ) + if pressure is not None: + session.add(pressure) + session.add(expired_lease) + session.commit() + + dml_statements: list[str] = [] + commits: list[bool] = [] + + def record_statement(_conn, _cursor, statement, _parameters, _context, _many) -> None: + operation = statement.lstrip().split(maxsplit=1)[0].upper() + if operation in {"INSERT", "UPDATE", "DELETE", "REPLACE", "MERGE", "TRUNCATE"}: + dml_statements.append(statement) + + def record_commit(_session: Session) -> None: + commits.append(True) + + engine = session.get_bind() + event.listen(engine, "before_cursor_execute", record_statement) + event.listen(session, "after_commit", record_commit) + request_settings = Settings( + _env_file=None, + operator_api_key="scheduler-read-only-test-key", + ) + app.dependency_overrides[get_settings] = lambda: request_settings + app.dependency_overrides[get_serving_service] = lambda: base + try: + response = TestClient(app).get( + "/api/v1/scheduler", + headers={"X-ModelForge-Admin-Token": "scheduler-read-only-test-key"}, + ) + finally: + app.dependency_overrides.clear() + event.remove(engine, "before_cursor_execute", record_statement) + event.remove(session, "after_commit", record_commit) + + assert response.status_code == 200, response.text + budget = response.json()[0] + assert budget["pressure_state"] == "NORMAL" + assert budget["leased_vram_bytes"] == baseline.leased_vram_bytes + assert dml_statements == [] + assert commits == [] + + persisted_lease = session.get(ServingGpuLease, expired_lease.id) + pressure_query = select(SchedulerAcceleratorState).where( + SchedulerAcceleratorState.accelerator_id == deployment.accelerator_id + ) + persisted_pressure = session.scalar(pressure_query) + if pressure_boundary: + assert persisted_pressure is not None + assert persisted_pressure.pressure_state == "HIGH" + assert persisted_pressure.recovery_candidate == "NORMAL" + assert persisted_pressure.recovery_candidate_since is not None + assert persisted_pressure.recovery_candidate_since.replace(tzinfo=UTC) == since + else: + assert persisted_pressure is None + assert persisted_lease is not None and persisted_lease.state == "active" + + # The default service path still stabilizes and commits pressure for write/admission callers. + base.scheduler_overview() + persisted_pressure = session.scalar(pressure_query) + assert persisted_pressure is not None + assert persisted_pressure.pressure_state == "NORMAL" + assert persisted_pressure.recovery_candidate is None + + class InlineWorkerServingService(ServingService): + synthetic_worker_metrics: dict[str, object] + def _wait_job(self, job_id: uuid.UUID, deadline: float): del deadline job = self.repo.job(job_id) @@ -577,7 +683,10 @@ class InlineWorkerServingService(ServingService): "normalized": True, "prompt_tokens": len(lease.input) * 4, } - metrics = {"inference_time_ms": 25.0} + metrics = { + "inference_time_ms": 25.0, + **getattr(self, "synthetic_worker_metrics", {}), + } else: assert lease.rerank_query is not None assert lease.rerank_documents is not None @@ -667,6 +776,67 @@ def test_real_gateway_path_uses_scheduler_cold_load_then_warm_reuse(session: Ses assert history[0].latency_breakdown["worker_total"] >= 0 +def test_runtime_failure_fails_gateway_and_releases_request_resources(session: Session) -> None: + base, candidate, _node = setup_m5(session) + _approval, deployment = approve_and_promote(base, candidate.id) + + class FailingRuntimeService(InlineWorkerServingService): + def _wait_job(self, job_id: uuid.UUID, deadline: float): + job = self.repo.job(job_id) + assert job is not None + if job.operation != "invoke": + return super()._wait_job(job_id, deadline) + node = self.repo.node(job.compute_node_id) + assert node is not None + lease = self.claim_next(node) + assert lease is not None and lease.job_id == job_id + self.fail_job( + job_id, + node, + AgentServingJobFailure( + lease_token=lease.lease_token, + worker_instance_id="failed-runtime-worker", + failure_code="RUNTIME_UNAVAILABLE", + failure_message="synthetic worker transport failure", + ), + ) + return ServingService._wait_job(self, job_id, deadline) + + service = FailingRuntimeService(session, base.settings, base.manifests, base.payload_store) + created = service.create_client( + ServiceClientCreate(name="runtime-failure-client", allowed_capabilities=["rag.embedding@1"]) + ) + app.dependency_overrides[get_settings] = lambda: service.settings + app.dependency_overrides[get_serving_service] = lambda: service + try: + response = TestClient(app).post( + "/api/v1/capabilities/rag.embedding@1/invoke", + headers={"Authorization": f"Bearer {created.credential}"}, + json={"input": "must not be inferred elsewhere"}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 503 + assert response.json()["error"]["code"] == "RUNTIME_UNAVAILABLE" + requests = list(session.scalars(select(GatewayRequest))) + assert len(requests) == 1 + assert requests[0].status == "failed" + assert requests[0].failure_code == "RUNTIME_UNAVAILABLE" + assert requests[0].capability_deployment_id == deployment.id + jobs = list(session.scalars(select(ServingJob))) + assert [job.operation for job in jobs] == ["load", "invoke"] + assert jobs[-1].status == "failed" and jobs[-1].failure_code == "RUNTIME_UNAVAILABLE" + assert jobs[-1].payload_reference is None + assert service.payload_store.values == {} + leases = list(session.scalars(select(ServingGpuLease))) + assert len(leases) == 1 + assert leases[0].state == "failed" and leases[0].released_at is not None + residency = service.repo.residency(deployment.id) + assert residency is not None and residency.active_requests == 0 + assert residency.state == "cold" + + def test_lab_experiment_has_opaque_route_own_space_and_no_production_cutover( session: Session, ) -> None: diff --git a/backend/tests/test_serving_external_budget.py b/backend/tests/test_serving_external_budget.py new file mode 100644 index 0000000..ab32aff --- /dev/null +++ b/backend/tests/test_serving_external_budget.py @@ -0,0 +1,89 @@ +"""Native admission honors external reservations until their explicit release.""" +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import ( + Base, + ExternalCreativeAllocation, + ProjectBinding, + ServiceClient, +) +from modelforge_api.services.manifest_registry import ManifestRegistry +from modelforge_api.services.serving import ServingService +from modelforge_api.settings import Settings +from tests.test_selected_comparison_postgres import _valid_selection +from tests.test_serving import MemoryPayloadStore + + +@pytest.fixture +def budget_fixture(): + engine = create_engine("sqlite://") + event.listen(engine, "connect", lambda connection, _: connection.execute("PRAGMA foreign_keys=ON")) + Base.metadata.create_all(engine) + with Session(engine) as session: + selection, client_id = _valid_selection(session) + service = ServingService(session, Settings(_env_file=None, env="test"), + ManifestRegistry(), MemoryPayloadStore()) + deployment = service.repo.deployment(selection.current_deployment_id) + client = session.get(ServiceClient, client_id) + assert deployment is not None and client is not None + binding = session.get(ProjectBinding, client.project_binding_id) + assert binding is not None + yield session, service, deployment, (client.id, binding.project_id) + engine.dispose() + + +def allocation(deployment, client, *, state, required): + return ExternalCreativeAllocation( + service_client_id=client[0], project_id=client[1], + accelerator_id=deployment.accelerator_id, request_id=str(uuid4()), + idempotency_key=str(uuid4()), request_sha256="a" * 64, workload="image", + resource_ref="fixture", provider_resource_id="fixture", required_vram_bytes=required, + priority="background", ttl_seconds=30, state=state, decision_code="FIXTURE", + decision_evidence={}, required_actions=[], + expires_at=datetime.now(UTC) - timedelta(seconds=1), + ) + + +@pytest.mark.parametrize(("state", "reserved"), [ + ("granted", True), ("uncertain", True), ("queued", False), ("released", False), +]) +def test_actual_native_budget_retains_expired_grants_and_uncertain_reservations( + budget_fixture, state, reserved, +): + session, service, deployment, client = budget_fixture + before = service._budget(deployment) + required = 512 * 1024**2 + session.add(allocation(deployment, client, state=state, required=required)) + session.flush() + after = service._budget(deployment) + delta = required if reserved else 0 + assert after.leased_vram_bytes == before.leased_vram_bytes + delta + assert after.schedulable_free_vram_bytes == before.schedulable_free_vram_bytes - delta + assert after.invariant_delta_bytes == before.invariant_delta_bytes == 0 + + +def test_native_placement_cannot_spend_external_reservation(budget_fixture): + session, service, deployment, client = budget_fixture + before = service._budget(deployment) + session.add(allocation(deployment, client, state="uncertain", + required=before.schedulable_free_vram_bytes - 1)) + session.flush() + plan = service._placement_plan(deployment, priority="production", dry_run=True, commit=False) + assert plan.headroom_before_bytes == 1 + assert plan.verdict not in {"ADMIT", "ADMIT_AFTER_EVICTION"} + + +def test_overview_does_not_commit_owning_admission_transaction(budget_fixture): + session, service, _deployment, _client = budget_fixture + commits = [] + event.listen(session, "after_commit", lambda _: commits.append(True)) + assert service.scheduler_overview(commit=False) + assert commits == [] + assert session.in_transaction() + service.scheduler_overview() + assert commits == [True] diff --git a/backend/tests/test_serving_generation.py b/backend/tests/test_serving_generation.py new file mode 100644 index 0000000..b2d0d3b --- /dev/null +++ b/backend/tests/test_serving_generation.py @@ -0,0 +1,27 @@ +"""Legacy route entry points participate in the durable generation fence.""" +from sqlalchemy.orm import Session + +from modelforge_api.domain.serving import CapabilityPromotionCreate +from modelforge_api.persistence.models import CapabilityContract +from tests.test_serving import approve_and_promote, session, setup_m5 + +__all__ = ["session"] + + +def test_first_promotion_and_repeated_drain_do_not_allow_generation_reuse(session: Session): + service, candidate, _ = setup_m5(session) + approval, deployment = approve_and_promote(service, candidate.id) + contract = session.get(CapabilityContract, approval.capability_contract_id) + assert contract.serving_generation == deployment.route_generation == 1 + replay = service.promote(candidate.id, CapabilityPromotionCreate( + production_approval_id=approval.id, keep_warm_seconds=900)) + assert replay.id == deployment.id and contract.serving_generation == 1 + service.request_unload(deployment.id) + assert contract.serving_generation == 1 + service.request_unload(deployment.id, drain=True) + assert contract.serving_generation == 2 + service.request_unload(deployment.id, drain=True) + assert contract.serving_generation == 2 + replacement = service.promote(candidate.id, CapabilityPromotionCreate( + production_approval_id=approval.id, keep_warm_seconds=901)) + assert replacement.id != deployment.id and contract.serving_generation == replacement.route_generation == 3 diff --git a/backend/tests/test_serving_generation_recovery.py b/backend/tests/test_serving_generation_recovery.py new file mode 100644 index 0000000..003b6b9 --- /dev/null +++ b/backend/tests/test_serving_generation_recovery.py @@ -0,0 +1,168 @@ +"""Fresh source projections preserve route generation through schema migration.""" +from __future__ import annotations + +import copy +import importlib.util +import re +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import MetaData, Table, create_engine, inspect, select, text +from sqlalchemy.engine import URL +from sqlalchemy.orm import Session + +from modelforge_api.persistence.models import Base, Capability, CapabilityContract +from modelforge_api.services.recovery_fingerprint import ( + SERVING_GENERATION_SCHEMA, + _without_model_changes, + _without_portfolio, + audit_fingerprint_differences_are_compatible, + fingerprint_compatibility, +) +from modelforge_api.services.recovery_fingerprint import ( + semantic_fingerprint as _current_semantic_fingerprint, +) +from tests.recovery_fixtures import recreate_0038_benchmark_runs + + +def semantic_fingerprint(connection): + return _without_portfolio(_without_model_changes(_current_semantic_fingerprint(connection))) + + +def _remove_lab_serving_job_link(connection) -> None: + create_sql = connection.scalar( + text("SELECT sql FROM sqlite_master WHERE type='table' AND name='serving_jobs'") + ) + assert isinstance(create_sql, str) + index_sql = connection.execute( + text("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='serving_jobs' AND sql IS NOT NULL") + ).scalars().all() + columns = [ + row[1] + for row in connection.execute(text("PRAGMA table_info(serving_jobs)")).all() + if row[1] != "lab_execution_run_id" + ] + legacy_sql = re.sub( + r"^\s*lab_execution_run_id\b[^\n]*\n", "", create_sql, flags=re.MULTILINE + ) + legacy_sql = re.sub( + r",\s*\n\s*FOREIGN KEY\(lab_execution_run_id\)[^\n]*", "", legacy_sql + ).replace("CREATE TABLE serving_jobs", "CREATE TABLE serving_jobs_legacy", 1) + connection.exec_driver_sql(legacy_sql) + legacy_table = Table("serving_jobs_legacy", MetaData(), autoload_with=connection) + source_table = Table("serving_jobs", MetaData(), autoload_with=connection) + connection.execute( + legacy_table.insert().from_select( + columns, select(*(source_table.c[column] for column in columns)) + ) + ) + connection.exec_driver_sql("DROP TABLE serving_jobs") + connection.exec_driver_sql("ALTER TABLE serving_jobs_legacy RENAME TO serving_jobs") + for statement in index_sql: + if "lab_execution_run_id" not in statement: + connection.exec_driver_sql(statement) + connection.exec_driver_sql("DROP TABLE lab_execution_runs") + + +def migration(): + path = Path(__file__).parents[1] / "alembic/versions/20260910_0034_approved_serving_transition.py" + spec = importlib.util.spec_from_file_location("serving_generation", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def upgraded(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with engine.begin() as connection: + # Keep this historical 0033 fixture physically faithful to its declared schema. + connection.execute(text('DROP TABLE "alert_notification_deliveries"')) + recreate_0038_benchmark_runs(connection) + _remove_lab_serving_job_link(connection) + with Session(engine) as session: + capability = Capability(key="generation.fixture", description="Isolated fixture") + session.add(capability) + session.flush() + session.add(CapabilityContract(capability_id=capability.id, version=1, + input_schema={}, output_schema={}, contract={}, upgrade_class="transparent")) + session.commit() + module = migration() + with engine.begin() as connection: + module.op = Operations(MigrationContext.configure(connection)) + module.downgrade() + connection.execute(text("CREATE TABLE alembic_version(version_num VARCHAR(32))")) + connection.execute(text("INSERT INTO alembic_version VALUES ('20260909_0033')")) + before = semantic_fingerprint(connection) + module.upgrade() + connection.execute(text("UPDATE alembic_version SET version_num='20260910_0034'")) + yield connection, before + engine.dispose() + + +def test_populated_prior_contracts_gain_only_zero_generation(upgraded): + connection, before = upgraded + after = semantic_fingerprint(connection) + assert connection.scalar(text("SELECT serving_generation FROM capability_contracts")) == 0 + assert "serving_generation_projection" in before["tables"]["capability_contracts"] + contract = fingerprint_compatibility(before, after, source_schema_revision="20260909_0033") + assert contract.compatible + assert audit_fingerprint_differences_are_compatible(before, after, contract) + connection.execute(text("UPDATE capability_contracts SET serving_generation=1")) + assert not audit_fingerprint_differences_are_compatible(before, semantic_fingerprint(connection), contract) + + +def test_opaque_prior_digest_cannot_prove_default_generation(upgraded): + connection, before = upgraded + del before["tables"]["capability_contracts"]["serving_generation_projection"] + after = semantic_fingerprint(connection) + contract = fingerprint_compatibility(before, after, source_schema_revision="20260909_0033") + assert not audit_fingerprint_differences_are_compatible(before, after, contract) + + +def test_current_generation_is_preserved_and_cannot_be_projected_away(upgraded): + connection, _ = upgraded + connection.execute(text("UPDATE capability_contracts SET serving_generation=7")) + before = semantic_fingerprint(connection) + after = copy.deepcopy(before) + contract = fingerprint_compatibility(before, after, source_schema_revision=SERVING_GENERATION_SCHEMA) + assert contract.compatible and audit_fingerprint_differences_are_compatible(before, after, contract) + forged = copy.deepcopy(before) + forged["tables"]["capability_contracts"]["serving_generation_projection"] = after["tables"]["capability_contracts"] + assert not fingerprint_compatibility(forged, after, source_schema_revision=SERVING_GENERATION_SCHEMA).compatible + connection.execute(text("UPDATE capability_contracts SET serving_generation=0")) + assert not audit_fingerprint_differences_are_compatible(before, semantic_fingerprint(connection), contract) + module = migration() + module.op = Operations(MigrationContext.configure(connection)) + connection.execute(text("UPDATE capability_contracts SET serving_generation=7")) + with pytest.raises(RuntimeError, match="history exists"): + module.downgrade() + + +@contextmanager +def legacy_generation_fixture(owner: URL) -> Iterator[None]: + """Seed isolated historical schemas using today's fixture code, then restore exact DDL. + + Current ORM selects and serving fixture calls require this future column. Only the + fixture setup temporarily exposes it; fingerprints, dumps and migrations see the + actual historical schema. The runtime role receives no DDL privilege. + """ + engine = create_engine(owner) + introduced = False + try: + with engine.begin() as connection: + if "serving_generation" not in {c["name"] for c in inspect(connection).get_columns("capability_contracts")}: + connection.execute(text("ALTER TABLE capability_contracts ADD COLUMN serving_generation BIGINT NOT NULL DEFAULT 0")) + introduced = True + yield + finally: + if introduced: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE capability_contracts DROP COLUMN serving_generation")) + assert "serving_generation" not in {c["name"] for c in inspect(connection).get_columns("capability_contracts")} + engine.dispose() diff --git a/backend/tests/test_serving_reconciliation_loop.py b/backend/tests/test_serving_reconciliation_loop.py new file mode 100644 index 0000000..ec500b3 --- /dev/null +++ b/backend/tests/test_serving_reconciliation_loop.py @@ -0,0 +1,58 @@ +"""Transient infrastructure errors remain visible and do not stop reconciliation.""" + +import uuid +from unittest.mock import MagicMock + +import pytest +import redis +from sqlalchemy.exc import OperationalError + +from modelforge_api.services import serving_reconciliation as module +from modelforge_api.settings import Settings + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error", [ + OperationalError("private SQL", {}, Exception("private connection data")), + redis.ConnectionError("private Redis address"), + RuntimeError("private runtime details"), +]) +async def test_transient_failure_logged_safely_and_next_cycle_runs(monkeypatch, error): + settings = Settings(_env_file=None, env="test") + loop = module.ServingReconciliationService(settings, MagicMock()) + session = MagicMock() + monkeypatch.setattr(module, "Session", session) + monkeypatch.setattr(module, "ManifestRegistry", MagicMock()) + monkeypatch.setattr(module, "RedisPayloadStore", MagicMock()) + logger = MagicMock() + monkeypatch.setattr(module, "logger", logger) + calls = 0 + + def reconcile(): + nonlocal calls + calls += 1 + if calls == 1: + raise error + loop.stop() + + worker = MagicMock() + worker.return_value.reconcile.side_effect = reconcile + monkeypatch.setattr(module, "ServingService", worker) + # Skip the timer without changing stop semantics or creating a real Redis connection. + async def wait_for(awaitable, timeout): + del timeout + if loop._stop.is_set(): + return await awaitable + awaitable.close() + raise TimeoutError + + monkeypatch.setattr(module.asyncio, "wait_for", wait_for) + await loop.run() + assert calls == 2 + logger.error.assert_called_once() + args, details = logger.error.call_args + assert args == ("serving_reconciliation_failed",) + assert details["error_type"] == type(error).__name__ + assert uuid.UUID(details["correlation_id"]) + assert "private" not in repr(logger.error.call_args) + assert session.return_value.__exit__.call_count == 2 diff --git a/backend/tests/test_supply_chain_m16.py b/backend/tests/test_supply_chain_m16.py index c5812d7..e8db024 100644 --- a/backend/tests/test_supply_chain_m16.py +++ b/backend/tests/test_supply_chain_m16.py @@ -264,6 +264,7 @@ _PUBLISHED_PORT = re.compile(r'^\s*-\s*"(?P[^"]*:\d+)"\s*$') # The API is published deliberately: every admin route is operator-authenticated and the console has # to reach it. Everything else defaults to loopback. _DELIBERATELY_PUBLISHED = ("MODELFORGE_API_BIND",) +_VALIDATION_TLS_PUBLIC_MAPPING = "0.0.0.0:18444:8443" def _published_mappings(path: Path) -> list[str]: @@ -331,7 +332,16 @@ def test_every_compose_projection_binds_its_ports_to_loopback(compose: Path) -> written for cannot find the next occurrence, so this one reads every projection. """ - for mapping in _published_mappings(compose): + mappings = _published_mappings(compose) + if compose.name == "docker-compose.validation-tls.yml": + # This overlay is the one explicitly isolated candidate ingress. Its rendered service, + # project and network are independently checked by rc_isolation_preflight.py. + assert mappings.count(_VALIDATION_TLS_PUBLIC_MAPPING) == 1 + assert "\n tls:\n" in compose.read_text("utf-8") + + for mapping in mappings: + if compose.name == "docker-compose.validation-tls.yml" and mapping == _VALIDATION_TLS_PUBLIC_MAPPING: + continue if any(name in mapping for name in _DELIBERATELY_PUBLISHED): continue assert _binds_to_loopback(mapping), ( @@ -379,6 +389,11 @@ def test_the_console_sets_its_security_headers_in_every_location() -> None: ): assert header in headers + assert "location = /.well-known/modelforge-build-provenance.json" in config + assert config.index("location = /.well-known/modelforge-build-provenance.json") < config.index( + "location ~ /\\." + ) + def test_no_dependency_is_declared_as_a_floating_latest() -> None: """`latest` makes a build unrepeatable and pulls a compromised release automatically.""" @@ -395,9 +410,21 @@ def test_no_dependency_is_declared_as_a_floating_latest() -> None: assert floating == [], f"floating dependency specifiers: {floating}" -def test_the_release_images_upgrade_their_installer() -> None: - for dockerfile in (ROOT / "backend" / "Dockerfile", ROOT / "node-agent" / "Dockerfile"): - assert "--upgrade pip" in dockerfile.read_text("utf-8"), dockerfile +def test_python_release_images_use_the_hash_locked_build_toolchain() -> None: + for dockerfile in ( + ROOT / "backend" / "Dockerfile", + ROOT / "node-agent" / "Dockerfile", + ROOT / "runtime-worker" / "Dockerfile", + ): + text = dockerfile.read_text("utf-8") + assert "COPY config/security/build-toolchain.lock /tmp/modelforge-build-toolchain.lock" in text + assert "--require-hashes" in text + assert "--only-binary=:all:" in text + assert "--index-url https://pypi.org/simple" in text + assert "--no-build-isolation" in text + assert "--upgrade pip" not in text + if dockerfile.parent.name == "node-agent": + assert "--ignore-installed" in text def test_every_release_base_image_is_digest_pinned_and_security_updated() -> None: @@ -410,29 +437,49 @@ def test_every_release_base_image_is_digest_pinned_and_security_updated() -> Non text = dockerfile.read_text("utf-8") from_lines = [line for line in text.splitlines() if line.startswith("FROM ")] assert from_lines - assert all("@sha256:" in line for line in from_lines), dockerfile - assert ( - "apk upgrade --no-cache" in text - or "apt-get upgrade --yes" in text + stages = { + line.split(" AS ", 1)[1].strip() + for line in from_lines + if " AS " in line + } + assert all( + "@sha256:" in line or line.split()[1] in stages + for line in from_lines ), dockerfile + if dockerfile.parent.name == "node-agent": + # The Node Agent final stage is Canonical's glibc-compatible chiseled runtime. It has + # no package manager by design; patch currency is supplied by the exact immutable + # runtime digest and the managed exact-image Trivy gate. Its builder installs the + # hash-locked toolchain and is excluded from the shipped image. + assert "ubuntu/python:3.12-24.04@sha256:" in text, dockerfile + else: + assert ( + "apk upgrade --no-cache" in text + or "apt-get upgrade --yes" in text + ), dockerfile for dockerfile in (dockerfiles[0], dockerfiles[2]): text = dockerfile.read_text("utf-8") - assert '"setuptools>=78.1.1"' in text, dockerfile - assert '"msgpack>=1.2.1"' in text, dockerfile + assert "--require-hashes" in text, dockerfile + assert "--no-build-isolation" in text, dockerfile assert "pip check" in text, dockerfile - assert "python -m pip uninstall --yes pip setuptools" in text, dockerfile + assert "-m pip uninstall --yes pip setuptools wheel" in text, dockerfile def test_node_agent_runtime_is_glibc_multistage_and_keeps_nvidia_hardening() -> None: dockerfile = (ROOT / "node-agent" / "Dockerfile").read_text("utf-8") compose = (ROOT / "docker-compose.node-agent.yml").read_text("utf-8") - assert dockerfile.count("FROM python:3.12-slim-trixie@sha256:") == 2 + assert dockerfile.count("FROM python:3.12-slim-trixie@sha256:") == 1 + assert "FROM ubuntu/python:3.12-24.04@sha256:" in dockerfile + assert " AS runtime-base" in dockerfile assert " AS builder" in dockerfile assert "alpine" not in dockerfile.lower() - assert "USER modelforge-agent" in dockerfile - assert "--uid 100" in dockerfile and "--gid 101" in dockerfile + assert "USER 100:101" in dockerfile + assert "modelforge-agent:x:100:101" in dockerfile + assert "modelforge-agent:x:101:" in dockerfile + assert "COPY --from=builder /runtime/etc/passwd /etc/passwd" in dockerfile + assert "COPY --from=builder /runtime/etc/group /etc/group" in dockerfile assert ( "MODELFORGE_AGENT_ACCELERATOR_MODE: ${MODELFORGE_AGENT_ACCELERATOR_MODE:-nvidia}" in compose @@ -468,6 +515,8 @@ def test_release_builder_uses_reproducible_image_exports() -> None: script = (ROOT / "scripts" / "release_build.py").read_text("utf-8") assert '"--provenance=false"' in script assert '"--sbom=false"' in script + assert '"--pull"' in script + assert '"--no-cache"' in script assert "SOURCE_DATE_EPOCH" in script diff --git a/backend/tests/test_update_preflight.py b/backend/tests/test_update_preflight.py new file mode 100644 index 0000000..27cc04c --- /dev/null +++ b/backend/tests/test_update_preflight.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime, timedelta + +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from modelforge_api.domain.release import PRODUCT_VERSION, TARGET_SCHEMA_REVISION +from modelforge_api.main import app +from modelforge_api.settings import Settings, get_settings + +ADMIN_TOKEN = "update-preflight-admin-test-token-long-enough" # noqa: S105 - synthetic test credential +VIEWER_TOKEN = "mfop_" + "v" * 43 +OPERATOR_TOKEN = "mfop_" + "o" * 43 + + +def test_update_preflight_is_read_only_admin_only_and_marks_missing_approval_unknown() -> None: + identities = [ + { + "credential_id": role, + "subject": role, + "role": role, + "token_sha256": hashlib.sha256(token.encode()).hexdigest(), + "expires_at": (datetime.now(UTC) + timedelta(days=1)).isoformat(), + } + for role, token in (("viewer", VIEWER_TOKEN), ("operator", OPERATOR_TOKEN)) + ] + settings = Settings( + _env_file=None, + operator_api_key=SecretStr(ADMIN_TOKEN), + operator_identities=SecretStr(json.dumps(identities)), + ) + app.dependency_overrides[get_settings] = lambda: settings + client = TestClient(app) + try: + path = "/api/v1/admin/updates/preflight" + assert client.get(path).status_code == 401 + for token in (VIEWER_TOKEN, OPERATOR_TOKEN): + denied = client.get(path, headers={"X-ModelForge-Admin-Token": token}) + assert denied.status_code == 403 + + response = client.get(path, headers={"X-ModelForge-Admin-Token": ADMIN_TOKEN}) + assert response.status_code == 200 + payload = response.json() + + assert payload["read_only"] is True + assert payload["status"] == "INCOMPLETE" + assert payload["current"]["version"] == PRODUCT_VERSION + assert payload["current"]["schema_revision"] == TARGET_SCHEMA_REVISION + assert payload["approved"]["version"] is None + assert payload["approved"]["status"] == "UNKNOWN" + assert payload["approved"]["notes"]["status"] == "UNKNOWN" + assert payload["approved"]["notes"]["value"] is None + assert payload["impact"]["schema"]["status"] == "UNKNOWN" + assert payload["impact"]["schema"]["value"] is None + assert payload["impact"]["breaking"]["status"] == "UNKNOWN" + assert payload["impact"]["breaking"]["value"] is None + assert payload["backup"]["required"]["status"] == "UNKNOWN" + assert payload["backup"]["required"]["value"] is None + assert payload["provenance"]["current"]["status"] == "UNKNOWN" + assert payload["provenance"]["approved"]["status"] == "UNKNOWN" + assert payload["execution"] == { + "available": False, + "control": "HUMAN_CONTROLLED", + } + + assert client.post(path, headers={"X-ModelForge-Admin-Token": ADMIN_TOKEN}).status_code == 405 + finally: + client.close() + app.dependency_overrides.clear() diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 0000000..48828e5 --- /dev/null +++ b/clients/README.md @@ -0,0 +1,54 @@ +# Lightweight ModelForge clients + +These dependency-free clients are included in the installable release archive. They invoke an exact +capability, never a model, node or worker. They use explicit timeouts, bounded responses and typed +errors carrying the HTTP status and correlation ID. They do not automatically retry inference or +follow redirects with credentials. They are server-side consumer clients, not browser session SDKs. + +For Python 3.11+, copy `python/modelforge_client.py` into your application's import path. For Node.js +20+ JavaScript/TypeScript, copy `typescript/modelforge-client.mjs` and its adjacent `.d.mts` declaration +file into your source directory. No package registry or dependency installation is required. + +Configure the consumer process: + +```text +MODELFORGE_URL=https://modelforge.example.internal +MODELFORGE_TOKEN_FILE=/run/secrets/modelforge-token +MODELFORGE_CAPABILITY=rag.embedding@1 +``` + +Store only the issued service key in that protected file. HTTP is rejected by default; set +`MODELFORGE_ALLOW_HTTP=true` only for an explicitly trusted private lab. Origins cannot contain +userinfo, paths, query strings or fragments. TLS certificate verification stays enabled. + +```python +from modelforge_client import ModelForgeClient, ModelForgeError + +client = ModelForgeClient.from_env(timeout=60) +try: + print(client.health()) # Verifies identity and scope; does not start inference. + response = client.invoke({"input": ["A non-sensitive integration check"]}) +except ModelForgeError as error: + print(error.code, error.status, error.correlation_id) +``` + +```typescript +import { ModelForgeClient, ModelForgeError } from "./modelforge-client.mjs"; + +const client = await ModelForgeClient.fromEnv({ timeoutMs: 60000 }); +try { + console.log(await client.health()); + const response = await client.invoke({ input: ["A non-sensitive integration check"] }); +} catch (error) { + if (error instanceof ModelForgeError) + console.error(error.code, error.status, error.correlationId); + else throw error; +} +``` + +Use the payload defined by your selected capability. `health()` proves connectivity, not model +health or production eligibility. Do not automatically retry timed-out inference: the server may +have admitted the original request. Retain correlation/request IDs for diagnosis. + +Validation: backend `test_project_python_client.py` and +`node --test clients/typescript/modelforge-client.test.mjs`, both included in managed full validation. diff --git a/clients/python/modelforge_client.py b/clients/python/modelforge_client.py new file mode 100644 index 0000000..c8cecfb --- /dev/null +++ b/clients/python/modelforge_client.py @@ -0,0 +1,108 @@ +"""Dependency-free ModelForge capability client. Python 3.11+, no automatic inference retries.""" + +from __future__ import annotations + +import json +import math +import os +import re +from http.client import HTTPException +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 + + +class ModelForgeError(Exception): + def __init__(self, code: str, message: str, status: int = 0, correlation_id: str | None = None): + super().__init__(message) + self.code = code + self.status = status + self.correlation_id = correlation_id + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None: + return None + + +class ModelForgeClient: + def __init__(self, base_url: str, token: str, capability: str, *, timeout: float = 60, allow_http: bool = False): + url = urlsplit(base_url) + if (url.scheme not in ({"http", "https"} if allow_http else {"https"}) + or not url.hostname or url.username or url.password or url.query or url.fragment + or url.path not in {"", "/"}): + raise ValueError("Use a gateway origin without userinfo, path, query or fragment; HTTP requires allow_http=True") + if not re.fullmatch(r"[a-z][a-z0-9_.]*@[1-9][0-9]*", capability): + raise ValueError("Use an exact versioned capability key") + if not re.fullmatch(r"mf_[A-Za-z0-9_-]{48}", token): + raise ValueError("Use a scoped ModelForge service credential") + if not math.isfinite(timeout) or not 0 < timeout <= 300: + raise ValueError("timeout must be between 0 and 300 seconds") + self.base_url = base_url.rstrip("/") + self.capability = capability + self.timeout = timeout + self._token = token + self._opener = build_opener(_NoRedirect()) + + @classmethod + def from_env(cls, *, timeout: float = 60) -> ModelForgeClient: + with Path(os.environ["MODELFORGE_TOKEN_FILE"]).open("r", encoding="ascii") as handle: + token = handle.read(513).strip() + return cls(os.environ["MODELFORGE_URL"], token, os.environ["MODELFORGE_CAPABILITY"], + timeout=timeout, allow_http=os.getenv("MODELFORGE_ALLOW_HTTP") == "true") + + def _request(self, suffix: str, body: dict[str, Any] | None = None) -> Any: + path = f"/api/v1/capabilities/{quote(self.capability, safe='')}/{suffix}" + request = Request(self.base_url + path, data=json.dumps(body).encode() if body is not None else None, + headers={"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"}, + method="POST" if body is not None else "GET") + try: + response = self._opener.open(request, timeout=self.timeout) + except HTTPError as error: + correlation = error.headers.get("X-Correlation-ID") + try: + with error: + raw = error.read(MAX_RESPONSE_BYTES + 1) + except (TimeoutError, OSError, HTTPException): + raise ModelForgeError("TRANSPORT_ERROR", "Gateway response failed or timed out", + error.code, correlation) from None + try: + payload = json.loads(raw) if len(raw) <= MAX_RESPONSE_BYTES else {} + except (ValueError, UnicodeError): + payload = {} + if not isinstance(payload, dict): + payload = {} + nested_error = payload.get("error") + error_payload = nested_error if isinstance(nested_error, dict) else payload + raise ModelForgeError( + str(error_payload.get("code", "HTTP_ERROR")), + str(error_payload.get("message", "Gateway request refused")), + error.code, + error_payload.get("correlation_id") or correlation, + ) from None + except (URLError, TimeoutError, OSError, HTTPException): + raise ModelForgeError("TRANSPORT_ERROR", "Gateway connection failed or timed out") from None + try: + with response: + raw = response.read(MAX_RESPONSE_BYTES + 1) + correlation = response.headers.get("X-Correlation-ID") + except (TimeoutError, OSError, HTTPException): + raise ModelForgeError("TRANSPORT_ERROR", "Gateway response failed or timed out") from None + if len(raw) > MAX_RESPONSE_BYTES: + raise ModelForgeError("RESPONSE_TOO_LARGE", "Gateway response exceeds the client limit", correlation_id=correlation) + try: + return json.loads(raw) + except (ValueError, UnicodeError): + raise ModelForgeError("INVALID_RESPONSE", "Gateway response is not JSON", correlation_id=correlation) from None + + def health(self) -> dict[str, Any]: + """Authenticate the exact scope without executing inference.""" + return self._request("check") + + def invoke(self, payload: dict[str, Any]) -> Any: + """Execute once. The client never changes capability, deployment or scheduling priority.""" + return self._request("invoke", payload) diff --git a/clients/typescript/modelforge-client.d.mts b/clients/typescript/modelforge-client.d.mts new file mode 100644 index 0000000..4469599 --- /dev/null +++ b/clients/typescript/modelforge-client.d.mts @@ -0,0 +1,17 @@ +export interface ClientOptions { timeoutMs?: number; allowHttp?: boolean; } +export interface Connectivity { client_id: string; project_key: string | null; capability: string; connected: true; correlation_id: string; } +export class ModelForgeError extends Error { + readonly code: string; + readonly status: number; + readonly correlationId: string | null; + constructor(code: string, message: string, status?: number, correlationId?: string | null); +} +export class ModelForgeClient { + readonly baseUrl: string; + readonly capability: string; + readonly timeoutMs: number; + constructor(baseUrl: string, token: string, capability: string, options?: ClientOptions); + static fromEnv(options?: ClientOptions): Promise; + health(): Promise; + invoke(payload: Record): Promise; +} diff --git a/clients/typescript/modelforge-client.mjs b/clients/typescript/modelforge-client.mjs new file mode 100644 index 0000000..da17106 --- /dev/null +++ b/clients/typescript/modelforge-client.mjs @@ -0,0 +1,69 @@ +import { open } from "node:fs/promises"; + +export class ModelForgeError extends Error { + constructor(code, message, status = 0, correlationId = null) { + super(message); this.name = "ModelForgeError"; this.code = code; + this.status = status; this.correlationId = correlationId; + } +} + +/** Node.js 20+. Capability-only, bounded requests, no redirects or inference retries. */ +export class ModelForgeClient { + #token; + constructor(baseUrl, token, capability, { timeoutMs = 60000, allowHttp = false } = {}) { + const url = new URL(baseUrl); + if (!((allowHttp && url.protocol === "http:") || url.protocol === "https:") || url.username || url.password || url.search || url.hash || url.pathname !== "/") throw new TypeError("Use a gateway origin; HTTP requires allowHttp: true"); + if (!/^[a-z][a-z0-9_.]*@[1-9][0-9]*$/.test(capability)) throw new TypeError("Use an exact versioned capability key"); + if (!/^mf_[A-Za-z0-9_-]{48}$/.test(token)) throw new TypeError("Use a scoped ModelForge service credential"); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300000) throw new TypeError("timeoutMs must be between 0 and 300000"); + this.baseUrl = url.origin; this.capability = capability; this.timeoutMs = timeoutMs; this.#token = token; + } + static async fromEnv(options = {}) { + const file = await open(process.env.MODELFORGE_TOKEN_FILE, "r"); + let token; + try { const buffer = Buffer.alloc(513); const { bytesRead } = await file.read(buffer); token = buffer.subarray(0, bytesRead).toString("utf8").trim(); } + finally { await file.close(); } + return new ModelForgeClient(process.env.MODELFORGE_URL, token, process.env.MODELFORGE_CAPABILITY, { allowHttp: process.env.MODELFORGE_ALLOW_HTTP === "true", ...options }); + } + async #request(suffix, payload) { + let response; + let raw; + try { + response = await fetch(`${this.baseUrl}/api/v1/capabilities/${encodeURIComponent(this.capability)}/${suffix}`, { + method: payload === undefined ? "GET" : "POST", redirect: "error", credentials: "omit", + signal: AbortSignal.timeout(this.timeoutMs), + headers: { Authorization: `Bearer ${this.#token}`, "Content-Type": "application/json" }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + const reader = response.body?.getReader(); + const chunks = []; let size = 0; + if (reader) { try { while (true) { + const { done, value } = await reader.read(); if (done) break; + size += value.length; + if (size > 8 * 1024 * 1024) { await reader.cancel(); throw new ModelForgeError("RESPONSE_TOO_LARGE", "Gateway response exceeds the client limit"); } + chunks.push(value); + } } finally { reader.releaseLock(); } } + raw = Buffer.concat(chunks).toString("utf8"); + } catch (error) { + if (error instanceof ModelForgeError) throw error; + throw new ModelForgeError("TRANSPORT_ERROR", "Gateway connection failed or timed out"); + } + let data; + try { data = JSON.parse(raw); } catch { throw new ModelForgeError("INVALID_RESPONSE", "Gateway response is not JSON", response.status, response.headers.get("X-Correlation-ID")); } + if (!response.ok) { + const nestedError = data?.error; + const error = nestedError !== null && typeof nestedError === "object" ? nestedError : data; + const code = typeof error?.code === "string" ? error.code : "HTTP_ERROR"; + const message = typeof error?.message === "string" ? error.message : "Gateway request refused"; + throw new ModelForgeError( + code, + message, + response.status, + error?.correlation_id ?? response.headers.get("X-Correlation-ID"), + ); + } + return data; + } + health() { return this.#request("check"); } + invoke(payload) { return this.#request("invoke", payload); } +} diff --git a/clients/typescript/modelforge-client.test.mjs b/clients/typescript/modelforge-client.test.mjs new file mode 100644 index 0000000..c6ac84c --- /dev/null +++ b/clients/typescript/modelforge-client.test.mjs @@ -0,0 +1,54 @@ +import { strict as assert } from "node:assert"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { test } from "node:test"; +import { ModelForgeClient, ModelForgeError } from "./modelforge-client.mjs"; + +test("capability scope, typed errors, no retries, no redirect credential forwarding", async () => { + const seen = []; + const server = createServer(async (req, res) => { + seen.push({ path: req.url, authorization: req.headers.authorization }); + if (req.method === "GET") { res.end('{"connected":true}'); return; } + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks)); + if (body.redirect) { res.writeHead(307, { Location: "/must-not-follow" }); res.end(); return; } + res.writeHead(503, { "Content-Type": "application/json" }); + if (body.flat_error) { + res.end('{"code":"LEGACY_ERROR","message":"Legacy error","correlation_id":"legacy-case"}'); + return; + } + res.end(JSON.stringify({ error: { + code: "NO_ELIGIBLE_NODE", message: "No node", correlation_id: "sdk-case", + details: { input: "private benchmark input that must remain redacted" }, + } })); + }); + server.listen(0, "127.0.0.1"); await once(server, "listening"); + const token = "mf_" + "k".repeat(48); + try { + const client = new ModelForgeClient(`http://127.0.0.1:${server.address().port}`, token, "rag.embedding@1", { allowHttp: true, timeoutMs: 2000 }); + assert.deepEqual(await client.health(), { connected: true }); + await assert.rejects(client.invoke({ input: ["test"] }), (error) => { + assert.ok(error instanceof ModelForgeError); + assert.equal(error.code, "NO_ELIGIBLE_NODE"); + assert.equal(error.message, "No node"); + assert.equal(error.status, 503); + assert.equal(error.correlationId, "sdk-case"); + assert.ok(!error.message.includes("private benchmark input")); + return true; + }); + await assert.rejects(client.invoke({ flat_error: true }), (error) => + error instanceof ModelForgeError + && error.code === "LEGACY_ERROR" + && error.message === "Legacy error" + && error.correlationId === "legacy-case"); + await assert.rejects(client.invoke({ redirect: true }), (error) => error instanceof ModelForgeError && error.code === "TRANSPORT_ERROR"); + assert.equal(seen.length, 4); + assert.ok(seen.every(({ path, authorization }) => path.startsWith("/api/v1/capabilities/rag.embedding%401/") && authorization === `Bearer ${token}`)); + assert.ok(!JSON.stringify(client).includes(token)); + } finally { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); } +}); + +test("unsafe origins and credential classes fail before network access", () => { + for (const origin of ["http://lab.invalid", "https://user:password@lab.invalid", "https://lab.invalid/path", "https://lab.invalid/?q=key"]) assert.throws(() => new ModelForgeClient(origin, "mf_" + "k".repeat(48), "rag.embedding@1")); + assert.throws(() => new ModelForgeClient("https://lab.invalid", "mfop_" + "k".repeat(43), "rag.embedding@1")); +}); diff --git a/config/capabilities/creative.resource.v1.yaml b/config/capabilities/creative.resource.v1.yaml new file mode 100644 index 0000000..eb8022e --- /dev/null +++ b/config/capabilities/creative.resource.v1.yaml @@ -0,0 +1,17 @@ +capability: creative.resource +version: 1 +description: Cross-project capacity coordination for creative workloads executed by registered external providers. +input_schema: {type: object} +output_schema: {type: object} +modalities: {input: [text], output: [json]} +languages: [nl, en] +streaming: false +structured_output: true +quality_metrics: [admission_correctness, allocation_reconciliation] +upgrade_class: behavioral +fallback: {allowed: false, mode: hard_fail} +privacy: {classification: confidential, allow_persistence: false, allow_logging_payloads: false, allow_network_egress: false} +resources: {accelerator_required: true, cpu_fallback_allowed: false} +production_priority: interactive +default_residency: load_on_demand +estate: {category: GENERATION, purpose: Cross-project GPU coordination, stability: experimental, resource_class: EXCLUSIVE_GPU, evaluation_type: generation, consumers: [privatemuse], payload_limits: {max_bytes: 16384, max_batch_count: 1}} diff --git a/config/public-candidate-unfixed-vulnerabilities.json b/config/public-candidate-unfixed-vulnerabilities.json index aea4365..d4b5bae 100644 --- a/config/public-candidate-unfixed-vulnerabilities.json +++ b/config/public-candidate-unfixed-vulnerabilities.json @@ -1,23 +1,6 @@ { "schema_version": 1, - "reviewed_at": "2026-09-01", - "review_reference": "docs/quality/V1_2_1_RELEASE_VERIFICATION.md", - "vulnerabilities": [ - {"vulnerability_id": "CVE-2026-41992", "package": "gzip", "installed_version": "1.13-1", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-54369", "package": "libacl1", "installed_version": "2.3.2-2+b1", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2025-69720", "package": "libncursesw6", "installed_version": "6.5+20250216-2", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-11822", "package": "libsqlite3-0", "installed_version": "3.46.1-7+deb13u1", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-11824", "package": "libsqlite3-0", "installed_version": "3.46.1-7+deb13u1", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2025-69720", "package": "libtinfo6", "installed_version": "6.5+20250216-2", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2025-69720", "package": "ncurses-base", "installed_version": "6.5+20250216-2", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2025-69720", "package": "ncurses-bin", "installed_version": "6.5+20250216-2", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-13221", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "CRITICAL"}, - {"vulnerability_id": "CVE-2026-42496", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "CRITICAL"}, - {"vulnerability_id": "CVE-2026-8376", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "CRITICAL"}, - {"vulnerability_id": "CVE-2026-42497", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-48962", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-57432", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-57433", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "HIGH"}, - {"vulnerability_id": "CVE-2026-9538", "package": "perl-base", "installed_version": "5.40.1-6", "severity": "HIGH"} - ] + "reviewed_at": "2026-09-12", + "review_reference": "docs/quality/RC_NODE_IMAGE_REMEDIATION.md", + "vulnerabilities": [] } diff --git a/config/release/rc-program.json b/config/release/rc-program.json new file mode 100644 index 0000000..533511f --- /dev/null +++ b/config/release/rc-program.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "target_version": "1.3.0-rc.1", + "baseline_commit": "9b61f23ea3ed102ec58c9966c027b50ebd6a22b6", + "ledger": "docs/quality/RC_GAP_MATRIX.md", + "roadmap": "docs/product/RC_EXECUTION_ROADMAP.md", + "packets": [ + {"id":"P00","depends_on":[],"gaps":["RC-SEC-01","RC-SEC-02","RC-DEL-01","RC-DB-01"]}, + {"id":"P01","depends_on":["P00"],"gaps":["RC-CFG-01","RC-CFG-02","RC-CFG-03","RC-AUTH-01","RC-AUTH-02"]}, + {"id":"P02","depends_on":["P00","P01"],"gaps":["RC-CAP-01","RC-SUP-01"]}, + {"id":"P03","depends_on":["P02","P06"],"gaps":["RC-LAB-01"]}, + {"id":"P04","depends_on":["P03"],"gaps":["RC-LAB-02","RC-LAB-03"]}, + {"id":"P05","depends_on":["P04"],"gaps":["RC-LIF-01","RC-LIF-02"]}, + {"id":"P06","depends_on":["P00","P01"],"gaps":["RC-NOD-01","RC-NOD-02","RC-NOD-03","RC-SCH-01","RC-SCH-02"]}, + {"id":"P07","depends_on":["P00","P01","P03","P06"],"gaps":["RC-OPS-01","RC-OPS-02","RC-OPS-03","RC-OPS-04","RC-OPS-05"]}, + {"id":"P08","depends_on":["P01","P07"],"gaps":["RC-REC-01","RC-UPD-01","RC-UPD-02"]}, + {"id":"P09","depends_on":["P01","P02","P03","P04","P05","P06","P07","P08"],"gaps":["RC-SEA-01","RC-UX-01","RC-UX-02","RC-UX-03","RC-UX-04"]}, + {"id":"P10","depends_on":["P01","P02","P03","P04","P05","P06","P07","P08"],"gaps":["RC-API-01","RC-API-02"]}, + {"id":"P11","depends_on":["P09","P10"],"gaps":["RC-SEC-03","RC-DEL-02"]}, + {"id":"P15A","depends_on":["P11"],"gaps":["RC-REL-01","RC-REL-04"]}, + {"id":"P12","depends_on":["P00","P01","P06","P11","P15A"],"gaps":["RC-RC-01"]}, + {"id":"P13","depends_on":["P02","P03","P04","P05","P06","P12"],"gaps":["RC-PRJ-01","RC-PRJ-02","RC-PRJ-03","RC-CON-01","RC-CON-02"]}, + {"id":"P14","depends_on":["P07","P08","P09","P10","P11","P12","P13"],"gaps":["RC-QUAL-01","RC-QUAL-02","RC-QUAL-03","RC-QUAL-04","RC-QUAL-05","RC-QUAL-06"]}, + {"id":"P15","depends_on":["P11","P12","P13","P14","P15A"],"gaps":["RC-REL-02","RC-REL-03","RC-DOC-01"]} + ], + "deferred_gaps": ["RC-POST-01","RC-POST-02","RC-POST-03","RC-POST-04","RC-POST-05"] +} diff --git a/config/security/accepted-risks.json b/config/security/accepted-risks.json new file mode 100644 index 0000000..9746727 --- /dev/null +++ b/config/security/accepted-risks.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "accepted_risks": [] +} diff --git a/config/security/build-toolchain.lock b/config/security/build-toolchain.lock new file mode 100644 index 0000000..49019d3 --- /dev/null +++ b/config/security/build-toolchain.lock @@ -0,0 +1,13 @@ +# Hash-locked release and CI build toolchain, verified against the official PyPI JSON API. +# msgpack hashes cover CPython 3.11-3.14 glibc x86_64/aarch64 and CPython 3.12-3.14 Alpine musl x86_64/aarch64. +editables==0.5 --hash=sha256:61e5ffa82629e0d8bfe09bc44a07db3c1ab8ed1ce78a6980732870f19b5e7d4c +hatchling==1.32.4 --hash=sha256:08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c +msgpack==1.2.2 --hash=sha256:f11e09f10210a91c169e39c7a5a1f9090eaa73ad75555fafad5023c3053c47ba --hash=sha256:b9b0c1f2aa7b0026b4bd50718100e8b04175e4f36e160aa852502377b5e572e7 --hash=sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923 --hash=sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba --hash=sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9 --hash=sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc --hash=sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3 --hash=sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367 --hash=sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab --hash=sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b --hash=sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f --hash=sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe --hash=sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54 --hash=sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b +packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pathspec==1.1.1 --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 +pip==26.2.1 --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e +pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +setuptools==84.0.0 --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 +tomlkit==0.14.0 --hash=sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 +trove-classifiers==2026.9.21.13 --hash=sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17 +wheel==0.48.0 --hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab diff --git a/config/security/gitleaks-false-positives.json b/config/security/gitleaks-false-positives.json new file mode 100644 index 0000000..ce111bb --- /dev/null +++ b/config/security/gitleaks-false-positives.json @@ -0,0 +1,24 @@ +[ + { + "scanner": "gitleaks", + "rule_id": "generic-api-key", + "fingerprint_sha256": "b25524088aadca143f5b33de32f1af64497427a4b465a5caf0ce1e49bf7c16d2", + "classification": "FALSE_POSITIVE", + "owner": "ModelForge security owner", + "classified_on": "2026-09-30", + "review_on": "2026-10-30", + "tracking": "RC-DEL-02 supply-chain gate review", + "reason": "Historical unit-test-only AES fixture; the decoded bytes are synthetic test material and were never a credential or production backup key." + }, + { + "scanner": "gitleaks", + "rule_id": "generic-api-key", + "fingerprint_sha256": "6114f9e29b47c068fb0913c6465915044ff9fef7dcf53d4a60ff1776c8404ddb", + "classification": "FALSE_POSITIVE", + "owner": "ModelForge security owner", + "classified_on": "2026-09-30", + "review_on": "2026-10-30", + "tracking": "RC-DEL-02 supply-chain gate review", + "reason": "Historical unit-test-only AES fixture; the decoded bytes are synthetic test material and were never a credential or production backup key." + } +] diff --git a/config/security/pip-audit-toolchain.lock b/config/security/pip-audit-toolchain.lock new file mode 100644 index 0000000..2e5ee71 --- /dev/null +++ b/config/security/pip-audit-toolchain.lock @@ -0,0 +1,34 @@ +# Fully resolved pip-audit 2.10.1 scanner toolchain for CPython 3.12 / Linux x86_64. +# Generated only from https://pypi.org/simple with: +# python -m pip download --only-binary=:all: --platform manylinux2014_x86_64 \ +# --python-version 312 --implementation cp --abi cp312 pip-audit==2.10.1 +# Every selected official wheel is pinned and hash-checked again by pip at managed-gate install. +boolean.py==5.0 --hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9 +cachecontrol==0.14.4 --hash=sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b +certifi==2026.7.22 --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 +charset-normalizer==3.5.1 --hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 +cyclonedx-python-lib==11.12.0 --hash=sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54 +defusedxml==0.7.1 --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 +filelock==3.32.4 --hash=sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd +idna==3.19 --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 +license-expression==30.4.4 --hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 +markdown-it-py==4.2.0 --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a +mdurl==0.1.2 --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 +msgpack==1.2.2 --hash=sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923 +packageurl-python==0.17.6 --hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9 +packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pip==26.2.1 --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e +pip-api==0.0.34 --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb +pip-audit==2.10.1 --hash=sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a +pip-requirements-parser==32.0.1 --hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 +platformdirs==4.11.5 --hash=sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b +py-serializable==2.1.0 --hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304 +pygments==2.21.0 --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 +pyparsing==3.3.2 --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d +requests==2.34.2 --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 +rich==15.0.0 --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb +sortedcontainers==2.4.0 --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 +tomli==2.4.1 --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 +tomli-w==1.2.0 --hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 +typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +urllib3==2.7.0 --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 diff --git a/deploy/postgres/provision-existing-1.2.1.sql b/deploy/postgres/provision-existing-1.2.1.sql index b207268..5a431a1 100644 --- a/deploy/postgres/provision-existing-1.2.1.sql +++ b/deploy/postgres/provision-existing-1.2.1.sql @@ -9,6 +9,26 @@ \getenv runtime_password MODELFORGE_RUNTIME_DB_PASSWORD \getenv database_name MODELFORGE_POSTGRES_DB +-- PostgreSQL permanently protects the cluster bootstrap superuser (OID 10): even another +-- superuser cannot remove that role's SUPERUSER attribute. v1.2.1 installations initialized with +-- POSTGRES_USER=modelforge therefore need a fresh cluster plus logical restore. Refuse before +-- creating roles or changing privileges; direct pg_authid mutation is not a supported shortcut. +do $$ +begin + if exists ( + select 1 + from pg_catalog.pg_roles + where rolname = 'modelforge' and oid = 10 + ) then + raise exception using + errcode = '55000', + message = 'modelforge is the protected PostgreSQL bootstrap superuser (OID 10)', + detail = 'PostgreSQL cannot demote the bootstrap superuser.', + hint = 'Follow docs/UPGRADE.md: create a fresh cluster under the retained admin, logically restore with --no-owner --no-privileges, then run migration 0024.'; + end if; +end +$$; + select pg_catalog.format( 'create role %I login password %L superuser createdb createrole inherit replication bypassrls', :'admin_role', :'admin_password' diff --git a/deploy/validation/Caddyfile b/deploy/validation/Caddyfile new file mode 100644 index 0000000..2a541a3 --- /dev/null +++ b/deploy/validation/Caddyfile @@ -0,0 +1,11 @@ +https://modelforge.example.com:8443 { + tls /run/tls/fullchain.pem /run/tls/privkey.pem + + @api path /api/* /v1/* + handle @api { + reverse_proxy api:8000 + } + handle { + reverse_proxy web:3000 + } +} diff --git a/docker-compose.backup.yml b/docker-compose.backup.yml index 2e24617..bac766f 100644 --- a/docker-compose.backup.yml +++ b/docker-compose.backup.yml @@ -26,6 +26,7 @@ services: done environment: MODELFORGE_BASE_URL: ${MODELFORGE_BACKUP_BASE_URL:-http://api:8000} + MODELFORGE_BACKUP_ADMIN_API_KEY: ${MODELFORGE_BACKUP_ADMIN_API_KEY:-} MODELFORGE_OPERATOR_API_KEY: ${MODELFORGE_OPERATOR_API_KEY:-} MODELFORGE_BACKUP_ID_PREFIX: ${MODELFORGE_BACKUP_ID_PREFIX:-scheduled} MODELFORGE_BACKUP_INTERVAL_SECONDS: ${MODELFORGE_BACKUP_INTERVAL_SECONDS:-86400} diff --git a/docker-compose.dr.yml b/docker-compose.dr.yml index 2d748ce..59cd620 100644 --- a/docker-compose.dr.yml +++ b/docker-compose.dr.yml @@ -32,7 +32,8 @@ services: dr-api: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile env_file: - path: .env required: false diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 02d4c9c..2bb3eea 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -8,8 +8,8 @@ # working tree. It still requires separate database secrets; this overlay additionally turns on # every production startup rule and requires immutable release identities. # -# Every secret here uses the ${VAR:?message} form, so Compose refuses to render the project at all -# when one is missing. Failing before the first container starts is the cheapest possible failure. +# Database, backup and origin requirements fail Compose rendering when absent. Human authentication +# accepts either a bootstrap key or a named administrator registry, validated before API startup. services: postgres: environment: @@ -37,7 +37,8 @@ services: api: image: ${MODELFORGE_API_IMAGE:-modelforge-api:${MODELFORGE_VERSION:?set an exact release version, for example 1.2.1}} build: - context: ./backend + context: . + dockerfile: backend/Dockerfile args: MODELFORGE_VERSION: ${MODELFORGE_VERSION:-0.0.0} MODELFORGE_COMMIT: ${MODELFORGE_COMMIT:-} @@ -47,7 +48,8 @@ services: # production deployment silently runs under development semantics. MODELFORGE_ENV: production MODELFORGE_DATABASE_URL: ${MODELFORGE_RUNTIME_DATABASE_URL:?set a URL for only the modelforge_runtime role} - MODELFORGE_OPERATOR_API_KEY: ${MODELFORGE_OPERATOR_API_KEY:?generate an operator API key; see docs/CONFIGURATION.md} + # Startup validates a legacy key OR an unexpired named administrator. + MODELFORGE_OPERATOR_API_KEY: ${MODELFORGE_OPERATOR_API_KEY:-} MODELFORGE_BACKUP_ENCRYPTION_KEY: ${MODELFORGE_BACKUP_ENCRYPTION_KEY:?generate a backup encryption key; without it no backup can be produced} MODELFORGE_CORS_ORIGINS: ${MODELFORGE_CORS_ORIGINS:?set the exact console origin; a wildcard is refused in production} # Remote model code is never executed in production. Stated here as well as enforced at diff --git a/docker-compose.rc-ingress.yml b/docker-compose.rc-ingress.yml new file mode 100644 index 0000000..4db1034 --- /dev/null +++ b/docker-compose.rc-ingress.yml @@ -0,0 +1,26 @@ +# Isolated candidate ingress. Use only with a modelforge-validation-* Compose project and the +# production overlay after scripts/rc_isolation_preflight.py approves the rendered plan. +# The backend network is project-private; an existing reverse proxy may be attached +# only to the project-scoped ingress network after the stack passes its preflight. +services: + postgres: + networks: + - backend + redis: + networks: + - backend + migrate: + networks: + - backend + api: + networks: + - backend + - ingress + web: + networks: + - ingress + +networks: + backend: + internal: true + ingress: {} diff --git a/docker-compose.validation-tls.yml b/docker-compose.validation-tls.yml new file mode 100644 index 0000000..19a2059 --- /dev/null +++ b/docker-compose.validation-tls.yml @@ -0,0 +1,33 @@ +# Dedicated candidate HTTPS entry point. Render with the base, production, and +# candidate ingress overlays, then run the isolation preflight before startup. +# The key files must be copies confined to the candidate root, never an NPM bind. +services: + tls: + image: caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d + command: ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] + environment: + XDG_CONFIG_HOME: /config + XDG_DATA_HOME: /data + volumes: + - ./deploy/validation/Caddyfile:/etc/caddy/Caddyfile:ro + - ${MODELFORGE_CANDIDATE_TLS_DIR:?set a certificate copy directory inside the candidate root}/fullchain.pem:/run/tls/fullchain.pem:ro + - ${MODELFORGE_CANDIDATE_TLS_DIR:?set a certificate copy directory inside the candidate root}/privkey.pem:/run/tls/privkey.pem:ro + networks: + - ingress + ports: + - "0.0.0.0:18444:8443" + read_only: true + tmpfs: + - /config:size=4m,mode=0700 + - /data:size=4m,mode=0700 + - /tmp:size=4m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + depends_on: + api: + condition: service_healthy + web: + condition: service_started + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 6c3c77d..6526938 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,10 @@ services: POSTGRES_DB: ${MODELFORGE_POSTGRES_DB:-modelforge} POSTGRES_USER: ${MODELFORGE_POSTGRES_ADMIN_USER:-postgres} POSTGRES_PASSWORD: ${MODELFORGE_POSTGRES_ADMIN_PASSWORD:?set a bootstrap-only PostgreSQL admin password} + # Keep the ModelForge names available to the explicit existing-cluster provisioning script. + # POSTGRES_USER/PASSWORD are consumed by the upstream image; psql's \getenv reads these. + MODELFORGE_POSTGRES_ADMIN_USER: ${MODELFORGE_POSTGRES_ADMIN_USER:-postgres} + MODELFORGE_POSTGRES_ADMIN_PASSWORD: ${MODELFORGE_POSTGRES_ADMIN_PASSWORD:?set a bootstrap-only PostgreSQL admin password} MODELFORGE_MIGRATION_DB_PASSWORD: ${MODELFORGE_MIGRATION_DB_PASSWORD:?set the non-superuser migration role password} MODELFORGE_RUNTIME_DB_PASSWORD: ${MODELFORGE_RUNTIME_DB_PASSWORD:?set the non-owner runtime role password} volumes: @@ -37,7 +41,8 @@ services: api: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile args: # Build identity, so any image built from this file can say where it came from. An argument # that is never passed stays empty and is reported as null rather than guessed. @@ -57,7 +62,11 @@ services: MODELFORGE_NODE_LIVENESS_MONITOR_ENABLED: "true" MODELFORGE_REGISTRY_SEED_ON_STARTUP: "true" MODELFORGE_OPERATOR_API_KEY: ${MODELFORGE_OPERATOR_API_KEY:-} + MODELFORGE_OPERATOR_IDENTITIES: ${MODELFORGE_OPERATOR_IDENTITIES:-} + MODELFORGE_OPERATOR_PASSWORD_HASH: ${MODELFORGE_OPERATOR_PASSWORD_HASH:-} MODELFORGE_HF_TOKEN: ${MODELFORGE_HF_TOKEN:-} + MODELFORGE_SECRET_SLOT_ROOT_KEY: ${MODELFORGE_SECRET_SLOT_ROOT_KEY:-} + MODELFORGE_SECRET_SLOT_ROOT_KEY_ID: ${MODELFORGE_SECRET_SLOT_ROOT_KEY_ID:-hf-control-plane-1} MODELFORGE_NODE_STALE_AFTER_SECONDS: ${MODELFORGE_NODE_STALE_AFTER_SECONDS:-30} MODELFORGE_NODE_OFFLINE_AFTER_SECONDS: ${MODELFORGE_NODE_OFFLINE_AFTER_SECONDS:-90} MODELFORGE_LIVENESS_POLL_INTERVAL_SECONDS: ${MODELFORGE_LIVENESS_POLL_INTERVAL_SECONDS:-5} @@ -65,6 +74,17 @@ services: MODELFORGE_LIFECYCLE_RECONCILIATION_ENABLED: ${MODELFORGE_LIFECYCLE_RECONCILIATION_ENABLED:-true} MODELFORGE_MIGRATION_RECONCILIATION_ENABLED: ${MODELFORGE_MIGRATION_RECONCILIATION_ENABLED:-true} MODELFORGE_OBSERVABILITY_MONITOR_ENABLED: ${MODELFORGE_OBSERVABILITY_MONITOR_ENABLED:-true} + MODELFORGE_ALERT_NOTIFICATIONS_ENABLED: ${MODELFORGE_ALERT_NOTIFICATIONS_ENABLED:-false} + MODELFORGE_ALERT_WEBHOOK_URL: ${MODELFORGE_ALERT_WEBHOOK_URL:-} + MODELFORGE_ALERT_WEBHOOK_ALLOWED_HOSTS: ${MODELFORGE_ALERT_WEBHOOK_ALLOWED_HOSTS:-} + MODELFORGE_ALERT_WEBHOOK_MIN_SEVERITY: ${MODELFORGE_ALERT_WEBHOOK_MIN_SEVERITY:-WARNING} + MODELFORGE_ALERT_NOTIFICATION_TIMEOUT_SECONDS: ${MODELFORGE_ALERT_NOTIFICATION_TIMEOUT_SECONDS:-5.0} + MODELFORGE_ALERT_NOTIFICATION_MAX_ATTEMPTS: ${MODELFORGE_ALERT_NOTIFICATION_MAX_ATTEMPTS:-8} + MODELFORGE_ALERT_NOTIFICATION_BATCH_SIZE: ${MODELFORGE_ALERT_NOTIFICATION_BATCH_SIZE:-20} + MODELFORGE_ALERT_NOTIFICATION_LEASE_SECONDS: ${MODELFORGE_ALERT_NOTIFICATION_LEASE_SECONDS:-60} + MODELFORGE_ALERT_NOTIFICATION_RETRY_BASE_SECONDS: ${MODELFORGE_ALERT_NOTIFICATION_RETRY_BASE_SECONDS:-15} + MODELFORGE_MODEL_UPDATE_MONITOR_ENABLED: ${MODELFORGE_MODEL_UPDATE_MONITOR_ENABLED:-true} + MODELFORGE_MODEL_UPDATE_CHECK_INTERVAL_SECONDS: ${MODELFORGE_MODEL_UPDATE_CHECK_INTERVAL_SECONDS:-86400} MODELFORGE_RECOVERY_RECONCILIATION_ENABLED: ${MODELFORGE_RECOVERY_RECONCILIATION_ENABLED:-true} MODELFORGE_SOURCE_COMMIT: ${MODELFORGE_SOURCE_COMMIT:-} MODELFORGE_SOURCE_REFERENCE: ${MODELFORGE_SOURCE_REFERENCE:-} @@ -105,7 +125,8 @@ services: migrate: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile args: MODELFORGE_VERSION: ${MODELFORGE_VERSION:-0.0.0} MODELFORGE_COMMIT: ${MODELFORGE_COMMIT:-} diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index f5604bb..6546745 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,32 +1,31 @@ -# Compatibility matrix — v1.2.1 plus unreleased RC hardening +# Compatibility matrix — v1.2.2 source/tag contract Nothing here is implied. The executable contract lives in `backend/src/modelforge_api/domain/release.py`; startup and release tests enforce it. -> RC development note: the unreleased product branch now targets additive schema -> `20260830_0024`, accepts `20260828_0022` and auth-only `20260830_0023` as upgrade sources, and refuses to serve until the -> audit-chain migration completes. Deployed stable v1.2.1 remains frozen on `20260828_0022`; the -> released matrix below continues to describe that production artifact until the RC is tagged. -> Selecting and stamping the post-0024 product version is an explicit pending release tranche; the -> temporary repository `1.2.1` constant must not be presented as a released schema-0024 artifact. +> Publication and deployment note: this file records the v1.2.2 source/tag compatibility contract. +> At the 2026-09-25 reconciliation checkpoint, Gitea's newest published Release object was v1.2.1; +> v1.2.2 existed as a tag. The observed production record was v1.2.3/schema-0032. The RC source +> is version 1.3.0-rc.1 with schema target 20260924_0039; it must not be treated as deployed until +> its own gates pass. ## Product and database | Contract | Value | | --- | --- | -| Version | `1.2.1` | +| Version | `1.2.2` | | Channel | `stable` | | Version source | repository `VERSION` | -| Runtime schema | `20260830_0023` | -| Direct upgrade-source schema | `20260827_0021`, `20260828_0022` | +| Runtime schema | `20260830_0024` | +| Direct upgrade-source schema | `20260827_0021`, `20260828_0022`, `20260830_0023` | | Minimum direct upgrade | `v1.0.0` | | PostgreSQL | 16+; Compose ships 17 | | SQLite | unit tests only; **NOT SUPPORTED FOR PRODUCTION** | -The hardened candidate serves only on schema 0023. The upgrade tool accepts 0021 or the production -0022 head, applies the intervening revisions, validates all existing node credential scopes, and -reaches 0023 before the API starts. PostgreSQL is the only production database contract; SQLite is -used only to exercise DDL mechanics in isolated tests. +v1.2.2 serves only on schema 0024. The upgrade tool accepts 0021, the production 0022 head or the +intermediate auth-only 0023 head, applies the intervening revisions, validates existing node +credential scopes and establishes the PostgreSQL-owned audit boundary before the API starts. +PostgreSQL is the only production database contract; SQLite is used only for isolated tests. Compatibility answers remain typed and fail closed: @@ -45,19 +44,22 @@ Compatibility answers remain typed and fail closed: | Accepted protocols | 1 | The Node Agent and Runtime Worker remain protocol-compatible with v1.0.0. Release images should -still be upgraded together so their product and provenance identity is v1.2.1. +still be upgraded together so their product and provenance identity is v1.2.2. Because the Runtime +Worker dependency update is behavioral, existing profiles require fresh LAB evidence before +production promotion. ## Upgrade matrix | From | Schema | To | Supported | | --- | --- | --- | --- | -| `v1.0.0` | `20260827_0021` | current candidate / `20260830_0023` | yes; backup and rehearsal required | -| `v1.1.0` | `20260828_0022` | current candidate / `20260830_0023` | yes; scope validation migration | -| `v1.1.1` | `20260828_0022` | current candidate / `20260830_0023` | yes; scope validation migration | -| `v1.2.0` | `20260828_0022` | current candidate / `20260830_0023` | yes; scope validation migration | -| `v1.2.1` | `20260828_0022` | current candidate / `20260830_0023` | yes; scope validation migration | -| current candidate | `20260830_0023` | current candidate | yes; already current | -| older than `v1.0.0` | any | `v1.2.1` | no direct upgrade | +| `v1.0.0` | `20260827_0021` | `v1.2.2` / `20260830_0024` | yes; backup and rehearsal required | +| `v1.1.0` | `20260828_0022` | `v1.2.2` / `20260830_0024` | yes; scope and audit migration | +| `v1.1.1` | `20260828_0022` | `v1.2.2` / `20260830_0024` | yes; scope and audit migration | +| `v1.2.0` | `20260828_0022` | `v1.2.2` / `20260830_0024` | yes; scope and audit migration | +| `v1.2.1` | `20260828_0022` | `v1.2.2` / `20260830_0024` | yes; scope and audit migration | +| intermediate RC | `20260830_0023` | `v1.2.2` / `20260830_0024` | yes; audit-boundary migration | +| `v1.2.2` | `20260830_0024` | `v1.2.2` | yes; already current | +| older than `v1.0.0` | any | `v1.2.2` | no direct upgrade | Because 0022 introduces terminal node and decommission-operation state, application-only rollback to v1.0.0 is unsupported. Restore the verified pre-upgrade backup. Alembic downgrade may be used in @@ -95,6 +97,13 @@ PATCH under SemVer. The console image is the one artifact whose *behaviour* differs, because the previous images could not reach a non-localhost API at all. That is a defect being repaired, not a capability being added. +## Why v1.2.2 is a PATCH release + +v1.2.2 hardens public packaging, dependency and database-authority boundaries without changing the +capability API or agent protocol. The Runtime Worker dependency set changes and is therefore +classified `behavioral` operationally: it remains a patch release, but production promotion is +blocked until exact LAB probes prove compatibility, output shape and unload/reclaim behavior. + ## Why v1.2.0 is a MINOR release v1.2.0 adds substantive, backward-compatible product capability at the console layer: a command diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 31f6e88..3264af4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,6 +1,6 @@ # Configuration reference -Generated from the typed settings by `scripts/generate_configuration_docs.py` for ITWorx ModelForge 1.2.1. Every setting the control plane reads appears here; a setting added without documentation fails the build. +Generated from the typed settings by `scripts/generate_configuration_docs.py` for ITWorx ModelForge 1.3.0-rc.1. Every setting the control plane reads appears here; a setting added without documentation fails the build. All settings are environment variables with the `MODELFORGE_` prefix, read from the process environment or from `.env`. @@ -10,7 +10,6 @@ All settings are environment variables with the `MODELFORGE_` prefix, read from - `MODELFORGE_DATABASE_URL` — SQLAlchemy URL for the API's non-owner modelforge_runtime role. It may read the audit trail and execute the canonical append function, but cannot mutate audit tables directly. - `MODELFORGE_REDIS_URL` — Redis URL for transient request payloads and queues. -- `MODELFORGE_OPERATOR_API_KEY` — Operator API key guarding every admin route. Generate at least 32 random characters; ModelForge never mints one for you. - `MODELFORGE_BACKUP_ENCRYPTION_KEY` — Base64 AES-256 key for backup encryption. Without it no backup can be produced, and without the same key no backup can be restored — store it outside this deployment. Production additionally refuses: a well-known development database password, a wildcard CORS origin, remote model code execution, an unwritable storage root, an unsupported schema revision, a PostgreSQL major below 16, and three policy combinations that cannot all hold at once. @@ -34,13 +33,17 @@ Losing the backup encryption key makes every existing backup unrecoverable. It i ## Sensitivity -5 settings are credentials. They are never logged, never written to a release artefact and never echoed in an error response: +9 settings are credentials. They are never logged, never written to a release artefact and never echoed in an error response: - `MODELFORGE_DATABASE_URL` - `MODELFORGE_MIGRATION_DATABASE_URL` +- `MODELFORGE_OPERATOR_IDENTITIES` +- `MODELFORGE_OPERATOR_PASSWORD_HASH` - `MODELFORGE_OPERATOR_API_KEY` - `MODELFORGE_HF_TOKEN` +- `MODELFORGE_SECRET_SLOT_ROOT_KEY` - `MODELFORGE_BACKUP_ENCRYPTION_KEY` +- `MODELFORGE_ALERT_WEBHOOK_URL` ## Every setting @@ -56,8 +59,12 @@ Losing the backup encryption key makes every existing backup unrecoverable. It i | `MODELFORGE_MIGRATION_DATABASE_URL` | secret, optional | — | no | secret | SQLAlchemy URL for the non-superuser modelforge schema-owner role. Set only in the one-shot migration process; production API startup refuses when this secret is present. | | `MODELFORGE_REDIS_URL` | string | `redis://localhost:6379/0` | yes | internal | Redis URL for transient request payloads and queues. | | `MODELFORGE_CORS_ORIGINS` | string | `http://localhost:3000` | no | public | Comma-separated exact origins allowed to call the API from a browser. A wildcard is refused in production because requests are credentialed. | -| `MODELFORGE_OPERATOR_API_KEY` | secret, optional | — | yes | secret | Operator API key guarding every admin route. Generate at least 32 random characters; ModelForge never mints one for you. | -| `MODELFORGE_HF_TOKEN` | secret, optional | — | no | secret | Optional Hugging Face token, used only for acquiring gated repositories. It is never passed to a runtime and never leaves the control plane. | +| `MODELFORGE_OPERATOR_IDENTITIES` | secret, optional | — | no | secret | Optional JSON array of named human identities with role, credential ID, SHA-256 key digest and timezone-aware expiry. Provision with scripts/operator_identity.py. Replace this deployment setting and recreate the API to rotate or revoke access. | +| `MODELFORGE_OPERATOR_PASSWORD_HASH` | secret, optional | — | no | secret | Optional salted scrypt verifier generated by scripts/operator_password.py. Enables password login with 15-minute sessions; requires the separate strong operator API key. Changing either value invalidates password sessions. | +| `MODELFORGE_OPERATOR_API_KEY` | secret, optional | — | no | secret | Legacy bootstrap administrator key. Generate at least 32 random characters. Production requires this key or an unexpired named administrator in operator_identities. | +| `MODELFORGE_HF_TOKEN` | secret, optional | — | no | secret | Optional deployment Hugging Face token for control-plane metadata. Compose may also provision it independently to node-agent downloads; API secret activation does not update nodes. | +| `MODELFORGE_SECRET_SLOT_ROOT_KEY` | secret, optional | — | no | secret | External base64 32-byte AES-256 key for the managed Hugging Face control-plane slot. Keep independent of backup and operator keys; retain it externally for historical restore. | +| `MODELFORGE_SECRET_SLOT_ROOT_KEY_ID` | string | `hf-control-plane-1` | no | public | External key identifier bound to encrypted HF slot versions. Replacing a key does not rewrap history. | | `MODELFORGE_BACKUP_ENCRYPTION_KEY` | secret, optional | — | yes | secret | Base64 AES-256 key for backup encryption. Without it no backup can be produced, and without the same key no backup can be restored — store it outside this deployment. | | `MODELFORGE_BACKUP_ENCRYPTION_KEY_ID` | string | `modelforge-backup-key-1` | no | public | Identifier recorded in each backup manifest so a restore can name the key it needs. | | `MODELFORGE_HF_HOME` | string | `/data/hf-cache` | no | public | Hugging Face cache root inside the container. | @@ -108,6 +115,17 @@ Losing the backup encryption key makes every existing backup unrecoverable. It i | `MODELFORGE_MIGRATION_RECONCILIATION_ENABLED` | boolean | `false` | no | public | Report interrupted migration cutovers at startup. They are never auto-resolved: external alias truth cannot be inferred after a crash. | | `MODELFORGE_OBSERVABILITY_MONITOR_ENABLED` | boolean | `false` | no | public | Run SLO and alert evaluation in this process. | | `MODELFORGE_OBSERVABILITY_POLL_INTERVAL_SECONDS` | integer | `60` | no | public | Interval between observability evaluation passes. | +| `MODELFORGE_ALERT_NOTIFICATIONS_ENABLED` | boolean | `false` | no | public | Queue and dispatch the deployment-managed operational alert webhook. | +| `MODELFORGE_ALERT_WEBHOOK_URL` | secret, optional | — | no | secret | Deployment-managed HTTPS notification destination. Its exact hostname must also appear in alert_webhook_allowed_hosts; the URL is never returned by the API. | +| `MODELFORGE_ALERT_WEBHOOK_ALLOWED_HOSTS` | string | — | no | public | Comma-separated exact destination host allowlist for the alert webhook. Wildcards and redirects are not accepted. | +| `MODELFORGE_ALERT_WEBHOOK_MIN_SEVERITY` | `INFO`, `WARNING`, `CRITICAL` | `WARNING` | no | public | Minimum alert severity sent to the single webhook route: INFO, WARNING or CRITICAL. | +| `MODELFORGE_ALERT_NOTIFICATION_TIMEOUT_SECONDS` | number | `5.0` | no | public | Total timeout for one webhook delivery. | +| `MODELFORGE_ALERT_NOTIFICATION_MAX_ATTEMPTS` | integer | `8` | no | public | Maximum webhook attempts before a delivery fails permanently. | +| `MODELFORGE_ALERT_NOTIFICATION_BATCH_SIZE` | integer | `20` | no | public | Maximum outbox rows claimed by one notification dispatch pass. | +| `MODELFORGE_ALERT_NOTIFICATION_LEASE_SECONDS` | integer | `60` | no | public | Lease duration before an interrupted delivery can be reclaimed. | +| `MODELFORGE_ALERT_NOTIFICATION_RETRY_BASE_SECONDS` | integer | `15` | no | public | Base delay for bounded exponential webhook retries. | +| `MODELFORGE_MODEL_UPDATE_MONITOR_ENABLED` | boolean | `true` | no | public | In production, check explicitly followed model references for changed revisions. Metadata only; never downloads, tests or promotes a model. | +| `MODELFORGE_MODEL_UPDATE_CHECK_INTERVAL_SECONDS` | integer | `86400` | no | public | Minimum interval between automatic checks of each followed model reference; default one day. Each API process checks at most one due reference per minute. | | `MODELFORGE_RECOVERY_RECONCILIATION_ENABLED` | boolean | `false` | no | public | Reconcile interrupted backups and restores at startup. | | `MODELFORGE_BACKUP_PG_DUMP_PATH` | string | `pg_dump` | no | public | pg_dump executable. Must match the server major version. | | `MODELFORGE_BACKUP_PG_RESTORE_PATH` | string | `pg_restore` | no | public | pg_restore executable. | @@ -172,4 +190,4 @@ Read by Compose, the Node Agent and the Runtime Worker rather than by the contro ## Restarts -Every setting is read at process start. Changing any of them requires restarting the control plane; none is re-read from the environment while the process is running. +Deployment environment settings are read at process start; changing an environment value requires a restart. The Console also supports a closed set of numeric serving overrides which take effect for new requests after preview and explicit apply. See the [configuration workflow](operations/CONFIGURATION_WORKFLOW.md) for scope, acknowledgement, rollback and secret-safe transfer. Other settings remain deployment-managed. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index adbd0b3..4a6fa00 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -1,7 +1,17 @@ -# Installing ITWorx ModelForge v1.2.1 +# Installing ITWorx ModelForge — historical v1.2.3 procedure -This is the whole installation. If you have to do something that is not written here, that is a -defect — please report it rather than working around it. +At the 2026-09-23 reconciliation checkpoint, the observed production installation ran v1.2.3 at +schema `20260909_0032` and no v1.2.3 Gitea Release object existed. The current `1.3.0-rc.1` source targets +`20260924_0039`; its qualification is established by an exact published release record, not this +historical guide. The steps below preserve the earlier bounded fresh-install +procedure; do not treat them as validation of the current RC archive, production upgrade, or +rollback. Select exact source and image identities and complete the [RC acceptance gates](operations/RC_ACCEPTANCE.md) +before using a candidate package. See the [current-state reconciliation](quality/CURRENT_STATE_RECONCILIATION_2026-09-24.md) +and [upgrade boundary](UPGRADE.md#source-and-schema-boundary). + + +The final RC installation procedure must be checked against its exact archive and isolated +clean-install rehearsal; unresolved steps are release blockers. The steps below are release-gated against a fresh project with isolated volumes, newly generated test secrets and an empty PostgreSQL database. @@ -74,14 +84,25 @@ MODELFORGE_OPERATOR_API_KEY= MODELFORGE_BACKUP_ENCRYPTION_KEY= MODELFORGE_CORS_ORIGINS=https://modelforge.example.internal VITE_API_BASE_URL=https://modelforge.example.internal:8000 -MODELFORGE_VERSION=1.2.1 -MODELFORGE_API_IMAGE=modelforge-api:1.2.1 -MODELFORGE_WEB_IMAGE=modelforge-web:1.2.1 +MODELFORGE_VERSION=1.2.3 +MODELFORGE_API_IMAGE=modelforge-api:1.2.3 +MODELFORGE_WEB_IMAGE=modelforge-web:1.2.3 ``` Every setting is documented in [CONFIGURATION.md](CONFIGURATION.md), which is generated from the typed settings — it cannot drift from what the code actually reads. +For browser password sign-in, run `python scripts/operator_password.py` and enter your chosen +password at the hidden prompt. Put only the generated hash in `.env` as +`MODELFORGE_OPERATOR_PASSWORD_HASH=''`. Keep the single quotes: they prevent +Compose from interpreting the dollar signs in the hash. Keep the separate generated operator +API key above for session signing and recovery. Never use your password as that API key. +Recreate the API after changing either setting. Password sign-in returns a temporary session +valid for at most 15 minutes; locking or reloading the console clears it. Rotating the password +hash or API key invalidates existing password sessions. Login attempts require Redis and are +limited across API processes to five per socket peer and thirty globally per minute. +The console defaults to **Password**; **Advanced: API key recovery** retains direct key access. + To move a port, set `MODELFORGE_API_PUBLISHED_PORT`, `MODELFORGE_WEB_PORT`, `MODELFORGE_POSTGRES_PORT` or `MODELFORGE_REDIS_PORT`. You never have to edit a Compose file. @@ -119,7 +140,7 @@ path, but never derives a deployable image from a floating `latest` tag. To stamp build identity into the images so a running container can say where it came from: ```bash -MODELFORGE_VERSION=1.2.1 \ +MODELFORGE_VERSION=1.2.3 \ MODELFORGE_COMMIT=$(git rev-parse HEAD) \ MODELFORGE_BUILT_AT=$(date -u +%FT%TZ) \ docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --build diff --git a/docs/NODE_AGENT.md b/docs/NODE_AGENT.md index 8163a10..827bf61 100644 --- a/docs/NODE_AGENT.md +++ b/docs/NODE_AGENT.md @@ -19,6 +19,34 @@ different site work without inbound exposure. ## Enrolling +For the v1.3.0 RC, an administrator can use **Console → Hardware → Enroll node**. The wizard offers +5-minute, 15-minute, 1-hour, and 24-hour token expiry, and locks the node's API origin to the +Console's current API origin. RC enrollment always sets `production_eligible=false`. The one-time +token is held only in the open wizard's component memory; closing the wizard, checking readiness, +revoking the unused token, or reaching its expiry clears the reveal. The generated `.env` contains +only non-secret settings. Paste the start command into a Bash shell from the node-agent repository; +it disables shell tracing, reads the token without echo, uses a temporary mode-0600 env file, and +removes that file on exit, including error and signal paths. + +Use **Check node readiness** after starting the agent. Readiness is tied to the exact node ID returned +by the enrollment record; it checks connection, inventory age, protocol compatibility, agent health, +and telemetry age and includes diagnostic next steps. After enrollment is consumed, use the wizard's +container recreation command to remove the one-time token from the running container environment +while preserving its persisted identity and credential. A node is not ready merely because it +connected. + +The API flow below remains available for automation and existing installations. + +The RC wizard defaults to the local four-image release bundle. Verify the release `SHA256SUMS`, load +`modelforge-1.3.0-rc.1-images.tar` on the node with `docker image load`, and paste +`images.modelforge-node-agent` from the matching release manifest. The wizard requires the exact +`modelforge-node-agent:1.3.0-rc.1` tag, RootFS layer list, architecture, and all recorded OCI labels. +It ignores the build-host image/config ID because Docker may rewrite it during `docker load`; before +Compose starts it captures the local image ID and verifies the created container uses that same ID. +Start and cleanup use `--no-build --pull never`, and the generated configuration sets the local tag +explicitly. A registry image pinned to `@sha256:` remains available as an alternate mode. +The source-build Compose workflow below is separate and intended for development. + 1. An operator mints a single-use token: ```bash @@ -79,9 +107,13 @@ bounded diagnostic check that never enrolls is available as: ```bash docker run --rm --gpus all --network none \ -e MODELFORGE_AGENT_ACCELERATOR_MODE=nvidia \ - modelforge-node-agent:1.1.1 modelforge-node-agent --preflight + modelforge-node-agent:1.1.1 python3 -m modelforge_node_agent.main --preflight ``` +The chiselled runtime intentionally contains no shell or packaging-script directory. Invoke the +reviewed Python module directly for this bounded diagnostic, just as the image's default command +does. + ## Identity A node's identity is persisted and survives restarts. If a node reappears as a *new* identity, its diff --git a/docs/PROJECT_INTEGRATION.md b/docs/PROJECT_INTEGRATION.md index b591b14..53e1e34 100644 --- a/docs/PROJECT_INTEGRATION.md +++ b/docs/PROJECT_INTEGRATION.md @@ -18,9 +18,12 @@ capability key rag.embedding@1 If your integration needs a model name, a revision, a node hostname or an artifact path, something has gone wrong — those are ModelForge's business, not yours. +For guided setup, use the [Projects workflow](operations/PROJECT_ONBOARDING.md). Official lightweight +[Python and TypeScript clients](../clients/README.md) ship in the release archive. + ## Getting a credential -An operator creates a service client scoped to your project and the capabilities you may call: +An administrator creates a service client scoped to your project and the capabilities you may call: ```bash curl -s -X POST -H "X-ModelForge-Admin-Token: $KEY" -H "Content-Type: application/json" \ diff --git a/docs/PUBLICATION.md b/docs/PUBLICATION.md index 125100f..fbb8af4 100644 --- a/docs/PUBLICATION.md +++ b/docs/PUBLICATION.md @@ -29,7 +29,19 @@ manifest. The canonical repository and all of its Git history remain private. automatically on persistent private runners. The exporter therefore removes the automatic `pull_request` trigger from the public copy of `managed-validation.yml` and leaves only explicit owner dispatch. Keep public branch protection independent of private-runner status checks until - a public-safe CI runner is configured. + a public-safe CI runner is configured on a network segment that cannot reach private + infrastructure. + +## Current publication state + +The parentless public repository is live at `https://git.example.com/Jens/ModelForge-Public`. Its +protected `main` branch started with one root commit, and annotated tag `v1.2.2` points to that public +root. Subsequent source snapshots are published from the private canonical branch only after the +exact public candidate passes server acceptance, manifest validation and secret scanning. The public +workflow remains owner-dispatch-only: the available server runners share the +private LAN, so container isolation alone is insufficient for executing arbitrary fork code. A +future automatic PR runner must also have enforced private-network egress denial; it must never use +the private validation runner, deploy runner or host Docker socket. ## License decision diff --git a/docs/RELEASE_NOTES_v1.2.2.md b/docs/RELEASE_NOTES_v1.2.2.md index 5129e5e..f012ae4 100644 --- a/docs/RELEASE_NOTES_v1.2.2.md +++ b/docs/RELEASE_NOTES_v1.2.2.md @@ -9,6 +9,9 @@ evidence before promotion; no production capability is changed automatically. - The Runtime Worker now upgrades its digest-pinned Ubuntu base during the image build. - Build-only Linux kernel headers are removed after dependency installation instead of remaining in the running Runtime Worker image. +- Explicit shipping-image validation rebuilds all four images without Docker layer cache, refreshes + the Node Agent's digest-pinned Python base, upgrades fixed Runtime Worker dependencies, and removes + pip/setuptools/wheel after the final dependency check. - Pillow, protobuf, SentencePiece, Sentence Transformers, Transformers and inherited Python security dependencies are pinned to reviewed fixed versions. - The public-candidate acceptance gate builds and scans all four images, binds each Trivy report to @@ -44,3 +47,23 @@ The control plane and Node Agent change is `transparent`. The Runtime Worker inf update is `behavioral`: exact LAB probes, output-shape checks and unload/reclaim evidence are required before an existing runtime profile can be promoted. Embedding-space identities and project indexes are not reused or changed automatically. + +## Post-publication operational verification + +The exact Runtime Worker built from `b8755394117affad0178a79a627cf5574d3c8eef` passed a fresh +GPU Node LAB probe on 2026-09-01, including offline load, finite normalized `[1, 1024]` output and exact +VRAM return to baseline after unload. Production remained on v1.2.1 pending the separate backup, +migration and explicit deployment gates. See +[`quality/V1_2_2_LAB_VERIFICATION.md`](quality/V1_2_2_LAB_VERIFICATION.md). + +## Known limitations + +- Production upgrade from schema 0022 to 0024 requires downtime, a verified recovery point and + separate migration-owner/runtime-role provisioning; publication never performs it automatically. +- Public pull-request CI remains disabled until an isolated runner can enforce denial of private + network egress for untrusted fork code. +- ExampleRAG's ModelForge shadow candidate is not promotion-eligible after two critical retrieval + regressions; the existing production alias and vector space remain unchanged. +- ExampleVision owner-photo and ASR natural-speech qualification still require owner-provided ground + truth. Catalog images and synthetic/library audio are not accepted as substitutes. +- Point-in-time database recovery remains `NOT_SUPPORTED`. diff --git a/docs/RELEASE_NOTES_v1.2.3.md b/docs/RELEASE_NOTES_v1.2.3.md new file mode 100644 index 0000000..c3f3902 --- /dev/null +++ b/docs/RELEASE_NOTES_v1.2.3.md @@ -0,0 +1,36 @@ +# ModelForge v1.2.3 — operator password sign-in + +The console now opens with a Password field. A successful password exchange creates a signed, +memory-only session lasting at most 15 minutes. Passwords are verified against a salted scrypt +hash; the existing generated operator API key remains separate for signing and recovery. + +Existing API-key clients remain compatible. Set `MODELFORGE_OPERATOR_PASSWORD_HASH` using +`python scripts/operator_password.py`, single-quote the hash in the Compose environment file, +and recreate the API. See [installation](INSTALLATION.md). An unset hash preserves existing +API-key access. Password login fails closed when Redis is unavailable. Attempts are limited +across API processes, and forwarded headers do not change the peer used by the limiter. + +These release notes describe the published hotfix built from v1.2.2. Database schema `20260830_0024`, agent protocol 1, runtime +dependencies, model artifacts, capability bindings and promotion evidence are unchanged. +Upgrade classification: transparent. No model or inference runtime promotion is included. + +Validation before release: targeted backend authorization, password and release-contract tests; +frontend full test suite and production build; independent code review; isolated Redis 7 atomic +parallel attempt, global limit and TTL checks. Deployment and exact-image acceptance must be +recorded separately; source validation is not evidence of a successful production login. + +## Known limitations + +Password login requires configured Redis and a password hash; there is no self-service password +reset. Reloading the browser ends its session. Peers behind one reverse proxy share its attempt +budget. Runtime and GPU qualification from v1.2.2 remains applicable only to unchanged exact +runtime images; this hotfix does not qualify or promote new inference-worker images. + + +## RC integration boundary + +The development branch also incorporates this hotfix while retaining its newer schema +`20260908_0028`, named/managed identities and RC features. That source is not the published +schema-0024 hotfix image and is not qualified by its release evidence. Password sessions on the +RC branch pass the same identity, role and expiry checks as other operator sessions. Consult +[the upgrade source boundary](UPGRADE.md#source-and-schema-boundary) before deploying either build. diff --git a/docs/RELEASE_NOTES_v1.3.0-rc.1.md b/docs/RELEASE_NOTES_v1.3.0-rc.1.md new file mode 100644 index 0000000..36003ef --- /dev/null +++ b/docs/RELEASE_NOTES_v1.3.0-rc.1.md @@ -0,0 +1,28 @@ +# ModelForge v1.3.0-rc.1 — qualification candidate + +`v1.3.0-rc.1` identifies the release-candidate source and artifact set. This file belongs in its +archive; the annotated tag, Gitea prerelease, exact image identities and retained qualification +evidence must be verified from the published release record. A prerelease does not authorize a +production deployment. + +The current source targets Alembic schema `20260924_0039` and Node Agent protocol 1. The final +candidate must contain four first-class images: API, Web, Node Agent and Runtime Worker. P15A must +bind their exact OCI labels, content-addressable RootFS layer sets, SBOM, provenance, source/archive +hashes and exact-image security reports into one retained transport bundle. P12–P14 must load that +bundle without rebuilding it. + +The user-visible ModelForge changes are the already merged project-first model management, +onboarding, model library, immutable dimension pinning and guarded replacement/rollback flows. The +candidate reports the `rc` release channel so its API health and system metadata cannot be confused +with the stable production line. + +## Known limitations + +- The `v1.3.0-rc.1` tag and Gitea prerelease are permitted only after every mandatory readiness + row is verified against the exact artifact set; the version string does not prove that gate. +- The release record must retain proof of the isolated RC stack, real ExampleRAG consumer path, + project replacement/rollback, PostgreSQL 0039 recovery, fault journeys and 60-minute soak. +- A real GPU inference gate requires an available RTX 4080 SUPER window; unmanaged workloads are + never stopped or changed by this candidate process. +- Observed production remains v1.2.3/schema-0032 unless a separate stable promotion procedure + explicitly authorizes a change. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 936a1f1..eca7814 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -57,8 +57,9 @@ nginx — never a development server. Content-Security-Policy with `default-src 'none'`, `script-src 'self'` and no `unsafe-inline` or `unsafe-eval`; inline style is permitted only as an attribute via `style-src-attr`, which covers the dynamic width bars the console uses while still blocking an injected `