Compare commits

..
2 Commits
434 changed files with 67311 additions and 4317 deletions

No files matched your search

+69
View File
@@ -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
+48 -3
View File
@@ -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
+75 -127
View File
@@ -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__":
+118 -1
View File
@@ -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)
+158 -5
View File
@@ -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",
"urllib3==2.8.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,
+215
View File
@@ -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"],
)
+316
View File
@@ -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()
+111
View File
@@ -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()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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
-132
View File
@@ -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}"
+35
View File
@@ -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
+5 -1
View File
@@ -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.
+1 -1
View File
@@ -1,5 +1,5 @@
# Curated public source export
Generated from private canonical revision `b8755394117affad0178a79a627cf5574d3c8eef`.
Generated from private canonical revision `f3a9c1baac428adb5969ebb5c87a6ec1aab97504`.
This parentless candidate excludes private operational history and uses synthetic example identifiers.
+1673 -327
View File
File diff suppressed because it is too large. Load diff
+37 -9
View File
@@ -1,11 +1,26 @@
# ITWorx ModelForge
<p align="center"><strong>One stable capability interface for models running on your own hardware.</strong></p>
<p align="center"><a href="#console-tour">Console tour</a> · <a href="#first-useful-workflow">Workflow</a> · <a href="#what-you-get">Capabilities</a> · <a href="#quick-start">Quick start</a> · <a href="#architecture">Architecture</a></p>
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
@@ -117,6 +134,17 @@ docs/ User, architecture, operations and security documentation
scripts/ Preflight, bootstrap, release and recovery tools
```
## Diagnose readiness before invoking a model
| Symptom | Check first |
| --- | --- |
| Console opens but a capability is unavailable | Approved artifact revision, capability binding and runtime readiness. |
| A node is visible but cannot host the model | Hardware inventory, placement requirements and available capacity. |
| Invocation is denied | Caller credentials and the granted capability scope. |
| A replacement model is not promoted | Required validation evidence and the promotion/canary decision. |
Keep a working revision available for rollback. A downloaded weight file, a registered node or an accessible console alone does not establish a qualified serving route. Check the model's own license and resource requirements before acquiring its artifacts.
## Development
Each component keeps its own pinned dependencies and tests. The protected-branch validation installs
+1 -1
View File
@@ -1 +1 @@
1.2.2
1.3.0-rc.1
+14 -12
View File
@@ -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"]
@@ -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",
@@ -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(); "
@@ -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")
@@ -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")
@@ -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")
@@ -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)
@@ -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")
@@ -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")
@@ -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")
@@ -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,
)
@@ -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.
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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)
+6 -3
View File
@@ -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",
+93 -17
View File
@@ -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
@@ -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)
@@ -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)]
+37 -3
View File
@@ -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,
)
@@ -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
+328 -109
View File
@@ -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)
@@ -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)
@@ -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,
)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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]
@@ -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))
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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,
@@ -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,
)
@@ -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):
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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"
@@ -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."),
@@ -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
@@ -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}
@@ -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"]
+145
View File
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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"]
@@ -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
@@ -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
@@ -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
@@ -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]
@@ -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
+69 -7
View File
@@ -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"^(?P<major>0|[1-9][0-9]*)\.(?P<minor>0|[1-9][0-9]*)\."
r"(?P<patch>0|[1-9][0-9]*)rc(?P<number>0|[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.<number>`` 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
@@ -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]
@@ -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_]*$")
@@ -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
@@ -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)
+49 -12
View File
@@ -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
@@ -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(),
)
+151 -9
View File
@@ -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
]
},
}
@@ -1,3 +1,4 @@
from .model_change import ModelChangeCase
from .models import Base
__all__ = ["Base"]
__all__ = ["Base", "ModelChangeCase"]
@@ -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]:
@@ -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))
@@ -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"))
@@ -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)
@@ -20,4 +20,3 @@ def register_application_engine(engine: Engine) -> Engine:
def is_application_connection(connection: Connection) -> bool:
return connection.engine in _APPLICATION_ENGINES
@@ -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)
@@ -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
@@ -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"
)
@@ -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)
+2 -1
View File
@@ -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
@@ -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)
Loaded 100 of 434 files, more files were not shown because too many files have changed in this diff. Show more