Publish curated ITWorx-ModelForge source from 990401737f96c8d46a9f7b0d84994516cadcb6a0

This commit is contained in:
Public Source Publisher committed 2026-09-30 09:23:17 +00:00
1 parent 7082ab955a
commit 848f0d8f8a
434 files changed
+67298 -4315

No files matched your search

+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)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,145 @@
from __future__ import annotations
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from pydantic import SecretStr
from modelforge_api.domain.configuration import (
ConfigurationApplicationMode,
ConfigurationCatalogResponse,
ConfigurationSettingResponse,
ConfigurationSource,
)
from modelforge_api.domain.configuration_policy import CONFIGURATION_CONSUMER, SERVING_OVERRIDE_KEYS
from modelforge_api.domain.configuration_reference import SETTING_DOCS, Sensitivity
from modelforge_api.settings import Settings
def _environment_variable_name(setting_key: str) -> str:
return f"MODELFORGE_{setting_key.upper()}"
def _is_configured(value: object) -> bool:
if value is None:
return False
if isinstance(value, SecretStr):
return bool(value.get_secret_value())
if isinstance(value, str):
return bool(value)
return True
def _redact_url_userinfo(value: str) -> str:
"""Remove URL userinfo even when a legacy setting is not classified as SECRET."""
try:
parsed = urlsplit(value)
except ValueError:
return value
if not parsed.scheme or "@" not in parsed.netloc:
return value
# Keep useful topology without leaking a username or password. rsplit handles userinfo that
# legitimately contains percent-encoded separators while preserving the complete host/port.
safe_netloc = parsed.netloc.rsplit("@", 1)[-1]
return urlunsplit((parsed.scheme, safe_netloc, parsed.path, parsed.query, parsed.fragment))
def _safe_effective_value(
value: object, sensitivity: Sensitivity
) -> str | int | float | bool | None:
if sensitivity is Sensitivity.SECRET:
return None
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
return _redact_url_userinfo(value)
if isinstance(value, int | float):
return value
if isinstance(value, Path):
return str(value)
# Future settings may gain structured values. A read-only catalog must not guess a wire format
# or accidentally serialise an object with surprising/revealing repr semantics.
return None
def build_configuration_catalog(
settings: Settings,
*,
revision: int = 0,
overrides: dict[str, int | float] | None = None,
) -> ConfigurationCatalogResponse:
"""Return the runtime configuration reference without exposing credential material."""
setting_fields = set(type(settings).model_fields)
documented_fields = set(SETTING_DOCS)
if setting_fields != documented_fields:
missing_docs = sorted(setting_fields - documented_fields)
unknown_docs = sorted(documented_fields - setting_fields)
raise RuntimeError(
"configuration reference is out of sync with Settings: "
f"missing_docs={missing_docs}, unknown_docs={unknown_docs}"
)
active = overrides or {}
schema = Settings.model_json_schema()["properties"]
entries: list[ConfigurationSettingResponse] = []
for key in type(settings).model_fields:
documentation = SETTING_DOCS[key]
environment_variable = _environment_variable_name(key)
value = getattr(settings, key)
field = type(settings).model_fields[key]
rules = {
name: rule
for name, rule in schema[key].items()
if name in {"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "maxLength"}
}
editable = key in SERVING_OVERRIDE_KEYS
if key == "scheduler_safety_reserve_percentage":
rules.pop("maximum", None)
rules["exclusiveMaximum"] = 0.5
if key == "gateway_queue_timeout_seconds":
rules["less_than_setting"] = "gateway_request_timeout_seconds"
entries.append(
ConfigurationSettingResponse(
key=key,
display_name=key.replace("_", " ").capitalize(),
category=key.split("_", 1)[0],
data_type=schema[key].get("type", "optional"),
default_value=_safe_effective_value(field.default, documentation.sensitivity),
validation_rules=rules,
editable=editable,
consumer=CONFIGURATION_CONSUMER if editable else None,
rollback=(
"Preview a previous revision or remove the override, then apply."
if editable
else "Restore deployment configuration and restart the component."
),
environment_variable=environment_variable,
description=documentation.description,
sensitivity=documentation.sensitivity,
required_in_production=documentation.required_in_production,
application_mode=(
ConfigurationApplicationMode.DYNAMIC
if editable
else ConfigurationApplicationMode.RESTART_REQUIRED
),
configured=_is_configured(value),
source=(
ConfigurationSource.OPERATOR_OVERRIDE
if key in active
else ConfigurationSource(settings.setting_source(key))
),
value=_safe_effective_value(value, documentation.sensitivity),
)
)
return ConfigurationCatalogResponse(
environment=settings.env,
settings=entries,
read_only=False,
revision=revision,
overrides=active,
)
@@ -0,0 +1,326 @@
"""Atomic, preview-bound safe overrides; all persisted payloads are numeric and allowlisted."""
from __future__ import annotations
import hashlib
import hmac
import json
import math
from datetime import UTC, datetime
from pydantic import ValidationError
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from modelforge_api.db import rows_affected
from modelforge_api.domain.configuration import (
ConfigurationApplied,
ConfigurationApply,
ConfigurationChange,
ConfigurationDifference,
ConfigurationExport,
ConfigurationImport,
ConfigurationPreview,
ConfigurationRevisionResponse,
ConfigurationSource,
)
from modelforge_api.domain.configuration_policy import (
CONFIGURATION_CONSUMER,
CONFIGURATION_FORMAT,
SERVING_OVERRIDE_KEYS,
)
from modelforge_api.domain.configuration_reference import SETTING_DOCS, Sensitivity
from modelforge_api.domain.release import PRODUCT_VERSION
from modelforge_api.persistence.models import ConfigurationRevision, ConfigurationState
from modelforge_api.services.audit import AuditContext, AuditWriter
from modelforge_api.settings import Settings
class ConfigurationError(Exception):
def __init__(self, code: str, message: str, status_code: int = 409) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
def _validate_overrides(settings: Settings, overrides: dict[str, int | float]) -> Settings:
for key, value in overrides.items():
if key not in SERVING_OVERRIDE_KEYS:
raise ConfigurationError(
"CONFIGURATION_KEY_NOT_EDITABLE",
"Only the documented serving settings can be changed here.",
422,
)
if type(value) not in (int, float) or (
isinstance(value, float) and not math.isfinite(value)
):
raise ConfigurationError("CONFIGURATION_INVALID", "A finite number is required.", 422)
if Settings.model_fields[key].annotation is int and type(value) is not int:
raise ConfigurationError("CONFIGURATION_INVALID", f"{key} requires an integer.", 422)
try:
candidate = Settings.model_validate(settings.model_dump() | overrides)
candidate._inherit_setting_sources(settings)
except ValidationError as exc:
# Validation input may contain credentials from the deployment settings. Never echo it.
raise ConfigurationError(
"CONFIGURATION_INVALID",
"A value is outside the documented validation bounds.",
422,
) from exc
if candidate.gateway_queue_timeout_seconds >= candidate.gateway_request_timeout_seconds:
raise ConfigurationError(
"CONFIGURATION_INVALID",
"Queue timeout must be lower than request timeout.",
422,
)
if candidate.scheduler_safety_reserve_percentage >= 0.5:
raise ConfigurationError(
"CONFIGURATION_INVALID",
"The GPU safety reserve must be below 50%.",
422,
)
return candidate
def configuration_state(session: Session) -> tuple[int, dict[str, int | float]]:
row = session.execute(
select(
ConfigurationState.revision,
ConfigurationState.overrides,
).where(ConfigurationState.id == 1)
).first()
if row is None:
# Empty create_all test databases use the same first-write CAS as a fresh registry.
return 0, {}
return row.revision, dict(row.overrides)
def resolve_serving_settings(session: Session, settings: Settings) -> Settings:
return resolved_configuration(session, settings)[2]
def resolved_configuration(
session: Session,
settings: Settings,
) -> tuple[int, dict[str, int | float], Settings]:
revision, overrides = configuration_state(session)
return revision, overrides, _validate_overrides(settings, overrides) if overrides else settings
class ConfigurationService:
def __init__(self, session: Session, settings: Settings, context: AuditContext) -> None:
self.session = session
self.settings = settings
self.context = context
def preview(self, request: ConfigurationChange) -> ConfigurationPreview:
revision, current = configuration_state(self.session)
if request.expected_revision != revision:
raise ConfigurationError(
"CONFIGURATION_CONFLICT", "Configuration changed; refresh and preview again."
)
desired = dict(current)
for key, value in request.changes.items():
if key not in SERVING_OVERRIDE_KEYS:
raise ConfigurationError(
"CONFIGURATION_KEY_NOT_EDITABLE",
"The requested setting is deployment-managed.",
422,
)
if value is None:
desired.pop(key, None)
else:
desired[key] = value
before = _validate_overrides(self.settings, current)
after = _validate_overrides(self.settings, desired)
differences = [
ConfigurationDifference(
key=key,
before=getattr(before, key),
after=getattr(after, key),
source_before=(
ConfigurationSource.OPERATOR_OVERRIDE
if key in current
else ConfigurationSource(self.settings.setting_source(key))
),
source_after=(
ConfigurationSource.OPERATOR_OVERRIDE
if key in desired
else ConfigurationSource(self.settings.setting_source(key))
),
)
for key in sorted(request.changes)
if getattr(before, key) != getattr(after, key) or (key in current) != (key in desired)
]
if not differences:
raise ConfigurationError(
"CONFIGURATION_NO_CHANGE", "The proposal does not change configuration.", 422
)
# Include every editable base value: deployment changes invalidate earlier previews, even
# if the operator revision has not advanced. No credential enters this digest.
baseline = {key: getattr(self.settings, key) for key in sorted(SERVING_OVERRIDE_KEYS)}
payload = {
"format": CONFIGURATION_FORMAT,
"revision": revision,
"base": baseline,
"current": current,
"desired": desired,
}
digest = hashlib.sha256(
json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode()
).hexdigest()
return ConfigurationPreview(
expected_revision=revision,
preview_digest=digest,
differences=differences,
affected_components=[CONFIGURATION_CONSUMER],
overrides=desired,
impact="New serving requests use this snapshot. In-flight requests retain their settings. "
"Admission and queue changes may reject new work; no model is promoted or worker restarted.",
rollback="Preview a retained revision or remove an override, then explicitly apply it as a new revision.",
)
def apply(self, request: ConfigurationApply) -> ConfigurationApplied:
preview = self.preview(request)
if not hmac.compare_digest(request.preview_digest, preview.preview_digest):
raise ConfigurationError(
"CONFIGURATION_PREVIEW_STALE", "The proposal changed; preview it again."
)
next_revision = request.expected_revision + 1
try:
result = self.session.execute(
update(ConfigurationState)
.where(
ConfigurationState.id == 1,
ConfigurationState.revision == request.expected_revision,
)
.values(revision=next_revision, overrides=preview.overrides)
)
if rows_affected(result) != 1:
if request.expected_revision != 0:
raise ConfigurationError(
"CONFIGURATION_CONFLICT", "A concurrent configuration change won."
)
self.session.add(ConfigurationState(id=1, revision=1, overrides=preview.overrides))
self.session.flush() # Unique singleton also protects the empty-registry race.
self.session.add(
ConfigurationRevision(
revision=next_revision,
previous_revision=request.expected_revision,
created_at=datetime.now(UTC),
actor=self.context.actor_id,
correlation_id=self.context.correlation_id or "unknown",
overrides=preview.overrides,
preview_digest=preview.preview_digest,
)
)
self.session.flush()
# Exercise the exact reader used by every new ServingService before acknowledging.
resolve_serving_settings(self.session, self.settings)
AuditWriter(self.session, context=self.context).write(
"configuration.applied",
"configuration_revision",
str(next_revision),
{
"previous_revision": request.expected_revision,
"preview_digest": preview.preview_digest,
"differences": [item.model_dump() for item in preview.differences],
"acknowledged_by": CONFIGURATION_CONSUMER,
},
)
self.session.commit()
except IntegrityError as exc:
self.session.rollback()
raise ConfigurationError(
"CONFIGURATION_CONFLICT", "A concurrent configuration change won."
) from exc
except Exception:
self.session.rollback()
raise
return ConfigurationApplied(
revision=next_revision,
acknowledged_by=CONFIGURATION_CONSUMER,
preview_digest=preview.preview_digest,
)
def history(self, limit: int = 30) -> list[ConfigurationRevisionResponse]:
rows = self.session.scalars(
select(ConfigurationRevision)
.order_by(
ConfigurationRevision.revision.desc(),
)
.limit(limit)
)
return [
ConfigurationRevisionResponse.model_validate(row, from_attributes=True) for row in rows
]
def rollback_change(self, revision: int, expected_revision: int) -> ConfigurationChange:
_, current = configuration_state(self.session)
if revision == 0:
desired: dict[str, int | float] = {}
else:
row = self.session.get(ConfigurationRevision, revision)
if row is None:
raise ConfigurationError(
"CONFIGURATION_REVISION_NOT_FOUND", "Configuration revision was not found.", 404
)
desired = row.overrides
if desired == current:
raise ConfigurationError("CONFIGURATION_NO_CHANGE", "That revision is already effective.", 422)
return ConfigurationChange(
expected_revision=expected_revision,
changes={key: desired.get(key) for key in current.keys() | desired.keys()},
)
def export(self) -> ConfigurationExport:
_, overrides = configuration_state(self.session)
_validate_overrides(self.settings, overrides)
return ConfigurationExport(
format=CONFIGURATION_FORMAT,
source_version=PRODUCT_VERSION,
overrides=overrides,
secret_slots=sorted({"huggingface_control_plane"} | {
key for key, doc in SETTING_DOCS.items() if doc.sensitivity is Sensitivity.SECRET
}),
)
def import_change(self, request: ConfigurationImport) -> ConfigurationChange:
document = request.document
if document.format != CONFIGURATION_FORMAT:
raise ConfigurationError(
"CONFIGURATION_FORMAT_UNSUPPORTED", "Unsupported configuration format.", 422
)
if document.source_version.split(".")[0] != PRODUCT_VERSION.split(".")[0]:
raise ConfigurationError(
"CONFIGURATION_VERSION_UNSUPPORTED", "Incompatible product major version.", 422
)
expected_slots = {"huggingface_control_plane"} | {
key for key, doc in SETTING_DOCS.items() if doc.sensitivity is Sensitivity.SECRET
}
if (
len(document.secret_slots) != len(set(document.secret_slots))
or set(document.secret_slots) != expected_slots
):
raise ConfigurationError(
"CONFIGURATION_SLOTS_INVALID",
"Secret slot references do not match this product.",
422,
)
_validate_overrides(self.settings, document.overrides)
_, current = configuration_state(self.session)
changes = {
key: document.overrides.get(key) for key in current.keys() | document.overrides.keys()
}
if not changes:
raise ConfigurationError(
"CONFIGURATION_NO_CHANGE", "The import contains no overrides to change.", 422
)
return ConfigurationChange(expected_revision=request.expected_revision, changes=changes)
@@ -130,8 +130,16 @@ def _percentile(values: list[float], fraction: float) -> float:
class EvaluationService:
def __init__(self, session: Session) -> None:
def __init__(self, session: Session, *, commit: bool = True) -> None:
self.session = session
self._commit_results = commit
def _finish_result(self, row: EvaluationRun | EvaluationComparison) -> None:
if self._commit_results:
self.session.commit()
else:
self.session.flush()
self.session.refresh(row)
def create_suite(self, request: EvaluationSuiteCreate) -> EvaluationSuiteResponse:
if self.session.get(Project, request.project_id) is None:
@@ -220,6 +228,8 @@ class EvaluationService:
"project or suite revision not found",
status_code=404,
)
if not self._suite_belongs_to_project(revision.id, request.project_id):
raise EvaluationError("suite_project_mismatch", "suite revision belongs to another project")
cases = list(
self.session.scalars(
select(EvaluationCase).where(EvaluationCase.suite_revision_id == revision.id)
@@ -254,8 +264,7 @@ class EvaluationService:
started_at=datetime.now(UTC),
)
self.session.add(run)
self.session.commit()
self.session.refresh(run)
self._finish_result(run)
return EvaluationRunResponse.model_validate(run)
def complete_run(
@@ -266,6 +275,8 @@ class EvaluationService:
raise EvaluationError("run_not_found", "evaluation run not found", status_code=404)
if run.status != "running":
raise EvaluationError("run_not_active", "only a running evaluation can complete")
if not self._suite_belongs_to_project(run.suite_revision_id, run.project_id):
raise EvaluationError("suite_project_mismatch", "suite revision belongs to another project")
cases = {
case.id: case
for case in self.session.scalars(
@@ -310,10 +321,17 @@ class EvaluationService:
run.aggregate_metrics = {name: totals[name] / len(request.results) for name in METRICS}
run.status = "completed" if errors == 0 else "completed_with_errors"
run.completed_at = datetime.now(UTC)
self.session.commit()
self.session.refresh(run)
self._finish_result(run)
return EvaluationRunResponse.model_validate(run)
def _suite_belongs_to_project(self, revision_id: uuid.UUID, project_id: uuid.UUID) -> bool:
owner = self.session.scalar(
select(EvaluationSuite.project_id)
.join(EvaluationSuiteRevision, EvaluationSuiteRevision.suite_id == EvaluationSuite.id)
.where(EvaluationSuiteRevision.id == revision_id)
)
return owner == project_id
def runs(self) -> list[EvaluationRunResponse]:
return [
EvaluationRunResponse.model_validate(item)
@@ -758,6 +776,8 @@ class EvaluationService:
raise EvaluationError("run_incomplete", "both runs must be complete")
factors = {
"project": baseline.project_id == candidate.project_id,
"baseline_suite_project": self._suite_belongs_to_project(baseline.suite_revision_id, baseline.project_id),
"candidate_suite_project": self._suite_belongs_to_project(candidate.suite_revision_id, candidate.project_id),
"suite_revision": baseline.suite_revision_id == candidate.suite_revision_id,
"corpus_revision": baseline.corpus_revision == candidate.corpus_revision,
"retrieval_config": baseline.retrieval_config_digest
@@ -765,7 +785,8 @@ class EvaluationService:
}
if all(factors.values()):
comparability = "comparable"
elif factors["project"] and factors["suite_revision"]:
elif (factors["project"] and factors["suite_revision"]
and factors["baseline_suite_project"] and factors["candidate_suite_project"]):
comparability = "partially_comparable"
else:
comparability = "not_comparable"
@@ -817,8 +838,10 @@ class EvaluationService:
for name in METRICS
}
error_rate = candidate.error_count / candidate.expected_cases
baseline_error_free = baseline.status == "completed" and baseline.error_count == 0
eligible = (
comparability == "comparable"
and baseline_error_free
and error_rate <= request.maximum_error_rate
and deltas["recall_at_10"] >= request.minimum_recall_at_10_delta
and (not request.require_no_critical_regressions or critical_regressions == 0)
@@ -843,13 +866,14 @@ class EvaluationService:
promotion_eligibility=eligibility,
eligibility_evidence={
"error_rate": error_rate,
"baseline_error_count": baseline.error_count,
"baseline_error_free": baseline_error_free,
"criteria": request.model_dump(mode="json"),
"promotion_performed": False,
},
)
self.session.add(comparison)
self.session.commit()
self.session.refresh(comparison)
self._finish_result(comparison)
return EvaluationComparisonResponse.model_validate(comparison)
def comparison(self, comparison_id: uuid.UUID) -> EvaluationComparisonResponse:
@@ -0,0 +1,770 @@
from __future__ import annotations
import hashlib
import json
import uuid
from datetime import UTC, datetime, timedelta
from typing import cast
import sqlalchemy as sa
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from modelforge_api.domain.external_creative import (
CreativeAllocationState,
CreativeRequiredAction,
CreativeWorkload,
ExternalActionAttestation,
ExternalAllocationCreate,
ExternalAllocationList,
ExternalAllocationResponse,
ExternalProfileObservation,
ExternalResourceApproval,
ExternalResourceList,
ExternalResourceResponse,
)
from modelforge_api.persistence.models import (
Accelerator,
ExternalCreativeActionAttestation,
ExternalCreativeAllocation,
ExternalCreativeProfileObservation,
ExternalCreativeResource,
Project,
ProjectBinding,
ServiceClient,
)
from modelforge_api.services.audit import AuditWriter
from modelforge_api.services.serving import ServingError, ServingService
ACTIVE = {"granted", "uncertain"}
PRIORITY = {"production": 0, "interactive": 1, "background": 2, "benchmark": 3}
resources = cast(sa.Table, ExternalCreativeResource.__table__)
attestations = cast(sa.Table, ExternalCreativeActionAttestation.__table__)
observations = cast(sa.Table, ExternalCreativeProfileObservation.__table__)
def _now() -> datetime:
return datetime.now(UTC)
def _digest(value: ExternalAllocationCreate) -> str:
return hashlib.sha256(
json.dumps(value.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
class ExternalCreativeService:
def __init__(self, session: Session, serving: ServingService):
self.session, self.serving = session, serving
def _lock_accelerators(self) -> None:
self.session.scalars(select(Accelerator).order_by(Accelerator.id).with_for_update()).all()
def _project(self, client: ServiceClient) -> uuid.UUID:
fresh = self.session.get(
ServiceClient, client.id, populate_existing=True, with_for_update=True
)
binding = (
self.session.get(
ProjectBinding,
fresh.project_binding_id,
populate_existing=True,
with_for_update=True,
)
if fresh and fresh.project_binding_id
else None
)
project = (
self.session.get(
Project, binding.project_id, populate_existing=True, with_for_update=True
)
if binding
else None
)
if (
fresh is None
or fresh.status != "active"
or fresh.disabled_at is not None
or "creative.resource@1" not in fresh.allowed_capabilities
or binding is None
or binding.deprecated_at is not None
or project is None
or not project.active
):
raise ServingError(
403,
"PROJECT_BINDING_REQUIRED",
"creative coordination requires an active project-bound client",
)
return binding.project_id
def _priority(self, client: ServiceClient, requested: str) -> str:
binding = self.session.get(ProjectBinding, client.project_binding_id)
allowed = max(
PRIORITY.get(client.workload_priority, 99),
PRIORITY.get(binding.priority, 99) if binding else 99,
)
if PRIORITY.get(requested, -1) < allowed:
raise ServingError(
403,
"ALLOCATION_PRIORITY_FORBIDDEN",
"requested priority exceeds client or project policy",
)
return requested
def _expire(self, row: ExternalCreativeAllocation) -> bool:
expiry = row.expires_at
if row.state == "granted" and (
expiry is None
or (expiry.astimezone(UTC) if expiry.tzinfo else expiry.replace(tzinfo=UTC)) <= _now()
):
row.state, row.uncertain_at, row.updated_at = "uncertain", _now(), _now()
row.expires_at = None
row.decision_code = "ALLOCATION_EXPIRED_UNCERTAIN"
self._audit("system", "external-creative-expiry").write(
"EXTERNAL_CREATIVE_ALLOCATION_EXPIRED",
"external_creative_allocation",
str(row.id),
{"state": row.state, "reservation_retained_bytes": row.required_vram_bytes},
)
return True
return False
def _expire_grants(self) -> None:
for row in self.session.scalars(
select(ExternalCreativeAllocation)
.where(ExternalCreativeAllocation.state == "granted")
.order_by(ExternalCreativeAllocation.id)
.with_for_update()
.execution_options(populate_existing=True)
):
self._expire(row)
self.session.flush()
def _queued_authority(self, row: ExternalCreativeAllocation) -> bool:
client = self.session.get(ServiceClient, row.service_client_id, populate_existing=True)
try:
if client is None or self._project(client) != row.project_id:
return False
self._priority(client, row.priority)
except ServingError:
return False
resource = (
self.session.execute(
select(resources)
.where(
resources.c.project_id == row.project_id,
resources.c.resource_ref == row.resource_ref,
resources.c.workload == row.workload,
resources.c.provider_resource_id == row.provider_resource_id,
)
.with_for_update()
)
.mappings()
.one_or_none()
)
if resource is None or not resource["approved"]:
return False
row.required_vram_bytes = max(
row.required_vram_bytes,
resource["configured_vram_bytes"],
resource["measured_peak_vram_bytes"] or 0,
)
return True
def _audit(
self, client_or_type: ServiceClient | str, actor_id: str | None = None
) -> AuditWriter:
if isinstance(client_or_type, ServiceClient):
return AuditWriter(self.session, "service_client", str(client_or_type.id))
return AuditWriter(self.session, client_or_type, actor_id or "unknown")
def _response(
self, row: ExternalCreativeAllocation, *, as_of: datetime | None = None
) -> ExternalAllocationResponse:
expired = False
if as_of is not None and row.state == "granted":
expiry = row.expires_at
expired = (
expiry is None
or (expiry.astimezone(UTC) if expiry.tzinfo else expiry.replace(tzinfo=UTC))
<= as_of
)
return ExternalAllocationResponse(
allocation_id=row.id,
project_id=row.project_id,
request_id=row.request_id,
installation_id=row.decision_evidence.get("installation_id", "unknown"),
workload=cast(CreativeWorkload, row.workload),
resource_ref=row.resource_ref,
provider_resource_id=row.provider_resource_id,
required_vram_bytes=row.required_vram_bytes,
priority=row.priority,
state=cast(CreativeAllocationState, "uncertain" if expired else row.state),
decision_code=("ALLOCATION_EXPIRED_UNCERTAIN" if expired else row.decision_code),
required_actions=cast(list[CreativeRequiredAction], row.required_actions or []),
accelerator_id=row.accelerator_id,
acquired_at=row.acquired_at,
heartbeat_at=row.heartbeat_at,
expires_at=None if expired else row.expires_at,
uncertain_at=row.uncertain_at,
released_at=row.released_at,
decision_evidence=row.decision_evidence or {},
queue_position=(row.decision_evidence or {}).get("queue_position"),
)
def _promote_queue(self) -> None:
self._lock_accelerators()
self._expire_grants()
queued = list(
self.session.scalars(
select(ExternalCreativeAllocation)
.where(ExternalCreativeAllocation.state == "queued")
.order_by(ExternalCreativeAllocation.created_at, ExternalCreativeAllocation.id)
.with_for_update()
)
)
queued.sort(
key=lambda item: (PRIORITY.get(item.priority, 9), item.created_at, str(item.id))
)
# Snapshot already includes all existing external reservations.
reserved: dict[uuid.UUID, int] = {}
budgets, now = self.serving.scheduler_overview(commit=False), _now()
remaining_position = 0
for item in queued:
if not self._queued_authority(item):
item.state, item.decision_code = "blocked", "QUEUED_AUTHORITY_REVOKED"
item.decision_evidence = {**item.decision_evidence, "queue_position": None}
self._audit("system", "external-creative-admission").write(
"EXTERNAL_CREATIVE_ALLOCATION_BLOCKED",
"external_creative_allocation",
str(item.id),
{"decision_code": item.decision_code},
)
continue
chosen = next(
(
budget
for budget in budgets
if budget.attribution_confidence != "UNKNOWN"
and budget.pressure_state not in {"HIGH", "CRITICAL"}
and budget.schedulable_free_vram_bytes - reserved.get(budget.accelerator_id, 0)
>= item.required_vram_bytes
),
None,
)
evidence = dict(item.decision_evidence or {})
if chosen is None:
remaining_position += 1
evidence["queue_position"] = remaining_position
item.decision_evidence = evidence
continue
item.state, item.decision_code, item.accelerator_id = (
"granted",
"CAPACITY_GRANTED_FROM_QUEUE",
chosen.accelerator_id,
)
item.acquired_at = item.heartbeat_at = item.updated_at = now
item.expires_at = now + timedelta(seconds=item.ttl_seconds)
# This grant is based on observed schedulable free VRAM. A forced
# unload would discard a reusable resident model despite admission
# already succeeding. Preparation is only meaningful when a future
# decision explicitly accounts for reclaimable provider memory.
item.required_actions = []
evidence.update(
{
"queue_position": None,
"promoted_at": now.isoformat(),
"policy_revision": chosen.policy_revision,
}
)
item.decision_evidence = evidence
self._audit("system", "external-creative-admission").write(
"EXTERNAL_CREATIVE_ALLOCATION_GRANTED",
"external_creative_allocation",
str(item.id),
{
"accelerator_id": str(chosen.accelerator_id),
"reserved_vram_bytes": item.required_vram_bytes,
},
)
reserved[chosen.accelerator_id] = (
reserved.get(chosen.accelerator_id, 0) + item.required_vram_bytes
)
def acquire(
self, request: ExternalAllocationCreate, client: ServiceClient
) -> ExternalAllocationResponse:
self._lock_accelerators()
self._expire_grants()
project_id = self._project(client)
self._priority(client, request.priority)
digest = _digest(request)
previous = self.session.scalar(
select(ExternalCreativeAllocation).where(
ExternalCreativeAllocation.service_client_id == client.id,
ExternalCreativeAllocation.idempotency_key == request.idempotency_key,
)
)
if previous:
if previous.request_sha256 != digest:
raise ServingError(
409,
"IDEMPOTENCY_CONFLICT",
"idempotency key belongs to another allocation request",
)
self.session.commit()
return self._response(previous)
binding = (
self.session.execute(
select(resources)
.where(
resources.c.project_id == project_id,
resources.c.resource_ref == request.resource_ref,
resources.c.workload == request.workload,
resources.c.provider_resource_id == request.provider_resource_id,
)
.with_for_update()
)
.mappings()
.one_or_none()
)
if binding is None or not binding["approved"]:
raise ServingError(
403,
"RESOURCE_NOT_APPROVED",
"resource and provider binding is not approved for this project",
)
profiled = binding["measured_peak_vram_bytes"] if request.use_measured_profile else None
required_vram = max(
request.required_vram_bytes, profiled or 0, binding["configured_vram_bytes"]
)
# Give already-queued work first access to newly available capacity, while
# allowing requests that fit to bypass queue entries that still cannot.
self._promote_queue()
budgets = self.serving.scheduler_overview(commit=False)
chosen = next(
(
b
for b in budgets
if b.attribution_confidence != "UNKNOWN"
and b.pressure_state not in {"HIGH", "CRITICAL"}
and b.schedulable_free_vram_bytes >= required_vram
),
None,
)
queued = list(
self.session.scalars(
select(ExternalCreativeAllocation).where(
ExternalCreativeAllocation.state == "queued"
)
)
)
queue_position = 1 + sum(
PRIORITY.get(item.priority, 9) <= PRIORITY[request.priority] for item in queued
)
state, code = ("granted", "CAPACITY_GRANTED") if chosen else ("queued", "CAPACITY_QUEUED")
observed_budget = chosen or max(
budgets, key=lambda budget: budget.schedulable_free_vram_bytes, default=None
)
actions: list[str] = []
evidence = {
"installation_id": request.installation_id,
"policy_revision": observed_budget.policy_revision
if observed_budget
else "unavailable",
"observed_at": observed_budget.observed_at.isoformat()
if observed_budget and observed_budget.observed_at
else None,
"headroom_before_bytes": (
observed_budget.schedulable_free_vram_bytes if observed_budget else 0
),
"profile_source": "measured" if profiled else "configured",
"requested_vram_bytes": request.required_vram_bytes,
"queue_position": None if chosen else queue_position,
}
now = _now()
row = ExternalCreativeAllocation(
service_client_id=client.id,
project_id=project_id,
accelerator_id=chosen.accelerator_id if chosen else None,
request_id=request.request_id,
idempotency_key=request.idempotency_key,
request_sha256=digest,
workload=request.workload,
resource_ref=request.resource_ref,
provider_resource_id=request.provider_resource_id,
required_vram_bytes=required_vram,
priority=request.priority,
ttl_seconds=request.ttl_seconds,
state=state,
decision_code=code,
decision_evidence=evidence,
required_actions=actions if chosen else [],
acquired_at=now if chosen else None,
heartbeat_at=now if chosen else None,
expires_at=now + timedelta(seconds=request.ttl_seconds) if chosen else None,
)
self.session.add(row)
try:
self.session.flush()
self._audit(client).write(
"EXTERNAL_CREATIVE_ALLOCATION_DECIDED",
"external_creative_allocation",
str(row.id),
{
"project_id": str(project_id),
"workload": row.workload,
"resource_ref": row.resource_ref,
"state": row.state,
"decision_code": row.decision_code,
"accelerator_id": str(row.accelerator_id) if row.accelerator_id else None,
},
)
self.session.commit()
except IntegrityError as exc:
self.session.rollback()
raise ServingError(
409,
"ALLOCATION_CONFLICT",
"allocation was concurrently created; retry with the same idempotency key",
) from exc
self.session.refresh(row)
return self._response(row)
def get(self, allocation_id: uuid.UUID, client: ServiceClient) -> ExternalAllocationResponse:
self._lock_accelerators()
self._project(client)
self._promote_queue()
row = self.session.get(ExternalCreativeAllocation, allocation_id)
if row is None or row.service_client_id != client.id:
raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found")
self.session.commit()
return self._response(row)
def action(
self,
allocation_id: uuid.UUID,
request_id: str,
action: str,
client: ServiceClient,
observed_peak_vram_bytes: int | None = None,
attestation: ExternalActionAttestation | None = None,
) -> ExternalAllocationResponse:
self._lock_accelerators()
row = self.session.get(
ExternalCreativeAllocation,
allocation_id,
with_for_update=True,
populate_existing=True,
)
if row is None or row.service_client_id != client.id or row.request_id != request_id:
raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found")
if self._expire(row) and action == "heartbeat":
self.session.commit()
raise ServingError(
409, "ALLOCATION_EXPIRED", "expired allocation retains an uncertain reservation"
)
if action == "heartbeat":
self._project(client)
if action == "attest" and attestation is None:
raise ServingError(422, "ATTESTATION_REQUIRED", "attestation is required")
now = _now()
if attestation is not None:
if attestation.action not in (row.required_actions or []):
raise ServingError(409, "ACTION_NOT_REQUIRED", "attested action was not required")
if attestation.observed_after > attestation.observed_before:
raise ServingError(
422, "ATTESTATION_INVALID", "action attestation did not release VRAM"
)
self.session.execute(
attestations.insert().values(
id=uuid.uuid4(),
allocation_id=row.id,
action=attestation.action,
before_vram_bytes=attestation.observed_before,
after_vram_bytes=attestation.observed_after,
observed_at=attestation.completed_at,
validated=True,
created_at=now,
)
)
if action == "release":
if row.state != "released":
row.state, row.released_at, row.updated_at = "released", now, now
elif action == "uncertain":
if row.state != "released":
row.state, row.uncertain_at, row.expires_at, row.updated_at = (
"uncertain",
now,
None,
now,
)
elif action == "heartbeat":
if row.state != "granted":
raise ServingError(
409, "ALLOCATION_NOT_ACTIVE", "only granted allocations can be renewed"
)
row.heartbeat_at, row.expires_at, row.updated_at = (
now,
now + timedelta(seconds=row.ttl_seconds),
now,
)
self._audit(client).write(
f"EXTERNAL_CREATIVE_ALLOCATION_{action.upper()}",
"external_creative_allocation",
str(row.id),
{"state": row.state, "workload": row.workload},
)
if action == "release":
self.session.flush()
self._promote_queue()
self.session.commit()
self.session.refresh(row)
return self._response(row)
def observe(
self, body: ExternalProfileObservation, client: ServiceClient
) -> ExternalResourceResponse:
self._lock_accelerators()
self._project(client)
row = self.session.get(
ExternalCreativeAllocation,
body.allocation_id,
with_for_update=True,
populate_existing=True,
)
if row is None or row.service_client_id != client.id:
raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found")
if self._expire(row):
self.session.commit()
if (
row.acquired_at is None
or row.accelerator_id is None
or row.state not in {"granted", "released"}
or body.reserved_vram_bytes != row.required_vram_bytes
):
raise ServingError(
409,
"PROFILE_GRANT_REQUIRED",
"observation requires an actual grant and exact reserved bytes",
)
if (row.resource_ref, row.provider_resource_id, row.workload) != (
body.resource_ref,
body.provider_resource_id,
body.workload,
):
raise ServingError(
409, "PROFILE_BINDING_MISMATCH", "observation does not match its allocation"
)
record = (
self.session.execute(
select(resources)
.where(
resources.c.project_id == row.project_id,
resources.c.resource_ref == row.resource_ref,
resources.c.workload == row.workload,
resources.c.provider_resource_id == row.provider_resource_id,
)
.with_for_update()
)
.mappings()
.one()
)
previous = self.session.execute(
select(observations.c.id).where(observations.c.allocation_id == row.id)
).scalar_one_or_none()
if previous is not None:
return self._resource(record["id"])
try:
self.session.execute(
observations.insert().values(
id=uuid.uuid4(),
allocation_id=row.id,
resource_ref=body.resource_ref,
provider_resource_id=body.provider_resource_id,
workload=body.workload,
observed_peak_vram_bytes=body.observed_peak_vram_bytes,
reserved_vram_bytes=row.required_vram_bytes,
duration_ms=body.duration_ms,
outcome=body.outcome,
created_at=_now(),
)
)
except IntegrityError as exc:
self.session.rollback()
raise ServingError(
409, "PROFILE_OBSERVATION_EXISTS", "allocation already has a profile observation"
) from exc
self.session.execute(
resources.update()
.where(resources.c.id == record["id"])
.values(
measured_peak_vram_bytes=max(
record["measured_peak_vram_bytes"] or 0, body.observed_peak_vram_bytes
),
measurement_count=record["measurement_count"] + 1,
updated_at=_now(),
)
)
self._audit(client).write(
"EXTERNAL_CREATIVE_PROFILE_OBSERVED",
"external_creative_resource",
str(record["id"]),
{"outcome": body.outcome, "observed_peak_vram_bytes": body.observed_peak_vram_bytes},
)
self.session.commit()
return self._resource(record["id"])
def _resource(self, identifier: uuid.UUID) -> ExternalResourceResponse:
record = (
self.session.execute(select(resources).where(resources.c.id == identifier))
.mappings()
.one()
)
return ExternalResourceResponse(
resource_id=identifier,
**{
key: record[key]
for key in ExternalResourceResponse.model_fields
if key != "resource_id"
},
)
def upsert_resource(
self, body: ExternalResourceApproval, actor_id: str
) -> ExternalResourceResponse:
record = (
self.session.execute(
select(resources)
.where(
resources.c.project_id == body.project_id,
resources.c.resource_ref == body.resource_ref,
resources.c.workload == body.workload,
resources.c.provider_resource_id == body.provider_resource_id,
)
.with_for_update()
)
.mappings()
.one_or_none()
)
identifier, now = (record["id"] if record else uuid.uuid4()), _now()
values = {**body.model_dump(), "updated_at": now}
if record:
self.session.execute(
resources.update().where(resources.c.id == identifier).values(**values)
)
else:
self.session.execute(
resources.insert().values(
id=identifier, measured_peak_vram_bytes=None, measurement_count=0, **values
)
)
self._audit("operator", actor_id).write(
"EXTERNAL_CREATIVE_RESOURCE_CONFIGURED",
"external_creative_resource",
str(identifier),
{"project_id": str(body.project_id), "approved": body.approved},
)
self.session.commit()
return self._resource(identifier)
def list_allocations(
self, state: str | None, limit: int, offset: int
) -> ExternalAllocationList:
observed_at = _now()
expired_grant = sa.and_(
ExternalCreativeAllocation.state == "granted",
sa.or_(
ExternalCreativeAllocation.expires_at.is_(None),
ExternalCreativeAllocation.expires_at <= observed_at,
),
)
effective_state = sa.case(
(expired_grant, "uncertain"), else_=ExternalCreativeAllocation.state
)
condition = effective_state == state if state else sa.true()
with self.session.no_autoflush:
total = (
self.session.scalar(
select(func.count()).select_from(ExternalCreativeAllocation).where(condition)
)
or 0
)
rows = self.session.scalars(
select(ExternalCreativeAllocation)
.where(condition)
.order_by(ExternalCreativeAllocation.created_at.desc())
.offset(offset)
.limit(limit)
).all()
return ExternalAllocationList(
items=[self._response(row, as_of=observed_at) for row in rows], total=total
)
def list_resources(
self, project_id: uuid.UUID | None, limit: int, offset: int
) -> ExternalResourceList:
condition = resources.c.project_id == project_id if project_id else sa.true()
total = (
self.session.scalar(select(func.count()).select_from(resources).where(condition)) or 0
)
records = (
self.session.execute(
select(resources)
.where(condition)
.order_by(resources.c.updated_at.desc())
.offset(offset)
.limit(limit)
)
.mappings()
.all()
)
return ExternalResourceList(
items=[
ExternalResourceResponse(
resource_id=record["id"],
**{
key: record[key]
for key in ExternalResourceResponse.model_fields
if key != "resource_id"
},
)
for record in records
],
total=total,
)
def reconcile(
self, allocation_id: uuid.UUID, action: str, reason: str, actor_id: str
) -> ExternalAllocationResponse:
self._lock_accelerators()
row = self.session.get(
ExternalCreativeAllocation,
allocation_id,
with_for_update=True,
populate_existing=True,
)
if row is None:
raise ServingError(404, "ALLOCATION_NOT_FOUND", "allocation was not found")
self._expire(row)
if row.state != "uncertain":
raise ServingError(
409, "ALLOCATION_NOT_UNCERTAIN", "only uncertain allocations can be reconciled"
)
if action == "confirm_released":
row.state, row.released_at, row.updated_at = "released", _now(), _now()
self.session.flush()
self._promote_queue()
self._audit("operator", actor_id).write(
"EXTERNAL_CREATIVE_ALLOCATION_RECONCILED",
"external_creative_allocation",
str(row.id),
{"action": action, "reason": reason},
)
self.session.commit()
self.session.refresh(row)
return self._response(row)
+208
View File
@@ -0,0 +1,208 @@
from __future__ import annotations
import hashlib
import json
from sqlalchemy.orm import Session
from modelforge_api.domain.lab import (
LabBinding,
LabEmbeddingInput,
LabInputSummary,
LabPreflightRequest,
LabPreflightResponse,
LabRerankingInput,
LabVisionInput,
)
from modelforge_api.persistence.models import DeploymentCandidate
from modelforge_api.services.manifest_registry import ManifestRegistry
from modelforge_api.services.registry import RegistryNotFound
from modelforge_api.services.runtime import RuntimeService
from modelforge_api.settings import Settings
FAMILY_MODALITIES = {
"rag.embedding": "embedding",
"rag.reranking": "reranking",
"document.ocr": "document",
"vision.embedding": "vision",
"speech.transcription": "audio",
}
class LabPreflightService:
"""Read-only snapshot validation, never an execution grant or resource reservation."""
def __init__(self, session: Session, settings: Settings) -> None:
self.session = session
self.settings = settings
self.runtime = RuntimeService(session, settings)
def preflight(self, request: LabPreflightRequest) -> LabPreflightResponse:
with self.session.no_autoflush:
return self._preflight(request)
def _preflight(self, request: LabPreflightRequest) -> LabPreflightResponse:
payload = request.input.payload.model_dump(mode="json")
encoded = json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode()
count = 1
blockers: list[str] = []
if isinstance(request.input, LabEmbeddingInput):
values = request.input.payload.inputs()
count = len(values)
if count > self.settings.gateway_max_batch_size or any(
len(value) > self.settings.gateway_max_input_characters for value in values
):
blockers.append("INPUT_LIMIT_EXCEEDED")
elif isinstance(request.input, LabRerankingInput):
count = len(request.input.payload.documents)
elif isinstance(request.input, LabVisionInput):
count = len(request.input.payload.items)
if len(encoded) > self.settings.gateway_max_payload_bytes:
blockers.append("PAYLOAD_LIMIT_EXCEEDED")
summary = LabInputSummary(
sha256=hashlib.sha256(encoded).hexdigest(), item_count=count, encoded_bytes=len(encoded)
)
contract = next(
(
item
for item in ManifestRegistry(self.settings.config_root).capabilities()
if item.capability == request.input.capability
and item.version == request.contract_version
),
None,
)
if contract is None:
blockers.append("CAPABILITY_CONTRACT_UNAVAILABLE")
binding: LabBinding | None = None
target = request.target
candidate = self.session.get(DeploymentCandidate, target.deployment_candidate_id)
if candidate is None:
blockers.append("CANDIDATE_NOT_FOUND")
else:
if (
candidate.production
or candidate.channel != "lab"
or candidate.status != "lab_ready"
):
blockers.append("CANDIDATE_NOT_LAB_READY")
if (
candidate.artifact_set_id != target.artifact_set_id
or candidate.runtime_profile_id != target.runtime_profile_id
or candidate.compute_node_id != target.compute_node_id
):
blockers.append("TARGET_IDENTITY_MISMATCH")
repo = self.runtime.repo
profile = repo.profile(candidate.runtime_profile_id)
node = repo.node(candidate.compute_node_id)
assessment = repo.assessment(candidate.compatibility_assessment_id)
probe = repo.probe(candidate.runtime_probe_id)
approval = repo.approval(target.execution_approval_id)
if not profile or not node or not assessment or not probe or not approval:
blockers.append("EVIDENCE_MISSING")
else:
if (
profile.artifact_set_id != candidate.artifact_set_id
or any(
item.artifact_set_id != candidate.artifact_set_id
or item.runtime_profile_id != candidate.runtime_profile_id
or item.compute_node_id != candidate.compute_node_id
for item in (assessment, probe)
)
or probe.compatibility_assessment_id != assessment.id
or probe.execution_approval_id != approval.id
or approval.artifact_set_id != candidate.artifact_set_id
):
blockers.append("EVIDENCE_IDENTITY_MISMATCH")
if profile.modality != FAMILY_MODALITIES[request.input.capability]:
blockers.append("CAPABILITY_PROFILE_MISMATCH")
if profile.trust_remote_code or profile.network_egress:
blockers.append("RUNTIME_POLICY_UNSUPPORTED")
if (
not node.enabled
or not node.lab_eligible
or node.liveness_state != "online"
or not {
"deployment.load.v1",
"deployment.invoke.v1",
"deployment.unload.v1",
}.issubset(node.agent_capabilities)
):
blockers.append("NODE_NOT_READY")
try:
if (
assessment.status not in {"compatible", "requires_probe"}
or assessment.blockers
or self.runtime.assessment_response(assessment).stale
):
blockers.append("ASSESSMENT_STALE_OR_BLOCKED")
if (
self.runtime.approval_response(approval).stale
or approval.scope != "lab_execution"
):
blockers.append("LAB_APPROVAL_STALE")
except RegistryNotFound:
blockers.append("ARTIFACT_EVIDENCE_MISSING")
if probe.status != "completed" or probe.cancel_requested or probe.failure_code:
blockers.append("PROBE_NOT_COMPLETED")
result = probe.inference_result
capability = request.input.capability
valid_output = False
if capability in {"rag.embedding", "vision.embedding"}:
dimension = result.get("dimension")
valid_output = (
isinstance(dimension, int)
and not isinstance(dimension, bool)
and 32 <= dimension <= 8192
and result.get("finite") is True
and result.get("output_type") in (None, "float_vector")
and (
not contract
or not contract.vector
or contract.vector.dimensionality is None
or dimension == contract.vector.dimensionality
)
)
elif capability == "rag.reranking":
valid_output = (
result.get("output_type") == "ranked_scores"
and result.get("finite") is True
and probe.runtime_facts.get("adapter") == "qwen3_reranker"
)
else:
expected = "ocr_text" if capability == "document.ocr" else "transcription"
valid_output = (
result.get("output_type") == expected and result.get("output_valid") is True
)
if not valid_output:
blockers.append("PROBE_OUTPUT_INVALID")
gpu_uuid = probe.runtime_facts.get("gpu_uuid")
accelerator = next(
(item for item in repo.accelerators(node.id) if item.device_uuid == gpu_uuid),
None,
)
if accelerator is None:
blockers.append("PROBED_ACCELERATOR_MISSING")
# Only expose a coherent server-resolved binding, never echo a mismatched target.
if not {"TARGET_IDENTITY_MISMATCH", "EVIDENCE_IDENTITY_MISMATCH"}.intersection(
blockers
):
binding = LabBinding(
**target.model_dump(),
compatibility_assessment_id=assessment.id,
runtime_probe_id=probe.id,
capability=capability,
runtime_profile_fingerprint=profile.fingerprint,
assessment_fingerprint=assessment.environment_fingerprint,
approval_evidence_fingerprint=approval.evidence_fingerprint,
probe_environment_fingerprint=probe.environment_fingerprint,
accelerator_id=accelerator.id if accelerator else None,
)
return LabPreflightResponse(
input_summary=summary,
binding=binding,
limits=request.limits,
blockers=list(dict.fromkeys(blockers)),
checks_passed=not blockers,
)
@@ -0,0 +1,805 @@
from __future__ import annotations
import hashlib
import json
import re
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from modelforge_api.domain.dispatch_context import DispatchContext
from modelforge_api.domain.lab import (
LabEmbeddingInput,
LabExecutionRunResponse,
LabExecutionStartRequest,
LabInputSummary,
)
from modelforge_api.persistence.models import (
CapabilityDeployment,
GatewayRequest,
LabExecutionRun,
ServingGpuLease,
ServingJob,
)
from modelforge_api.services.gpu_scheduler import PlacementVerdict
from modelforge_api.services.serving import ServingError, ServingService
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
_ACTIVE_STATUSES = frozenset({"accepted", "preparing", "queued", "running", "cancel_requested"})
_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "interrupted"})
_RESIDENT_STATES = frozenset({"warm", "idle"})
def _canonical_json(value: Any) -> bytes:
return json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
).encode("utf-8")
def _sha256(value: Any) -> str:
return hashlib.sha256(_canonical_json(value)).hexdigest()
def _now() -> datetime:
return datetime.now(UTC)
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
class LabRunService:
"""Persist actor-scoped identities for bounded, selected embedding runs."""
def __init__(self, serving: ServingService) -> None:
self.serving = serving
self.session = serving.session
def _owned(self, run_id: UUID, actor_subject: str) -> LabExecutionRun:
run = self.session.scalar(
select(LabExecutionRun).where(
LabExecutionRun.id == run_id,
LabExecutionRun.actor_subject == actor_subject,
)
)
if run is None:
raise ServingError(404, "LAB_RUN_NOT_FOUND", "LAB run was not found")
return run
@staticmethod
def _response(run: LabExecutionRun) -> LabExecutionRunResponse:
return LabExecutionRunResponse(
id=run.id,
status=run.status, # type: ignore[arg-type]
target_snapshot=run.target_snapshot,
input_summary=LabInputSummary(
sha256=run.input_sha256,
item_count=run.input_count,
encoded_bytes=run.input_encoded_bytes,
),
output_metadata=run.output_metadata,
worker_observations=run.worker_observations,
failure_code=run.failure_code,
input_payload_expires_at=run.input_payload_expires_at,
retention_seconds=run.retention_seconds,
created_at=run.created_at,
started_at=run.started_at,
cancellation_requested_at=run.cancellation_requested_at,
cancellation_acknowledged_at=run.cancellation_acknowledged_at,
finished_at=run.finished_at,
)
def _fingerprint(self, request: LabExecutionStartRequest) -> str:
return _sha256(request.model_dump(mode="json"))
def _selected_target(
self, request: LabExecutionStartRequest
) -> tuple[CapabilityDeployment, dict[str, Any], LabEmbeddingInput]:
deployment = self.serving.repo.deployment(request.deployment_id)
if deployment is None:
raise ServingError(404, "LAB_TARGET_NOT_FOUND", "selected LAB target was not found")
target = request.target
if (
deployment.deployment_candidate_id != target.deployment_candidate_id
or deployment.artifact_set_id != target.artifact_set_id
or deployment.runtime_profile_id != target.runtime_profile_id
or deployment.compute_node_id != target.compute_node_id
or deployment.execution_approval_id != target.execution_approval_id
):
raise ServingError(
409, "LAB_TARGET_MISMATCH", "selected LAB target identity does not match"
)
if not isinstance(request.input, LabEmbeddingInput):
raise ServingError(
422, "LAB_INPUT_NOT_SUPPORTED", "operator execution supports text embeddings only"
)
selected, _route_key = self.serving._admit_operator_embedding(
request.input, deployment.id, request.experiment_route_id
)
return (
selected,
self.serving._operator_lab_target_snapshot(selected, request.experiment_route_id),
request.input,
)
def _gateway(self, run: LabExecutionRun) -> GatewayRequest | None:
return self.session.get(GatewayRequest, run.gateway_request_id)
def _set_gateway_state(
self,
run: LabExecutionRun,
status: str,
*,
failure_code: str | None = None,
) -> None:
gateway = self._gateway(run)
if gateway is None:
return
gateway.status = status
if status in {"completed", "failed", "cancelled", "interrupted"}:
gateway.finished_at = _now()
if failure_code:
gateway.failure_code = failure_code
evidence = gateway.decision_evidence or {}
lab_evidence = evidence.get("lab_run_evidence", {})
if isinstance(lab_evidence, dict):
updated = {**lab_evidence, "status": status}
if failure_code:
updated["failure_code"] = failure_code
if run.output_metadata:
updated["output"] = run.output_metadata
if run.worker_observations:
updated["worker_observations"] = run.worker_observations
gateway.decision_evidence = {**evidence, "lab_run_evidence": updated}
def _delete_payload(self, run: LabExecutionRun) -> None:
if run.payload_reference:
self.serving.payload_store.delete(run.payload_reference)
run.payload_reference = None
def _release_reservation(self, run: LabExecutionRun, *, failed: bool = False) -> None:
gateway = self._gateway(run)
if gateway is None:
return
now = _now()
request_id = gateway.request_id
lease = self.session.scalar(
select(ServingGpuLease)
.where(ServingGpuLease.request_id == request_id)
.order_by(ServingGpuLease.created_at.desc())
.limit(1)
)
if lease is None:
return
if lease and lease.state not in {"released", "expired", "failed"}:
lease.state = "failed" if failed else "released"
lease.released_at = now
deployment = (
self.serving.repo.deployment(gateway.capability_deployment_id)
if gateway.capability_deployment_id
else None
)
residency = self.serving.repo.residency(deployment.id, lock=True) if deployment else None
if residency:
residency.active_requests = max(0, residency.active_requests - 1)
if residency.state == "busy":
residency.state = "warm"
elif residency.state == "loading":
residency.state = "cold"
residency.transition_reason = "LAB_RUN_CANCELLED_BEFORE_LOAD"
residency.last_used_at = now
def _finish(
self,
run: LabExecutionRun,
status: str,
*,
failure_code: str | None = None,
) -> None:
run.status = status
run.failure_code = failure_code
run.finished_at = _now()
self._set_gateway_state(run, status, failure_code=failure_code)
self._delete_payload(run)
def start(
self,
request: LabExecutionStartRequest,
*,
actor_subject: str,
idempotency_key: str,
) -> LabExecutionRunResponse:
if not actor_subject or len(actor_subject) > 255:
raise ServingError(
401, "OPERATOR_CONTEXT_REQUIRED", "operator principal is unavailable"
)
if not _IDEMPOTENCY_KEY.fullmatch(idempotency_key):
raise ServingError(422, "LAB_IDEMPOTENCY_KEY_INVALID", "idempotency key is invalid")
fingerprint = self._fingerprint(request)
existing = self.session.scalar(
select(LabExecutionRun).where(
LabExecutionRun.actor_subject == actor_subject,
LabExecutionRun.idempotency_key == idempotency_key,
)
)
if existing is not None:
if existing.request_fingerprint != fingerprint:
raise ServingError(
409,
"LAB_IDEMPOTENCY_CONFLICT",
"idempotency key was already used for a different LAB request",
)
return self._response(existing)
deployment, target_snapshot, embedding_input = self._selected_target(request)
payload = embedding_input.payload
inputs = payload.inputs()
input_sha256 = _sha256(inputs)
encoded_input = _canonical_json(payload.model_dump(mode="json"))
run_id = uuid4()
context = DispatchContext.for_operator(actor_subject, run_id)
now = _now()
payload_reference = self.serving.payload_store.put(
{"input": inputs, "input_type": payload.input_type},
request.limits.retention_seconds,
)
gateway_request = GatewayRequest(
request_id=uuid4(),
service_client_id=None,
capability_key="rag.embedding",
capability_version=1,
capability_deployment_id=deployment.id,
compute_node_id=deployment.compute_node_id,
status="queued",
priority="lab",
input_sha256=input_sha256,
input_count=len(inputs),
decision_evidence={
"dispatch_context": context.provenance(),
"operator_route_id": str(request.experiment_route_id),
"input_persisted": False,
"input_type": payload.input_type,
"lab_run_evidence": {
"schema_version": 1,
"status": "accepted",
"capability": "rag.embedding@1",
"operation_id": str(run_id),
"target": target_snapshot,
"input": {
"sha256": input_sha256,
"item_count": len(inputs),
"content_logged": False,
},
},
},
)
self.session.add(gateway_request)
try:
self.session.flush()
run = LabExecutionRun(
id=run_id,
actor_subject=actor_subject,
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
status="accepted",
capability="rag.embedding@1",
target_snapshot=target_snapshot,
input_sha256=input_sha256,
input_count=len(inputs),
input_encoded_bytes=len(encoded_input),
payload_reference=payload_reference,
input_payload_expires_at=now + timedelta(seconds=request.limits.retention_seconds),
gateway_request_id=gateway_request.id,
timeout_seconds=request.limits.timeout_seconds,
queue_timeout_seconds=request.limits.queue_timeout_seconds,
retention_seconds=request.limits.retention_seconds,
created_at=now,
)
self.session.add(run)
self.session.commit()
except IntegrityError:
self.session.rollback()
winner = self.session.scalar(
select(LabExecutionRun).where(
LabExecutionRun.actor_subject == actor_subject,
LabExecutionRun.idempotency_key == idempotency_key,
)
)
self.serving.payload_store.delete(payload_reference)
if winner is not None and winner.request_fingerprint == fingerprint:
return self._response(winner)
if winner is not None:
raise ServingError(
409,
"LAB_IDEMPOTENCY_CONFLICT",
"idempotency key was already used for a different LAB request",
) from None
raise ServingError(409, "STATE_CONFLICT", "LAB run could not be persisted") from None
except Exception:
self.session.rollback()
self.serving.payload_store.delete(payload_reference)
raise
self.session.refresh(run)
return self._response(run)
def _active_job(self, run: LabExecutionRun) -> ServingJob | None:
return self.session.scalar(
select(ServingJob)
.where(ServingJob.lab_execution_run_id == run.id)
.order_by(ServingJob.created_at.desc())
.limit(1)
)
def _input(self, run: LabExecutionRun) -> LabEmbeddingInput | None:
if not run.payload_reference or _aware(run.input_payload_expires_at) <= _now():
return None
payload = self.serving.payload_store.get(run.payload_reference)
if not payload:
return None
values = payload.get("input")
input_type = payload.get("input_type", "raw")
if (
not isinstance(values, list)
or not values
or len(values) != run.input_count
or any(not isinstance(value, str) for value in values)
or input_type not in {"raw", "query", "document"}
or _sha256(values) != run.input_sha256
):
return None
try:
return LabEmbeddingInput.model_validate(
{
"capability": "rag.embedding",
"payload": {"input": values, "input_type": input_type},
}
)
except ValueError:
return None
def _revalidate_target(
self, run: LabExecutionRun, input_value: LabEmbeddingInput
) -> CapabilityDeployment:
deployment_id = UUID(str(run.target_snapshot["deployment_id"]))
route_id = UUID(str(run.target_snapshot["experiment_route_id"]))
deployment, _ = self.serving._admit_operator_embedding(input_value, deployment_id, route_id)
current_snapshot = self.serving._operator_lab_target_snapshot(deployment, route_id)
if current_snapshot != run.target_snapshot:
raise ServingError(
409, "LAB_TARGET_CHANGED", "selected LAB target changed after run acceptance"
)
return deployment
def _add_job(
self,
run: LabExecutionRun,
deployment: CapabilityDeployment,
gateway: GatewayRequest,
operation: str,
*,
now: datetime,
) -> ServingJob:
key = _sha256({"run_id": str(run.id), "operation": operation})
existing = self.session.scalar(select(ServingJob).where(ServingJob.idempotency_key == key))
if existing is not None:
return existing
job = ServingJob(
capability_deployment_id=deployment.id,
compute_node_id=deployment.compute_node_id,
gateway_request_id=gateway.id,
lab_execution_run_id=run.id,
operation=operation,
status="queued",
priority="lab",
idempotency_key=key,
payload_reference=(run.payload_reference if operation == "invoke" else None),
attempt_count=0,
result_summary={},
created_at=now,
updated_at=now,
)
self.session.add(job)
self.session.flush()
return job
def _queue_invoke(
self,
run: LabExecutionRun,
deployment: CapabilityDeployment,
gateway: GatewayRequest,
*,
now: datetime,
) -> bool:
if self._input(run) is None:
self._finish(run, "failed", failure_code="REQUEST_PAYLOAD_EXPIRED")
self._release_reservation(run, failed=True)
return False
if self.serving.repo.queued_job_count(deployment.id) >= deployment.max_queue_depth:
return False
if (
self.serving.repo.active_gateway_requests()
> self.serving.settings.scheduler_global_queue_limit
):
return False
job = self._add_job(run, deployment, gateway, "invoke", now=now)
lease = self.session.scalar(
select(ServingGpuLease)
.where(ServingGpuLease.request_id == gateway.request_id)
.order_by(ServingGpuLease.created_at.desc())
.limit(1)
)
if lease:
lease.serving_job_id = job.id
run.status = "queued"
gateway.status = "running"
self._set_gateway_state(run, "queued")
self.session.commit()
return True
def _admit_and_queue(self, run: LabExecutionRun, now: datetime) -> bool:
input_value = self._input(run)
if input_value is None:
self._finish(run, "failed", failure_code="REQUEST_PAYLOAD_EXPIRED")
self.session.commit()
return True
try:
deployment = self._revalidate_target(run, input_value)
except ServingError as exc:
self._finish(run, "failed", failure_code=exc.code)
self.session.commit()
return True
gateway = self._gateway(run)
if gateway is None:
self._finish(run, "failed", failure_code="LAB_EVIDENCE_MISSING")
self.session.commit()
return True
if self.serving.repo.queued_job_count(deployment.id) >= deployment.max_queue_depth:
return False
if (
self.serving.repo.active_gateway_requests()
> self.serving.settings.scheduler_global_queue_limit
):
return False
self.serving.repo.lock_accelerator(deployment.accelerator_id)
residency = self.serving.repo.residency(deployment.id, lock=True)
if residency is None:
self._finish(run, "failed", failure_code="SCHEDULER_STATE_STALE")
self.session.commit()
return True
if residency.active_requests >= deployment.max_concurrency:
return False
if residency.state not in _RESIDENT_STATES and residency.state != "cold":
if residency.state in {"loading", "busy", "unloading", "draining"}:
return False
self._finish(run, "failed", failure_code="LAB_TARGET_UNAVAILABLE")
self.session.commit()
return True
plan = self.serving._placement_plan(
deployment,
priority="lab",
deadline_remaining_ms=float(run.timeout_seconds * 1000),
request_id=gateway.request_id,
dry_run=True,
persist=False,
commit=False,
)
if plan.verdict == PlacementVerdict.QUEUE.value:
self.session.commit()
return False
if plan.verdict == PlacementVerdict.ADMIT_AFTER_EVICTION.value:
self._finish(run, "failed", failure_code="LAB_EVICTION_REQUIRED")
self.session.commit()
return True
if plan.verdict != PlacementVerdict.ADMIT.value:
code = {
PlacementVerdict.REJECT_HEALTH.value: "LAB_TARGET_UNAVAILABLE",
PlacementVerdict.REJECT_POLICY.value: "LAB_POLICY_BLOCKED",
PlacementVerdict.REJECT_CAPACITY.value: "LAB_CAPACITY_BLOCKED",
}.get(plan.verdict, "LAB_ADMISSION_BLOCKED")
self._finish(run, "failed", failure_code=code)
self.session.commit()
return True
context = DispatchContext.for_operator(run.actor_subject, run.id)
deadline = _aware(run.created_at) + timedelta(seconds=run.timeout_seconds)
lease = ServingGpuLease(
accelerator_id=deployment.accelerator_id,
capability_deployment_id=deployment.id,
request_id=gateway.request_id,
reserved_vram_bytes=plan.required_vram_bytes,
priority="lab",
state="active",
owner=context.lease_owner,
created_at=now,
acquired_at=now,
heartbeat_at=now,
expires_at=deadline,
lease_type=(
"request_execution"
if residency.state in _RESIDENT_STATES
else "residency_reservation"
),
materialized_vram_bytes=(
residency.measured_resident_vram_bytes if residency.state in _RESIDENT_STATES else 0
),
generation=residency.generation,
)
self.session.add(lease)
gateway.status = "running"
gateway.decision_evidence = {
**(gateway.decision_evidence or {}),
"dispatch_context": context.provenance(),
"placement": {
"verdict": plan.verdict,
"decision_fingerprint": plan.decision_fingerprint,
"required_vram_bytes": plan.required_vram_bytes,
"reason_codes": plan.reason_codes,
},
}
run.dispatch_started_at = now
if residency.state == "cold":
residency.state = "loading"
residency.transition_started_at = now
residency.transition_reason = "LAB_RUN_LOAD"
residency.generation += 1
operation = "load"
run.status = "preparing"
else:
residency.state = "busy"
operation = "invoke"
run.status = "queued"
residency.active_requests += 1
residency.last_used_at = now
job = self._add_job(run, deployment, gateway, operation, now=now)
self.session.flush()
lease.serving_job_id = job.id
lab_evidence = (gateway.decision_evidence or {}).get("lab_run_evidence", {})
if isinstance(lab_evidence, dict):
gateway.decision_evidence = {
**gateway.decision_evidence,
"lab_run_evidence": {**lab_evidence, "status": run.status},
}
self.session.commit()
return True
def _drive_preparing(self, run: LabExecutionRun, now: datetime) -> bool:
gateway = self._gateway(run)
deployment = (
self.serving.repo.deployment(gateway.capability_deployment_id)
if gateway and gateway.capability_deployment_id
else None
)
if gateway is None or deployment is None:
self._finish(run, "failed", failure_code="LAB_EVIDENCE_MISSING")
self.session.commit()
return True
jobs = list(
self.session.scalars(
select(ServingJob)
.where(ServingJob.lab_execution_run_id == run.id)
.order_by(ServingJob.created_at.desc())
)
)
latest = jobs[0] if jobs else None
if latest is None:
self._finish(run, "interrupted", failure_code="LAB_DISPATCH_STATE_INCOMPLETE")
self.session.commit()
return True
if latest.status in {"queued", "running"}:
return False
if latest.operation != "load" or latest.status != "completed":
self._finish(run, "failed", failure_code=latest.failure_code or "RESIDENCY_LOAD_FAILED")
self._release_reservation(run, failed=True)
self.session.commit()
return True
input_value = self._input(run)
if input_value is None:
self._finish(run, "failed", failure_code="REQUEST_PAYLOAD_EXPIRED")
self._release_reservation(run, failed=True)
self.session.commit()
return True
try:
selected = self._revalidate_target(run, input_value)
except ServingError as exc:
self._finish(run, "failed", failure_code=exc.code)
self._release_reservation(run, failed=True)
self.session.commit()
return True
if _aware(run.created_at) + timedelta(seconds=run.timeout_seconds) <= now:
self._finish(run, "failed", failure_code="REQUEST_TIMEOUT")
self._release_reservation(run, failed=True)
self.session.commit()
return True
self._queue_invoke(run, selected, gateway, now=now)
return run.status == "queued"
def _expire_run(self, run: LabExecutionRun, now: datetime) -> bool:
gateway = self._gateway(run)
if gateway is None:
self._finish(run, "failed", failure_code="LAB_EVIDENCE_MISSING")
self.session.commit()
return True
job = self._active_job(run)
if job and job.status == "running":
run.status = "cancel_requested"
run.failure_code = "REQUEST_TIMEOUT"
run.cancellation_requested_at = run.cancellation_requested_at or now
self._set_gateway_state(run, "cancel_requested", failure_code="REQUEST_TIMEOUT")
self._delete_payload(run)
self.session.commit()
return True
# Do not lock/update a queued job here: claim and completion lock job then run.
# The terminal run state fences a concurrent claim, which will mark that job
# failed after acquiring the same locks in the established order.
self._release_reservation(run, failed=True)
self._finish(run, "failed", failure_code="REQUEST_TIMEOUT")
self.session.commit()
return True
def _interrupt_stale_job(self, job_id: UUID, now: datetime) -> bool:
job = self.session.scalar(
select(ServingJob).where(ServingJob.id == job_id).with_for_update()
)
if (
job is None
or job.lab_execution_run_id is None
or job.status != "running"
or job.lease_expires_at is None
or _aware(job.lease_expires_at) > now
):
self.session.rollback()
return False
run = self.session.scalar(
select(LabExecutionRun)
.where(LabExecutionRun.id == job.lab_execution_run_id)
.with_for_update()
)
if run is None or run.status in _TERMINAL_STATUSES:
self.session.rollback()
return False
job.status = "failed"
job.failure_code = "LAB_WORKER_LEASE_STALE"
job.finished_at = now
job.lease_token_hash = None
job.lease_expires_at = None
gateway = self._gateway(run)
deployment = (
self.serving.repo.deployment(gateway.capability_deployment_id)
if gateway and gateway.capability_deployment_id
else None
)
if deployment:
deployment.health_status = "unavailable"
residency = self.serving.repo.residency(deployment.id, lock=True)
if residency:
residency.state = "failed"
residency.failure_code = "LAB_WORKER_LEASE_STALE"
residency.failure_message = None
residency.transition_reason = "LAB_WORKER_LEASE_STALE"
self._finish(run, "interrupted", failure_code="LAB_WORKER_LEASE_STALE")
# The GPU reservation is intentionally left until its own expiry. A missing
# worker heartbeat is not proof that the process has stopped using the device.
self.session.commit()
return True
def reconcile(self) -> dict[str, int]:
now = _now()
interrupted = 0
stale_job_ids = list(
self.session.scalars(
select(ServingJob.id)
.where(
ServingJob.lab_execution_run_id.is_not(None),
ServingJob.status == "running",
ServingJob.lease_expires_at <= now,
)
.order_by(ServingJob.lease_expires_at)
.limit(100)
)
)
for job_id in stale_job_ids:
interrupted += self._interrupt_stale_job(job_id, now)
driven = 0
candidate_ids = list(
self.session.scalars(
select(LabExecutionRun.id)
.where(LabExecutionRun.status.in_(("accepted", "preparing", "queued")))
.order_by(LabExecutionRun.created_at)
.limit(100)
)
)
for run_id in candidate_ids:
run = self.session.scalar(
select(LabExecutionRun)
.where(
LabExecutionRun.id == run_id,
LabExecutionRun.status.in_(("accepted", "preparing", "queued")),
)
.with_for_update(skip_locked=True)
)
if run is None:
self.session.rollback()
continue
now = _now()
if _aware(run.created_at) + timedelta(seconds=run.timeout_seconds) <= now:
driven += self._expire_run(run, now)
continue
if run.status in {"accepted", "queued"} and (
_aware(run.created_at) + timedelta(seconds=run.queue_timeout_seconds) <= now
):
self._expire_run(run, now)
driven += 1
continue
if run.status == "accepted":
driven += self._admit_and_queue(run, now)
elif run.status == "preparing":
driven += self._drive_preparing(run, now)
elif run.status == "queued":
job = self._active_job(run)
if job is None or job.status not in {"queued", "running"}:
self._finish(run, "interrupted", failure_code="LAB_DISPATCH_STATE_INCOMPLETE")
self.session.commit()
driven += 1
else:
self.session.rollback()
return {"runs_driven": driven, "stale_runs_interrupted": interrupted}
def get(self, run_id: UUID, *, actor_subject: str) -> LabExecutionRunResponse:
return self._response(self._owned(run_id, actor_subject))
def cancel(self, run_id: UUID, *, actor_subject: str) -> LabExecutionRunResponse:
# Check ownership before taking locks so a foreign id stays indistinguishable from a
# missing run. Lock order is job then run, matching claim and worker completion.
self._owned(run_id, actor_subject)
job = self.session.scalar(
select(ServingJob)
.where(
ServingJob.lab_execution_run_id == run_id,
ServingJob.status.in_(("queued", "running")),
)
.order_by(ServingJob.created_at.desc())
.limit(1)
.with_for_update()
)
run = self.session.scalar(
select(LabExecutionRun)
.where(
LabExecutionRun.id == run_id,
LabExecutionRun.actor_subject == actor_subject,
)
.with_for_update()
)
if run is None:
raise ServingError(404, "LAB_RUN_NOT_FOUND", "LAB run was not found")
if run.status in _TERMINAL_STATUSES:
self.session.commit()
return self._response(run)
now = _now()
if job is not None and job.status == "running":
# Inference is synchronous inside the worker. The run remains nonterminal until
# the worker reaches a safe boundary and acknowledges cancellation.
run.status = "cancel_requested"
run.cancellation_requested_at = run.cancellation_requested_at or now
self._delete_payload(run)
self._set_gateway_state(run, "cancel_requested")
else:
if job is not None:
job.status = "cancelled"
job.failure_code = "CANCELLED"
job.finished_at = now
job.lease_token_hash = None
job.lease_expires_at = None
job.payload_reference = None
self._release_reservation(run)
self._finish(run, "cancelled")
run.cancellation_requested_at = run.cancellation_requested_at or now
run.cancellation_acknowledged_at = None
self.session.commit()
if job is not None and job.status == "cancelled":
# The worker never claimed this job, so no worker acknowledgement is expected.
pass
self.session.refresh(run)
return self._response(run)
+207 -22
View File
@@ -75,6 +75,9 @@ from modelforge_api.persistence.models import (
RuntimeProbe,
RuntimeProfile,
)
from modelforge_api.services.audit import AuditContext, AuditWriter
from modelforge_api.services.lifecycle_serving import LifecycleServing
from modelforge_api.services.serving import ServingError
class LifecycleError(RuntimeError):
@@ -94,6 +97,13 @@ def _now() -> datetime:
return datetime.now(UTC)
def _serving_changed_at(operation: LifecycleOperation) -> str:
if operation.finished_at is None:
raise LifecycleError(409, "SERVING_JOURNAL_INVALID", "committed serving timestamp is missing")
value = operation.finished_at
return (value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)).isoformat()
DEFAULT_POLICIES: tuple[dict[str, Any], ...] = (
{
"key": "lab-promotion",
@@ -305,6 +315,18 @@ class LifecycleService:
"SUBJECT_TARGET_MISMATCH",
"approval target does not match lifecycle subject",
)
if request.evidence.serving_transition is not None:
selected = request.evidence.serving_transition
if request.evidence.production_validation != "SATISFIED":
raise LifecycleError(409, "SERVING_PRODUCTION_VALIDATION_REQUIRED", "actual project serving requires satisfied production validation")
if (request.environment != "PRODUCTION" or policy.scope != "CAPABILITY_PRODUCTION"
or request.target_type != "CAPABILITY_DEPLOYMENT"
or request.target_ref != str(selected.candidate_deployment_id)
or request.requested_transition != "STABLE"
or request.evidence.capability_deployment_id != selected.candidate_deployment_id
or request.evidence.production_execution_approval_id != selected.production_approval_id
or request.evidence.rollback_target_ref != str(selected.current_deployment_id)):
raise LifecycleError(422, "SERVING_APPROVAL_MISMATCH", "serving transition requires exact production approval scope and targets")
resolved = self._resolve_evidence(request.evidence.model_dump(mode="json"))
snapshot = {
"declared": request.evidence.model_dump(mode="json"),
@@ -400,7 +422,9 @@ class LifecycleService:
item.status = ApprovalStatus.REJECTED
event_type = "APPROVAL_REJECTED"
elif action == "revoke":
if item.status != ApprovalStatus.APPROVED:
if item.status != ApprovalStatus.APPROVED and not (
item.status == ApprovalStatus.STALE and item.approved_by is not None
):
raise LifecycleError(409, "APPROVAL_NOT_APPROVED", "only approval can be revoked")
item.status = ApprovalStatus.REVOKED
event_type = "APPROVAL_REVOKED"
@@ -468,13 +492,29 @@ class LifecycleService:
"APPROVAL_MIGRATION_MISMATCH",
"promotion plan migration differs from approved evidence",
)
serving_contract = approval.evidence_snapshot.get("resolved", {}).get("serving_transition")
if "serving_transition" in request.affected_identities:
raise LifecycleError(422, "SERVING_CONTRACT_SERVER_OWNED", "serving contract is derived from approved evidence")
if serving_contract is not None:
if (request.canary_strategy or request.migration_class not in {"transparent", "behavioral"}
or subject.target_type != "CAPABILITY_DEPLOYMENT" or subject.environment != "PRODUCTION"
or subject.target_ref != str(request.candidate_deployment_id)
or subject.state not in {"LAB_READY", "PROMOTION_ELIGIBLE"}
or request.desired_state != "STABLE"):
raise LifecycleError(422, "SERVING_PLAN_UNSUPPORTED", "only reviewed same-space stable replacement is supported")
contract = LifecycleServing(self.session).validate(serving_contract)
request = request.model_copy(update={
"affected_identities": {**request.affected_identities, "serving_transition": serving_contract},
"project_consumers": sorted({value["project_id"] for value in contract.bindings.values()}),
})
try:
intermediate = (
DeploymentLifecycleState.CANARY
if request.canary_strategy
else request.desired_state
)
assert_deployment_transition(DeploymentLifecycleState(subject.state), intermediate)
if serving_contract is None:
assert_deployment_transition(DeploymentLifecycleState(subject.state), intermediate)
except ValueError as exc:
raise LifecycleError(422, "INVALID_LIFECYCLE_TRANSITION", str(exc)) from exc
if request.migration_class == "requires_reindex":
@@ -635,6 +675,11 @@ class LifecycleService:
raise LifecycleError(500, "SUBJECT_REFERENCE_MISSING", "plan subject is unavailable")
if subject.version != request.expected_subject_version:
raise LifecycleError(409, "VERSION_CONFLICT", "lifecycle subject changed concurrently")
serving_contract = plan.affected_identities.get("serving_transition")
if serving_contract is not None:
LifecycleServing(self.session).validate(serving_contract)
elif subject.environment == "PRODUCTION" and subject.target_type == "CAPABILITY_DEPLOYMENT" and plan.desired_state == "STABLE":
raise LifecycleError(409, "SERVING_TRANSITION_REQUIRED", "operational stable promotion requires the approved serving adapter")
plan_version = plan.version
snapshot_payload = {
"subject_id": str(subject.id),
@@ -645,7 +690,10 @@ class LifecycleService:
"version": subject.version,
"rollback_target_ref": plan.rollback_target_ref,
"affected_identities": plan.affected_identities,
"superseded_by_ref": subject.superseded_by_ref,
}
if serving_contract is not None:
snapshot_payload["serving_transition"] = serving_contract
snapshot = LifecycleRollbackSnapshot(
promotion_plan_id=plan.id,
snapshot=snapshot_payload,
@@ -741,7 +789,8 @@ class LifecycleService:
return self._operation_response(operation)
def observe_canary(
self, operation_id: uuid.UUID, observation: CanaryObservation
self, operation_id: uuid.UUID, observation: CanaryObservation,
*, audit_context: AuditContext | None = None,
) -> LifecycleOperationResponse:
operation = self._operation(operation_id)
if operation.stage != OperationStage.VERIFYING:
@@ -751,16 +800,32 @@ class LifecycleService:
)
if not canary or canary.status != CanaryStatus.RUNNING:
raise LifecycleError(409, "CANARY_NOT_RUNNING", "canary is not running")
plan = self._plan(operation.promotion_plan_id)
subject = self._require_manual_canary_rehearsal(plan)
context = audit_context or AuditContext(
actor_type="manual_lab_rehearsal", actor_id="local-lab-rehearsal",
)
canary.request_count += observation.request_count
canary.error_count += observation.error_count
canary.latency_p95_ms = observation.latency_p95_ms
canary.result = observation.model_dump(mode="json")
canary.result = {
**observation.model_dump(mode="json"),
"provenance": {
"source": "MANUAL_LAB_REHEARSAL",
"authoritative": False,
"actor": context.actor_id,
"correlation_id": context.correlation_id,
},
}
trigger = self._canary_trigger(canary, observation)
if trigger:
canary.status = CanaryStatus.ABORTED
canary.abort_trigger = trigger
canary.finished_at = _now()
self._rollback(operation, trigger, "SYSTEM", "Automatic canary abort")
self._rollback(
operation, trigger, context.actor_id,
"Automatic abort from manual LAB rehearsal observation",
)
event_type = "CANARY_ABORTED"
elif canary.request_count >= canary.target_request_count:
canary.status = CanaryStatus.READY_FOR_PROMOTION_REVIEW
@@ -768,18 +833,14 @@ class LifecycleService:
event_type = "CANARY_READY_FOR_PROMOTION_REVIEW"
else:
event_type = "CANARY_OBSERVED"
plan = self._plan(operation.promotion_plan_id)
subject = self.session.get(LifecycleSubject, plan.subject_id)
if subject is None:
raise LifecycleError(500, "SUBJECT_REFERENCE_MISSING", "plan subject is unavailable")
self._event(
event_type,
subject.target_type,
subject.target_ref,
subject.state,
canary.status,
"lifecycle-engine",
"SYSTEM",
context.actor_id,
"MANUAL_LAB_REHEARSAL",
self._approval(plan.approval_request_id).policy_revision_id,
[str(canary.id)],
trigger or "Bounded canary observation recorded without payload content",
@@ -790,14 +851,64 @@ class LifecycleService:
},
change_id=str(operation.id),
)
AuditWriter(self.session, context=context).write(
action="lifecycle.manual_lab_canary_observed",
resource_type="lifecycle_operation",
resource_id=str(operation.id),
details={
"source": "MANUAL_LAB_REHEARSAL",
"canary_id": str(canary.id),
"status": canary.status,
"authoritative": False,
},
)
self.session.commit()
return self._operation_response(operation)
def _require_manual_canary_rehearsal(
self, plan: LifecyclePromotionPlan,
) -> LifecycleSubject:
"""No manual or legacy canary evidence can qualify an operational promotion."""
subject = self.session.get(LifecycleSubject, plan.subject_id)
approval = self.session.get(LifecycleApprovalRequest, plan.approval_request_id)
policy = self.session.get(LifecyclePolicyRevision, approval.policy_revision_id) if (
approval is not None
) else None
if (
subject is None or approval is None or policy is None
or subject.target_type != "LAB_REHEARSAL"
or subject.environment != "LAB"
or approval.environment != "LAB"
or policy.scope != "LAB_PROMOTION"
or plan.desired_state != "LAB_STABLE"
or approval.subject_id != subject.id
or approval.target_type != subject.target_type
or approval.target_ref != subject.target_ref
or approval.requested_transition != plan.desired_state
):
raise LifecycleError(
409, "MANUAL_CANARY_REHEARSAL_ONLY",
"Canary observations and commit require an explicit LAB rehearsal; "
"authoritative operational canary evidence ingestion is unavailable",
)
return subject
def commit_operation(
self, operation_id: uuid.UUID, decision: ApprovalDecision
) -> LifecycleOperationResponse:
operation = self._operation(operation_id)
plan = self._plan(operation.promotion_plan_id)
if operation.stage == OperationStage.COMMITTED:
transition = plan.affected_identities.get("serving_transition")
if transition is not None:
snapshot = self.session.scalar(select(LifecycleRollbackSnapshot).where(
LifecycleRollbackSnapshot.promotion_plan_id == plan.id))
if (snapshot is None or _hash(snapshot.snapshot) != snapshot.snapshot_digest
or snapshot.snapshot.get("serving_transition") != transition):
raise LifecycleError(409, "SERVING_SNAPSHOT_INVALID", "exact installed serving snapshot is required")
LifecycleServing(self.session).installed(transition, _serving_changed_at(operation))
self.session.commit()
return self._operation_response(operation)
if operation.stage != OperationStage.VERIFYING:
raise LifecycleError(
409, "OPERATION_NOT_REVIEWABLE", "operation is not ready for review"
@@ -805,6 +916,8 @@ class LifecycleService:
canary = self.session.scalar(
select(LifecycleCanaryRun).where(LifecycleCanaryRun.operation_id == operation.id)
)
if canary or plan.canary_strategy:
self._require_manual_canary_rehearsal(plan)
if canary and canary.status != CanaryStatus.READY_FOR_PROMOTION_REVIEW:
raise LifecycleError(
409,
@@ -823,14 +936,26 @@ class LifecycleService:
raise LifecycleError(
409, "ROLLBACK_SNAPSHOT_MISSING", "immutable rollback snapshot is unavailable"
)
serving_contract = plan.affected_identities.get("serving_transition")
serving_changed_at = None
if serving_contract is not None:
approval = self._approval(plan.approval_request_id)
self._refresh_approval(approval)
if approval.status != ApprovalStatus.APPROVED or _hash(approval.evidence_snapshot) != approval.evidence_fingerprint:
raise LifecycleError(409, "APPROVAL_STALE", "serving approval is no longer current")
if snapshot.snapshot.get("serving_transition") != serving_contract or _hash(snapshot.snapshot) != snapshot.snapshot_digest:
raise LifecycleError(409, "SERVING_SNAPSHOT_INVALID", "exact immutable serving snapshot is required")
serving_changed_at = _now()
LifecycleServing(self.session).switch(serving_contract, serving_changed_at.isoformat())
expected_state = (
DeploymentLifecycleState.CANARY if plan.canary_strategy else plan.current_state
)
try:
assert_deployment_transition(
DeploymentLifecycleState(expected_state),
DeploymentLifecycleState(plan.desired_state),
)
if serving_contract is None:
assert_deployment_transition(
DeploymentLifecycleState(expected_state),
DeploymentLifecycleState(plan.desired_state),
)
except ValueError as exc:
raise LifecycleError(409, "STATE_CHANGED", str(exc)) from exc
previous = str(expected_state)
@@ -862,7 +987,7 @@ class LifecycleService:
)
self.session.refresh(subject)
operation.stage = OperationStage.COMMITTED
operation.finished_at = _now()
operation.finished_at = serving_changed_at or _now()
operation.version += 1
plan.status = PromotionPlanStatus.COMMITTED
plan.version += 1
@@ -1142,6 +1267,14 @@ class LifecycleService:
def _resolve_evidence(self, declared: dict[str, Any]) -> dict[str, Any]:
facts: dict[str, Any] = {}
if declared.get("serving_transition") is not None:
from modelforge_api.domain.lifecycle_contracts import ServingTransitionSelection
if declared.get("production_validation") != "SATISFIED":
raise LifecycleError(409, "SERVING_PRODUCTION_VALIDATION_REQUIRED", "actual project serving requires satisfied production validation")
facts["serving_transition"] = LifecycleServing(self.session).prepare(
ServingTransitionSelection.model_validate(declared["serving_transition"])
).model_dump(mode="json")
for field, model in (
("artifact_set_id", ArtifactSet),
("runtime_profile_id", RuntimeProfile),
@@ -1359,8 +1492,21 @@ class LifecycleService:
change_id=str(item.id),
)
return
current_resolved = self._resolve_evidence(item.evidence_snapshot["declared"])
if _hash(current_resolved) != _hash(item.evidence_snapshot["resolved"]):
stale_details: dict[str, Any] = {}
current_resolved: dict[str, Any] | None
try:
current_resolved = self._resolve_evidence(item.evidence_snapshot["declared"])
except (LifecycleError, ServingError) as exc:
# A historical serving approval must remain inspectable after its route
# changes. Expected qualification failures invalidate authority; internal
# errors still propagate rather than being disguised as stale evidence.
if (item.evidence_snapshot["declared"].get("serving_transition") is None
or exc.status_code not in {404, 409, 422}):
raise
current_resolved = None
stale_details = {"validation_code": exc.code, "validation_reason": str(exc)}
item.blockers = sorted(set(item.blockers) | {exc.code})
if current_resolved is None or _hash(current_resolved) != _hash(item.evidence_snapshot["resolved"]):
previous = item.status
item.status = ApprovalStatus.STALE
item.stale_at = now
@@ -1375,7 +1521,7 @@ class LifecycleService:
item.policy_revision_id,
[],
"Referenced evidence changed after approval request creation",
{},
stale_details,
change_id=str(item.id),
)
@@ -1461,6 +1607,7 @@ class LifecycleService:
) -> None:
started = time.perf_counter()
plan = self._plan(operation.promotion_plan_id)
self._approval(plan.approval_request_id) # Same approval-before-route lock order as commit.
snapshot = self.session.scalar(
select(LifecycleRollbackSnapshot).where(
LifecycleRollbackSnapshot.promotion_plan_id == plan.id
@@ -1492,6 +1639,21 @@ class LifecycleService:
)
return
snapshot_version = int(snapshot.snapshot["version"])
serving_contract = plan.affected_identities.get("serving_transition")
was_committed = operation.stage == OperationStage.COMMITTED
if serving_contract is not None:
if (snapshot.snapshot.get("serving_transition") != serving_contract
or _hash(snapshot.snapshot) != snapshot.snapshot_digest):
raise LifecycleError(409, "SERVING_SNAPSHOT_INVALID", "exact serving snapshot is unavailable")
# A subject CAS failure below rolls back the route in the same transaction.
# A later route is preserved rather than guessed away.
try:
LifecycleServing(self.session).restore(serving_contract,
_serving_changed_at(operation) if was_committed else _now().isoformat(),
committed=was_committed)
except Exception:
self.session.rollback()
raise
expected_version = snapshot_version + (
2 if operation.stage == OperationStage.COMMITTED else 1
)
@@ -1511,6 +1673,9 @@ class LifecycleService:
)
)
if rows_affected(restored) != 1:
if serving_contract is not None:
self.session.rollback()
raise LifecycleError(409, "ROLLBACK_VERSION_CONFLICT", "lifecycle subject changed; serving state was preserved")
operation.stage = OperationStage.FAILED
operation.failure_code = "ROLLBACK_VERSION_CONFLICT"
operation.failure_details = {
@@ -1581,6 +1746,26 @@ class LifecycleService:
500, "RETENTION_POLICY_MISSING", "rollback retention policy missing"
)
retained_until = _now() + timedelta(days=policy.minimum_rollback_days)
transition = plan.affected_identities.get("serving_transition")
if transition is not None:
prior = transition["current"]
for kind, ref in (("ARTIFACT_SET", prior["artifact_set_id"]),
("RUNTIME_PROFILE", prior["runtime_profile_id"]),
("CAPABILITY_DEPLOYMENT", prior["id"])):
retained = self.session.scalar(select(LifecycleRetentionRecord).where(
LifecycleRetentionRecord.target_type == kind,
LifecycleRetentionRecord.target_ref == ref))
if retained is None:
self.session.add(LifecycleRetentionRecord(
policy_revision_id=policy.id, target_type=kind, target_ref=ref,
state=RetentionState.ROLLBACK_RETAINED, retained_until=retained_until,
legal_hold=False, reason="Exact prior serving identity retained for approved rollback",
created_at=_now()))
else:
previous_until = retained.retained_until
if previous_until is None or previous_until.replace(tzinfo=UTC) < retained_until:
retained.retained_until = retained_until
retained.state = RetentionState.ROLLBACK_RETAINED
existing = self.session.scalar(
select(LifecycleRetentionRecord).where(
LifecycleRetentionRecord.target_type == "ROLLBACK_TARGET",
@@ -1856,19 +2041,19 @@ class LifecycleService:
)
def _approval(self, approval_id: uuid.UUID) -> LifecycleApprovalRequest:
item = self.session.get(LifecycleApprovalRequest, approval_id)
item = self.session.get(LifecycleApprovalRequest, approval_id, with_for_update=True, populate_existing=True)
if not item:
raise LifecycleError(404, "APPROVAL_NOT_FOUND", "approval request not found")
return item
def _plan(self, plan_id: uuid.UUID) -> LifecyclePromotionPlan:
item = self.session.get(LifecyclePromotionPlan, plan_id)
item = self.session.get(LifecyclePromotionPlan, plan_id, with_for_update=True, populate_existing=True)
if not item:
raise LifecycleError(404, "PROMOTION_PLAN_NOT_FOUND", "promotion plan not found")
return item
def _operation(self, operation_id: uuid.UUID) -> LifecycleOperation:
item = self.session.get(LifecycleOperation, operation_id)
item = self.session.get(LifecycleOperation, operation_id, with_for_update=True, populate_existing=True)
if not item:
raise LifecycleError(404, "LIFECYCLE_OPERATION_NOT_FOUND", "operation not found")
return item
@@ -0,0 +1,337 @@
"""Transaction-only serving adapter for approved lifecycle plans.
This adapter never commits, invokes inference, or changes a consumer index.
The lifecycle engine owns the approval, journal, transaction and rollback.
"""
from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime
from typing import Any, NoReturn
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from modelforge_api.domain.lifecycle_contracts import (
ServingTransitionContract,
ServingTransitionSelection,
)
from modelforge_api.domain.serving import ProductionApprovalCreate, SupplyChainReview
from modelforge_api.persistence.models import (
Accelerator,
ArtifactSet,
CapabilityContract,
CapabilityDeployment,
CapabilityExperimentRoute,
CapabilityResourceEnvelope,
ComputeNode,
DeploymentCandidate,
EvaluationComparison,
EvaluationRun,
ProductionExecutionApproval,
Project,
ProjectBinding,
ProjectFitEvidence,
ResidencyAllocation,
RuntimeCompatibilityAssessment,
RuntimeProbe,
SelectedModelComparison,
ServiceClient,
)
from modelforge_api.persistence.serving_repository import ServingRepository
from modelforge_api.services.audit import AuditContext
from modelforge_api.services.manifest_registry import ManifestRegistry
from modelforge_api.services.selected_model_comparison import SelectedModelComparisonService
from modelforge_api.services.serving import ServingService
from modelforge_api.settings import get_settings
def _fail(code: str, message: str) -> NoReturn:
# The parent service imports this adapter; defer its public boundary error.
from modelforge_api.services.lifecycle import LifecycleError
raise LifecycleError(409, code, message)
def _json(value: Any) -> Any:
if isinstance(value, datetime):
return value.replace(tzinfo=UTC).isoformat() if value.tzinfo is None else value.astimezone(UTC).isoformat()
return str(value) if isinstance(value, UUID) else value
def digest(value: Any) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"),
default=_json, allow_nan=False).encode()).hexdigest()
ROUTE_FIELDS = (
"id", "capability_contract_id", "deployment_candidate_id", "production_approval_id",
"execution_approval_id", "embedding_space_id", "artifact_set_id", "runtime_profile_id",
"compute_node_id", "accelerator_id", "channel", "status", "production", "routing_weight",
"fallback_policy", "residency_policy", "keep_warm_seconds", "max_concurrency", "max_queue_depth",
"config_fingerprint", "provenance", "rollback_policy", "promoted_at", "draining_at", "deprecated_at",
)
class LifecycleServing:
def __init__(self, session: Session) -> None:
self.session = session
self.repo = ServingRepository(session)
@staticmethod
def route_snapshot(row: CapabilityDeployment) -> dict[str, Any]:
return {field: _json(getattr(row, field)) for field in ROUTE_FIELDS}
def _deployment(self, identifier: UUID) -> CapabilityDeployment:
row = self.session.get(CapabilityDeployment, identifier, with_for_update=True, populate_existing=True)
if row is None:
_fail("SERVING_TARGET_MISSING", "exact serving deployment is unavailable")
return row
def _lock_accelerators(self, selection: ServingTransitionSelection) -> None:
node_ids = list(self.session.scalars(select(CapabilityDeployment.compute_node_id).where(
CapabilityDeployment.id.in_((selection.current_deployment_id, selection.candidate_deployment_id)))))
# Hardware inventory flushes compute_nodes before updating accelerators.
# Native admission does not acquire a node lock while holding its accelerator.
self.session.scalars(select(ComputeNode).where(ComputeNode.id.in_(node_ids)).order_by(
ComputeNode.id).with_for_update().execution_options(populate_existing=True)).all()
identifiers = list(self.session.scalars(select(CapabilityDeployment.accelerator_id).where(
CapabilityDeployment.id.in_((selection.current_deployment_id, selection.candidate_deployment_id)))))
self.session.scalars(select(Accelerator).where(Accelerator.id.in_(identifiers)).order_by(
Accelerator.id).with_for_update()).all()
def _bindings(self, contract_id: UUID) -> dict[str, dict[str, Any]]:
# Lock even deprecated bindings: a concurrent reactivation changes the blast radius.
bindings = list(self.session.scalars(select(ProjectBinding).where(
ProjectBinding.capability_contract_id == contract_id
).order_by(ProjectBinding.id).with_for_update().execution_options(populate_existing=True)))
result = {}
for binding in bindings:
project = self.session.get(Project, binding.project_id, with_for_update=True, populate_existing=True)
clients = list(self.session.scalars(select(ServiceClient).where(
ServiceClient.project_binding_id == binding.id
).order_by(ServiceClient.id).with_for_update().execution_options(populate_existing=True)))
if binding.channel != "stable" or binding.deprecated_at is not None:
continue
if project is None:
_fail("SERVING_BINDING_MISSING", "binding project disappeared")
result[str(binding.id)] = {
"project_id": str(binding.project_id), "project_active": project.active,
"priority": binding.priority, "optional": binding.optional,
"fallback_policy": binding.fallback_policy, "migration_support": binding.migration_support,
"slo": binding.slo, "benchmark_requirements": binding.benchmark_requirements,
"clients": [{"id": str(client.id), "status": client.status,
"disabled_at": _json(client.disabled_at),
"capabilities": client.allowed_capabilities} for client in clients],
}
if not result or len(result) > 1000:
_fail("SERVING_BINDINGS_UNBOUNDED", "transition requires one to 1000 exact stable bindings")
return result
def _experiment_routes(self, candidate_id: UUID) -> dict[str, dict[str, Any]]:
return {str(row.id): {"route_key": row.route_key, "status": row.status,
"capability_contract_id": str(row.capability_contract_id)}
for row in self.session.scalars(select(CapabilityExperimentRoute).where(
CapabilityExperimentRoute.capability_deployment_id == candidate_id
).order_by(CapabilityExperimentRoute.id).with_for_update().execution_options(populate_existing=True))}
def prepare(self, selection: ServingTransitionSelection) -> ServingTransitionContract:
# Obtain the contract before any deployment locks to match other route writers.
candidate_ref = self.session.get(CapabilityDeployment, selection.candidate_deployment_id)
if candidate_ref is None:
_fail("SERVING_TARGET_MISSING", "candidate deployment is unavailable")
contract = self.repo.lock_contract_route(candidate_ref.capability_contract_id)
if contract is None or contract.deprecated_at is not None:
_fail("SERVING_CONTRACT_UNAVAILABLE", "capability contract is unavailable")
if contract.serving_generation != selection.expected_route_generation:
_fail("SERVING_ROUTE_CHANGED", "stable route generation changed after selection")
# Match inventory node->accelerator and admission accelerator->residency/binding.
self._lock_accelerators(selection)
current = self._deployment(selection.current_deployment_id)
candidate = self._deployment(selection.candidate_deployment_id)
if (current.id == candidate.id or current.capability_contract_id != contract.id
or not current.production or current.status != "stable" or current.channel != "stable"
or candidate.production or candidate.status != "candidate" or candidate.channel != "experiment"):
_fail("SERVING_SELECTION_INVALID", "exact stable current and nonproduction candidate are required")
if current.embedding_space_id is None or current.embedding_space_id != candidate.embedding_space_id:
_fail("SERVING_REINDEX_REQUIRED", "changed embedding identity requires trusted consumer reindex and cutover")
experiment_routes = self._experiment_routes(candidate.id)
bindings = self._bindings(contract.id)
if set(bindings) != {str(identifier) for identifier in selection.comparison_ids}:
_fail("SERVING_BINDING_EVIDENCE_REQUIRED", "every affected stable binding requires its own comparison")
if len(set(selection.comparison_ids.values())) != len(bindings):
_fail("SERVING_COMPARISON_REUSED", "one project comparison cannot authorize another binding")
comparison_service = SelectedModelComparisonService(self.session, AuditContext("system", "lifecycle-serving"))
evidence = {}
for binding_id, comparison_id in sorted(selection.comparison_ids.items(), key=lambda item: str(item[0])):
comparison = self.session.get(SelectedModelComparison, comparison_id, with_for_update=True, populate_existing=True)
latest = self.session.scalar(select(SelectedModelComparison.id).where(
SelectedModelComparison.project_binding_id == binding_id
).order_by(SelectedModelComparison.created_at.desc(), SelectedModelComparison.id.desc()).limit(1))
if (comparison is None or comparison.status != "completed" or comparison.id != latest
or comparison.project_binding_id != binding_id
or comparison.current_deployment_id != current.id or comparison.candidate_deployment_id != candidate.id
or comparison.finished_at is None or comparison.result_digest is None):
_fail("SERVING_COMPARISON_STALE", "latest completed exact current/candidate comparison is required")
comparison_service._revalidate(comparison)
evaluated = self.session.get(EvaluationComparison, comparison.evaluation_comparison_id)
if (evaluated is None or evaluated.comparability != "comparable"
or evaluated.promotion_eligibility != "eligible" or evaluated.critical_regressions
or evaluated.baseline_run_id != comparison.baseline_run_id
or evaluated.candidate_run_id != comparison.candidate_run_id):
_fail("SERVING_COMPARISON_NOT_ELIGIBLE", "project comparison does not qualify replacement")
for run_id in (comparison.baseline_run_id, comparison.candidate_run_id):
run = self.session.get(EvaluationRun, run_id)
if run is None or run.status != "completed" or run.error_count or run.completed_cases != run.expected_cases:
_fail("SERVING_RUN_NOT_COMPLETE", "both comparison legs must be complete and error-free")
fit = self.session.scalar(select(ProjectFitEvidence).where(
ProjectFitEvidence.project_binding_id == binding_id,
ProjectFitEvidence.capability_deployment_id == candidate.id,
).order_by(ProjectFitEvidence.created_at.desc(), ProjectFitEvidence.id.desc()).limit(1)
.with_for_update().execution_options(populate_existing=True))
if fit is not None and (fit.recommendation != "PROMOTION_ELIGIBLE"
or fit.production_validation != "SATISFIED" or fit.engineering_integration != "PASS"
or fit.critical_errors or fit.blockers or fit.case_count < 1):
_fail("SERVING_PROJECT_FIT_BLOCKED", "latest exact binding/candidate project-fit evidence blocks production")
evidence[str(binding_id)] = {"id": str(comparison.id), "pins": comparison.pins,
"result_digest": comparison.result_digest, "evaluation_comparison_id": str(evaluated.id),
"baseline_run_id": str(evaluated.baseline_run_id), "candidate_run_id": str(evaluated.candidate_run_id),
"project_fit": ({"id": str(fit.id), "evidence_digest": fit.evidence_digest,
"record_digest": digest({column.name: _json(getattr(fit, column.name))
for column in fit.__table__.columns})} if fit is not None else None),
"evaluation_digest": digest({column.name: _json(getattr(evaluated, column.name))
for column in evaluated.__table__.columns})}
approval = self._production_evidence(candidate, contract, selection.production_approval_id)
return ServingTransitionContract(selection=selection, capability_contract_id=contract.id,
current=self.route_snapshot(current), candidate=self.route_snapshot(candidate),
experiment_routes=experiment_routes,
bindings=bindings, comparisons=evidence, production_approval_fingerprint=approval.evidence_fingerprint)
def _production_evidence(self, candidate: CapabilityDeployment, contract: CapabilityContract,
approval_id: UUID) -> ProductionExecutionApproval:
approval = self.session.get(ProductionExecutionApproval, approval_id,
with_for_update=True, populate_existing=True)
if (approval is None or approval.status != "approved" or approval.revoked_at is not None
or approval.deployment_candidate_id != candidate.deployment_candidate_id
or approval.capability_contract_id != contract.id
or approval.artifact_set_id != candidate.artifact_set_id
or approval.runtime_profile_id != candidate.runtime_profile_id
or approval.compute_node_id != candidate.compute_node_id):
_fail("SERVING_PRODUCTION_APPROVAL_INVALID", "exact current production execution approval is required")
# Immutable identity fields are already sealed. Mutable qualification/revocation
# fields must remain locked until the containing lifecycle transaction commits.
source = self.session.get(DeploymentCandidate, candidate.deployment_candidate_id,
with_for_update=True, populate_existing=True)
if source is None:
_fail("SERVING_TARGET_MISSING", "candidate provenance disappeared")
self.session.get(RuntimeProbe, source.runtime_probe_id, with_for_update=True, populate_existing=True)
self.session.get(RuntimeCompatibilityAssessment, source.compatibility_assessment_id,
with_for_update=True, populate_existing=True)
self.session.get(ArtifactSet, candidate.artifact_set_id, with_for_update=True, populate_existing=True)
self.session.get(ComputeNode, candidate.compute_node_id, with_for_update=True, populate_existing=True)
for artifact in self.repo.set_artifacts(candidate.artifact_set_id):
self.session.refresh(artifact, with_for_update=True)
# Reuse the existing production validation engine, without its create/promote commits.
settings = get_settings()
serving = ServingService(self.session, settings, ManifestRegistry(settings.config_root), None)
facts, _, probe, _, _ = serving._approval_facts(candidate.deployment_candidate_id, contract,
ProductionApprovalCreate(capability="rag.embedding", contract_version=1,
approved_by=approval.approved_by, reason=approval.reason,
supply_chain_review=SupplyChainReview.model_validate(approval.supply_chain_evidence),
deployment_config=approval.deployment_config))
if digest(facts) != approval.evidence_fingerprint:
_fail("SERVING_PRODUCTION_APPROVAL_STALE", "production probe or supply-chain evidence changed")
envelope = self.session.scalar(select(CapabilityResourceEnvelope).where(
CapabilityResourceEnvelope.capability_deployment_id == candidate.id
).with_for_update().execution_options(populate_existing=True))
if envelope is None or envelope.stale or envelope.runtime_probe_id != probe.id:
_fail("SERVING_ENVELOPE_STALE", "candidate requires its exact current measured envelope")
residency = self.session.scalar(select(ResidencyAllocation).where(
ResidencyAllocation.capability_deployment_id == candidate.id
).with_for_update().execution_options(populate_existing=True))
if (residency is None or residency.compute_node_id != candidate.compute_node_id
or residency.accelerator_id != candidate.accelerator_id
or residency.state not in {"cold", "ready", "warm"}):
_fail("SERVING_RESIDENCY_UNAVAILABLE", "candidate has no settled schedulable runtime residency")
return approval
def validate(self, payload: dict[str, Any]) -> ServingTransitionContract:
expected = ServingTransitionContract.model_validate(payload)
actual = self.prepare(expected.selection)
if actual != expected:
_fail("SERVING_PLAN_STALE", "approved serving identities or affected bindings changed")
return expected
def _generation(self, contract_id: UUID, expected: int) -> None:
changed = self.session.execute(update(CapabilityContract).where(
CapabilityContract.id == contract_id, CapabilityContract.serving_generation == expected
).values(serving_generation=expected + 1).returning(CapabilityContract.id)).scalar_one_or_none()
if changed is None:
_fail("SERVING_ROUTE_CHANGED", "stable route generation changed concurrently")
@staticmethod
def after(contract: ServingTransitionContract, changed_at: str) -> tuple[dict[str, Any], dict[str, Any]]:
old = {**contract.current, "status": "draining", "draining_at": changed_at, "routing_weight": 0}
new = {**contract.candidate, "status": "stable", "channel": "stable", "production": True,
"production_approval_id": str(contract.selection.production_approval_id),
"promoted_at": changed_at, "routing_weight": 100}
return old, new
def switch(self, payload: dict[str, Any], changed_at: str) -> None:
contract = self.validate(payload)
self._generation(contract.capability_contract_id, contract.selection.expected_route_generation)
current = self._deployment(contract.selection.current_deployment_id)
candidate = self._deployment(contract.selection.candidate_deployment_id)
current.status, current.draining_at = "draining", datetime.fromisoformat(changed_at)
current.routing_weight = 0
self.session.flush() # Release the one-stable partial unique index before activating the next.
candidate.status, candidate.channel, candidate.production = "stable", "stable", True
candidate.production_approval_id = contract.selection.production_approval_id
candidate.routing_weight = 100
candidate.promoted_at = datetime.fromisoformat(changed_at)
self.session.flush()
def installed(self, payload: dict[str, Any], changed_at: str, *, committed: bool = True) -> tuple[ServingTransitionContract, CapabilityDeployment, CapabilityDeployment]:
"""Verify exact persisted result for retry/rollback, without switching anything."""
contract = ServingTransitionContract.model_validate(payload)
route = self.repo.lock_contract_route(contract.capability_contract_id)
expected_generation = contract.selection.expected_route_generation + int(committed)
if route is None or route.serving_generation != expected_generation:
_fail("SERVING_ROLLBACK_CONFLICT", "later stable route generation must be preserved")
self._lock_accelerators(contract.selection)
current = self._deployment(contract.selection.current_deployment_id)
candidate = self._deployment(contract.selection.candidate_deployment_id)
expected = self.after(contract, changed_at) if committed else (contract.current, contract.candidate)
if ((self.route_snapshot(current), self.route_snapshot(candidate)) != expected
or self._bindings(contract.capability_contract_id) != contract.bindings
or self._experiment_routes(candidate.id) != contract.experiment_routes):
_fail("SERVING_ROLLBACK_CONFLICT", "serving state or affected bindings changed after the snapshot")
return contract, current, candidate
def restore(self, payload: dict[str, Any], changed_at: str, *, committed: bool) -> None:
contract, current, candidate = self.installed(payload, changed_at, committed=committed)
if not committed:
return
route = self.session.get(CapabilityContract, contract.capability_contract_id)
if route is None or current.production_approval_id is None:
_fail("SERVING_ROLLBACK_APPROVAL_MISSING", "prior serving authority is unavailable")
self._production_evidence(current, route, current.production_approval_id)
expected_generation = contract.selection.expected_route_generation + 1
self._generation(contract.capability_contract_id, expected_generation)
candidate.status = str(contract.candidate["status"])
candidate.channel = str(contract.candidate["channel"])
candidate.production = bool(contract.candidate["production"])
candidate.routing_weight = int(contract.candidate["routing_weight"])
candidate.production_approval_id = (UUID(contract.candidate["production_approval_id"])
if contract.candidate["production_approval_id"] else None)
candidate.promoted_at = (datetime.fromisoformat(contract.candidate["promoted_at"])
if contract.candidate["promoted_at"] else None)
self.session.flush()
current.status = str(contract.current["status"])
current.routing_weight = int(contract.current["routing_weight"])
current.draining_at = (datetime.fromisoformat(contract.current["draining_at"])
if contract.current["draining_at"] else None)
self.session.flush()
@@ -73,6 +73,10 @@ class ManifestRegistry:
ProjectManifest.model_validate(self._read_yaml(path))
for path in sorted((self.config_root / "projects").glob("*.yaml"))
]
project_ids = [item.project.id for item in manifests]
if len(project_ids) != len(set(project_ids)):
raise ValueError("duplicate project manifest identity")
capabilities = self.capabilities()
by_identity = {(item.capability, item.version): item for item in capabilities}
capability_versions = set(by_identity)
@@ -459,11 +459,13 @@ class MigrationEngineService:
}
return set()
def plans(self) -> list[MigrationPlanResponse]:
def plans(self, limit: int = 200) -> list[MigrationPlanResponse]:
return [
self._plan_response(item)
for item in self.session.scalars(
select(MigrationPlan).order_by(MigrationPlan.created_at.desc())
select(MigrationPlan)
.order_by(MigrationPlan.created_at.desc(), MigrationPlan.id.desc())
.limit(limit)
)
]
@@ -0,0 +1,813 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal, cast
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from modelforge_api.domain.model_changes import (
ChangeAcquisition,
ChangeImpact,
ChangeModelIdentity,
ChangeProgressStep,
ModelChangeCandidateLink,
ModelChangeCreate,
ModelChangeEvidenceLink,
ModelChangeList,
ModelChangeReject,
ModelChangeResponse,
ModelChangeStatus,
)
from modelforge_api.domain.selected_replacement import ReplacementPreview
from modelforge_api.persistence.model_change import ModelChangeCase
from modelforge_api.persistence.models import (
ArtifactJob,
ArtifactSet,
Capability,
CapabilityContract,
CapabilityDeployment,
DiscoveryCandidateAssessment,
DownloadPlan,
GatewayRequest,
LifecyclePromotionPlan,
MigrationPlan,
Model,
ModelRevision,
Project,
ProjectBinding,
SelectedModelComparison,
ServiceClient,
)
from modelforge_api.services.audit import AuditContext, AuditWriter
from modelforge_api.services.evaluation import EvaluationError
from modelforge_api.services.lifecycle import LifecycleError
from modelforge_api.services.selected_replacement import SelectedReplacementService
from modelforge_api.services.serving import ServingError
ProgressState = Literal["done", "current", "upcoming", "blocked"]
class ModelChangeError(ValueError):
def __init__(self, code: str, message: str, status_code: int = 409) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
def _now() -> datetime:
return datetime.now(UTC)
def _gate(value: object) -> str:
if not isinstance(value, dict):
return "unknown"
text = " ".join(
str(value.get(key, "")) for key in ("status", "state", "verdict", "result")
).lower()
if any(word in text for word in ("block", "reject", "deny", "fail", "incompat")):
return "blocked"
if any(
word in text for word in ("approved", "clear", "verified", "compatible", "pass", "safe")
):
return "clear"
return "review"
class ModelChangeService:
"""User workflow over authoritative ModelForge evidence; never executes lifecycle work itself."""
def __init__(self, session: Session, audit_context: AuditContext) -> None:
self.session = session
self.audit_context = audit_context
def _error(self, code: str, message: str, status: int = 409) -> ModelChangeError:
return ModelChangeError(code, message, status)
def _audit(
self,
row: ModelChangeCase,
action: str,
details: dict[str, object] | None = None,
) -> None:
AuditWriter(self.session, context=self.audit_context).write(
action,
"model_change_case",
str(row.id),
{
"project_binding_id": str(row.project_binding_id),
"current_deployment_id": str(row.current_deployment_id),
"candidate_deployment_id": (
str(row.candidate_deployment_id) if row.candidate_deployment_id else None
),
"status": row.status,
"version": row.version,
**(details or {}),
},
)
def _row(self, identifier: UUID, *, lock: bool = False) -> ModelChangeCase:
query = select(ModelChangeCase).where(ModelChangeCase.id == identifier)
if lock:
query = query.with_for_update().execution_options(populate_existing=True)
row = self.session.scalar(query)
if row is None:
raise self._error("MODEL_CHANGE_NOT_FOUND", "Model change case was not found", 404)
return row
def _binding_context(
self, binding_id: UUID
) -> tuple[ProjectBinding, Project, CapabilityContract, Capability]:
binding = self.session.get(ProjectBinding, binding_id)
project = self.session.get(Project, binding.project_id) if binding else None
contract = (
self.session.get(CapabilityContract, binding.capability_contract_id) if binding else None
)
capability = self.session.get(Capability, contract.capability_id) if contract else None
if (
binding is None
or binding.deprecated_at is not None
or binding.channel != "stable"
or project is None
or not project.active
or contract is None
or contract.deprecated_at is not None
or capability is None
):
raise self._error(
"MODEL_CHANGE_BINDING_INVALID",
"The selected project task is not an active stable binding",
422,
)
return binding, project, contract, capability
def _stable_deployment(self, contract_id: UUID) -> CapabilityDeployment | None:
return self.session.scalar(
select(CapabilityDeployment)
.where(
CapabilityDeployment.capability_contract_id == contract_id,
CapabilityDeployment.production.is_(True),
CapabilityDeployment.status == "stable",
CapabilityDeployment.deprecated_at.is_(None),
)
.order_by(
CapabilityDeployment.promoted_at.desc(),
CapabilityDeployment.created_at.desc(),
)
.limit(1)
)
def _identity(
self,
*,
model_id: UUID | None = None,
revision_id: UUID | None = None,
artifact_set_id: UUID | None = None,
deployment_id: UUID | None = None,
) -> ChangeModelIdentity | None:
deployment = self.session.get(CapabilityDeployment, deployment_id) if deployment_id else None
if deployment is not None:
artifact_set_id = deployment.artifact_set_id
artifact = self.session.get(ArtifactSet, artifact_set_id) if artifact_set_id else None
if artifact is not None:
revision_id = artifact.revision_id
revision = self.session.get(ModelRevision, revision_id) if revision_id else None
if revision is not None:
model_id = revision.model_id
model = self.session.get(Model, model_id) if model_id else None
if not any((deployment, artifact, revision, model)):
return None
return ChangeModelIdentity(
model_id=model.id if model else model_id,
display_name=model.display_name if model else None,
revision_id=revision.id if revision else revision_id,
resolved_commit_sha=revision.resolved_commit_sha if revision else None,
artifact_set_id=artifact.id if artifact else artifact_set_id,
deployment_id=deployment.id if deployment else deployment_id,
)
def _current_identity(self, row: ModelChangeCase) -> ChangeModelIdentity:
identity = self._identity(deployment_id=row.current_deployment_id)
if identity is None:
raise self._error(
"MODEL_CHANGE_CURRENT_IDENTITY_MISSING",
"The exact model identity behind the current deployment is incomplete",
)
return identity
def _candidate_identity(self, row: ModelChangeCase) -> ChangeModelIdentity | None:
return self._identity(
model_id=row.candidate_model_id,
revision_id=row.candidate_revision_id,
artifact_set_id=row.candidate_artifact_set_id,
deployment_id=row.candidate_deployment_id,
)
def _acquisition(self, row: ModelChangeCase) -> ChangeAcquisition:
assessment = (
self.session.get(DiscoveryCandidateAssessment, row.discovery_assessment_id)
if row.discovery_assessment_id
else None
)
plan = self.session.get(DownloadPlan, row.download_plan_id) if row.download_plan_id else None
job = self.session.get(ArtifactJob, row.artifact_job_id) if row.artifact_job_id else None
storage_bytes = plan.total_size_bytes if plan else None
if storage_bytes is None and row.candidate_artifact_set_id:
artifact = self.session.get(ArtifactSet, row.candidate_artifact_set_id)
storage_bytes = artifact.total_size_bytes if artifact else None
fit = "unknown"
if assessment is not None and isinstance(assessment.gpu_fit, dict):
fit = str(
assessment.gpu_fit.get("fit")
or assessment.gpu_fit.get("status")
or assessment.gpu_fit.get("verdict")
or "unknown"
)
return ChangeAcquisition(
discovery_assessment_id=assessment.id if assessment else None,
security_gate=_gate(assessment.security_state) if assessment else "unknown",
license_gate=_gate(assessment.license_state) if assessment else "unknown",
hardware_fit=fit,
storage_bytes=storage_bytes,
download_plan_id=plan.id if plan else None,
artifact_job_id=job.id if job else None,
download_status=job.status if job else (plan.status if plan else None),
)
def _successful_project_request_after(
self, binding_id: UUID, deployment_id: UUID, since: datetime | None
) -> bool:
clients = list(
self.session.scalars(
select(ServiceClient.id).where(
ServiceClient.project_binding_id == binding_id,
ServiceClient.status == "active",
ServiceClient.disabled_at.is_(None),
)
)
)
if not clients:
return False
query = select(GatewayRequest.id).where(
GatewayRequest.service_client_id.in_(clients),
GatewayRequest.capability_deployment_id == deployment_id,
GatewayRequest.status == "completed",
GatewayRequest.finished_at.is_not(None),
)
if since is not None:
query = query.where(GatewayRequest.finished_at >= since)
return (
self.session.scalar(query.order_by(GatewayRequest.finished_at.desc()).limit(1))
is not None
)
def _preview(self, row: ModelChangeCase) -> ReplacementPreview | None:
if row.selected_comparison_id is None:
return None
try:
return SelectedReplacementService(self.session).preview(row.selected_comparison_id)
except (EvaluationError, LifecycleError, ServingError):
# The detailed subsystem remains authoritative. A stale preview simply keeps this
# user projection from claiming readiness until the linked evidence is refreshed.
return None
def _derived_status(self, row: ModelChangeCase) -> ModelChangeStatus:
if row.closed_at is not None and row.status in {"rolled_back", "rejected"}:
return cast(ModelChangeStatus, row.status)
binding, _project, contract, _capability = self._binding_context(row.project_binding_id)
lifecycle = (
self.session.get(LifecyclePromotionPlan, row.lifecycle_plan_id)
if row.lifecycle_plan_id
else None
)
if lifecycle is not None and lifecycle.status == "ROLLED_BACK":
return "rolled_back"
stable = self._stable_deployment(contract.id)
if row.candidate_deployment_id and stable and stable.id == row.candidate_deployment_id:
since = stable.promoted_at or row.activated_at or row.updated_at
if self._successful_project_request_after(binding.id, stable.id, since):
return "checked"
return "active"
if lifecycle is not None and lifecycle.status == "COMMITTED":
return "active"
if lifecycle is not None and lifecycle.status == "APPROVED":
return "ready_to_activate"
comparison = (
self.session.get(SelectedModelComparison, row.selected_comparison_id)
if row.selected_comparison_id
else None
)
if comparison is not None:
if comparison.status == "completed":
preview = self._preview(row)
if preview is not None and not preview.same_embedding_space:
return "migration_required"
if preview is not None and preview.selection is not None:
return "ready_to_activate"
return "comparison_available"
if comparison.status in {"pending", "running", "cancel_requested"}:
return "tested"
if row.candidate_deployment_id:
return "ready_to_test"
job = self.session.get(ArtifactJob, row.artifact_job_id) if row.artifact_job_id else None
if job is not None and job.status == "completed":
return "downloaded"
plan = self.session.get(DownloadPlan, row.download_plan_id) if row.download_plan_id else None
if plan is not None and plan.status == "completed":
return "downloaded"
assessment = (
self.session.get(DiscoveryCandidateAssessment, row.discovery_assessment_id)
if row.discovery_assessment_id
else None
)
if assessment is not None and assessment.status.lower() not in {
"blocked",
"rejected",
"failed",
}:
return "source_checked"
if any((row.candidate_model_id, row.candidate_revision_id, row.candidate_artifact_set_id)):
return "found"
return "searching"
def _impact(self, row: ModelChangeCase) -> ChangeImpact:
_binding, project, _contract, _capability = self._binding_context(row.project_binding_id)
affected = [project.name]
blockers: list[str] = []
migration_required = False
migration_kind = "none"
rollback_available = bool(row.candidate_deployment_id)
preview = self._preview(row)
if preview is not None:
affected = list(dict.fromkeys(item.project_name for item in preview.affected_bindings)) or affected
blockers = list(dict.fromkeys(item.message for item in preview.blockers))
migration_required = not preview.same_embedding_space
migration_kind = "reindex" if migration_required else "none"
rollback_available = preview.current_deployment_id is not None
migration = (
self.session.get(MigrationPlan, row.migration_plan_id) if row.migration_plan_id else None
)
if migration is not None:
migration_kind = str(migration.migration_class).lower()
migration_required = migration_kind not in {"transparent", "none"}
lifecycle = (
self.session.get(LifecyclePromotionPlan, row.lifecycle_plan_id)
if row.lifecycle_plan_id
else None
)
downtime = False
application_change = False
if lifecycle is not None:
details = lifecycle.impact_analysis if isinstance(lifecycle.impact_analysis, dict) else {}
downtime = bool(details.get("downtime_expected", details.get("downtime", False)))
application_change = str(lifecycle.migration_class).lower() == "schema_breaking"
rollback_available = bool(lifecycle.rollback_target_ref)
if lifecycle.project_consumers:
affected = list(dict.fromkeys([*affected, *lifecycle.project_consumers]))
return ChangeImpact(
affected_projects=affected,
affected_project_count=len(affected),
application_change_required=application_change,
migration_required=migration_required,
migration_kind=migration_kind,
downtime_expected=downtime,
rollback_available=rollback_available,
blockers=blockers,
)
def _next_action(self, status: ModelChangeStatus) -> str:
return {
"searching": "Kandidaat zoeken",
"found": "Bron controleren",
"source_checked": "Downloaden voor test",
"downloaded": "Klaarzetten om te testen",
"ready_to_test": "Test uitvoeren",
"tested": "Vergelijk met huidig model",
"comparison_available": "Voorbereiden om te wisselen",
"migration_required": "Herindexering of migratie voorbereiden",
"ready_to_activate": "Veilig activeren",
"active": "Gezondheid controleren",
"checked": "Geen actie nodig",
"rolled_back": "Afgerond — teruggerold",
"rejected": "Afgerond — afgewezen",
}[status]
def _progress(self, status: ModelChangeStatus) -> list[ChangeProgressStep]:
labels = [
("candidate", "Kandidaat"),
("source", "Broncontrole"),
("download", "Download"),
("test", "Test"),
("compare", "Vergelijking"),
("impact", "Impact & migratie"),
("activate", "Activeren"),
("verify", "Controle"),
]
rank = {
"searching": 0,
"found": 0,
"source_checked": 1,
"downloaded": 2,
"ready_to_test": 3,
"tested": 4,
"comparison_available": 5,
"migration_required": 5,
"ready_to_activate": 6,
"active": 7,
"checked": 8,
"rolled_back": 8,
"rejected": 8,
}[status]
steps: list[ChangeProgressStep] = []
for index, (key, label) in enumerate(labels):
state: ProgressState = (
"done"
if index < rank
else "current"
if index == rank and rank < len(labels)
else "upcoming"
)
if status == "migration_required" and key == "impact":
state = "blocked"
steps.append(ChangeProgressStep(key=key, label=label, state=state))
return steps
def _response(self, row: ModelChangeCase) -> ModelChangeResponse:
binding, project, contract, capability = self._binding_context(row.project_binding_id)
status = self._derived_status(row)
return ModelChangeResponse(
id=row.id,
project_binding_id=binding.id,
project_id=project.id,
project_key=project.key,
project_name=project.name,
capability=f"{capability.key}@{contract.version}",
status=status,
stored_status=cast(ModelChangeStatus, row.status),
status_reason=row.status_reason,
version=row.version,
current=self._current_identity(row),
candidate=self._candidate_identity(row),
acquisition=self._acquisition(row),
selected_comparison_id=row.selected_comparison_id,
migration_plan_id=row.migration_plan_id,
lifecycle_plan_id=row.lifecycle_plan_id,
impact=self._impact(row),
next_action=self._next_action(status),
progress=self._progress(status),
created_by=row.created_by,
updated_by=row.updated_by,
activated_at=row.activated_at,
checked_at=row.checked_at,
closed_at=row.closed_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _remember_derived_status(self, row: ModelChangeCase) -> None:
status = self._derived_status(row)
if status != row.status:
row.status = status
row.status_reason = self._next_action(status)
def create(self, request: ModelChangeCreate) -> ModelChangeResponse:
binding, project, contract, capability = self._binding_context(request.project_binding_id)
self.session.scalar(
select(ProjectBinding).where(ProjectBinding.id == binding.id).with_for_update()
)
existing = self.session.scalar(
select(ModelChangeCase).where(
ModelChangeCase.project_binding_id == binding.id,
ModelChangeCase.closed_at.is_(None),
)
)
if existing is not None:
return self._response(existing)
current = self._stable_deployment(contract.id)
if current is None:
raise self._error(
"MODEL_CHANGE_CURRENT_UNAVAILABLE",
"No healthy stable model deployment is available for this project task",
422,
)
row = ModelChangeCase(
project_binding_id=binding.id,
current_deployment_id=current.id,
status="searching",
status_reason=request.reason,
version=1,
created_by=self.audit_context.actor_id,
updated_by=self.audit_context.actor_id,
context={
"project_id": str(project.id),
"project_key": project.key,
"capability": f"{capability.key}@{contract.version}",
"reason": request.reason,
},
)
self.session.add(row)
try:
self.session.flush()
self._audit(row, "MODEL_CHANGE_CASE_CREATED")
self.session.commit()
except IntegrityError as exc:
self.session.rollback()
raise self._error(
"MODEL_CHANGE_ALREADY_OPEN",
"Another model change is already open for this project task",
) from exc
self.session.refresh(row)
return self._response(row)
def get(self, identifier: UUID) -> ModelChangeResponse:
return self._response(self._row(identifier))
def list(
self,
*,
project_key: str | None = None,
status: str | None = None,
project_binding_id: UUID | None = None,
open_only: bool = False,
limit: int = 100,
offset: int = 0,
) -> ModelChangeList:
if not 1 <= limit <= 200:
raise self._error("MODEL_CHANGE_PAGE_INVALID", "Limit must be between 1 and 200", 422)
if offset < 0:
raise self._error("MODEL_CHANGE_OFFSET_INVALID", "Offset must be zero or greater", 422)
query = select(ModelChangeCase)
if project_key:
query = (
query.join(ProjectBinding, ProjectBinding.id == ModelChangeCase.project_binding_id)
.join(Project, Project.id == ProjectBinding.project_id)
.where(Project.key == project_key)
)
if status:
query = query.where(ModelChangeCase.status == status)
if project_binding_id is not None:
query = query.where(ModelChangeCase.project_binding_id == project_binding_id)
if open_only:
query = query.where(ModelChangeCase.closed_at.is_(None))
total = self.session.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = self.session.scalars(
query.order_by(ModelChangeCase.updated_at.desc(), ModelChangeCase.id.asc())
.offset(offset)
.limit(limit)
).all()
return ModelChangeList(items=[self._response(row) for row in rows], total=total)
def link_candidate(
self, identifier: UUID, request: ModelChangeCandidateLink
) -> ModelChangeResponse:
row = self._row(identifier, lock=True)
if row.closed_at is not None:
raise self._error("MODEL_CHANGE_CLOSED", "This model change is already closed")
if row.version != request.expected_version:
raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before editing it")
_binding, _project, contract, _capability = self._binding_context(row.project_binding_id)
deployment = (
self.session.get(CapabilityDeployment, request.candidate_deployment_id)
if request.candidate_deployment_id
else None
)
if deployment is not None and (
deployment.capability_contract_id != contract.id
or deployment.id == row.current_deployment_id
or deployment.production
or deployment.channel not in {"experiment", "lab", "candidate"}
or deployment.status not in {"candidate", "approved", "lab_ready"}
or deployment.deprecated_at is not None
):
raise self._error(
"MODEL_CHANGE_CANDIDATE_DEPLOYMENT_INVALID",
"The candidate deployment is not a safe non-production target for this project task",
422,
)
artifact_set_id = request.candidate_artifact_set_id or (
deployment.artifact_set_id if deployment else None
)
plan = self.session.get(DownloadPlan, request.download_plan_id) if request.download_plan_id else None
if plan is not None:
if artifact_set_id is not None and plan.artifact_set_id != artifact_set_id:
raise self._error(
"MODEL_CHANGE_DOWNLOAD_MISMATCH", "Download plan belongs to another candidate", 422
)
artifact_set_id = plan.artifact_set_id
job = self.session.get(ArtifactJob, request.artifact_job_id) if request.artifact_job_id else None
if job is not None:
if plan is None:
plan = self.session.get(DownloadPlan, job.plan_id)
if plan is None or job.plan_id != plan.id or (
artifact_set_id is not None and plan.artifact_set_id != artifact_set_id
):
raise self._error(
"MODEL_CHANGE_JOB_MISMATCH", "Download job belongs to another candidate", 422
)
artifact_set_id = plan.artifact_set_id
artifact = self.session.get(ArtifactSet, artifact_set_id) if artifact_set_id else None
if artifact_set_id and artifact is None:
raise self._error("MODEL_CHANGE_ARTIFACT_NOT_FOUND", "Candidate artifact set was not found", 404)
revision_id = request.candidate_revision_id or (artifact.revision_id if artifact else None)
revision = self.session.get(ModelRevision, revision_id) if revision_id else None
if revision_id and revision is None:
raise self._error("MODEL_CHANGE_REVISION_NOT_FOUND", "Candidate version was not found", 404)
model_id = request.candidate_model_id or (revision.model_id if revision else None)
model = self.session.get(Model, model_id) if model_id else None
if model_id and model is None:
raise self._error("MODEL_CHANGE_MODEL_NOT_FOUND", "Candidate model was not found", 404)
if revision and model_id and revision.model_id != model_id:
raise self._error(
"MODEL_CHANGE_IDENTITY_MISMATCH", "Candidate version belongs to another model", 422
)
if artifact and revision_id and artifact.revision_id != revision_id:
raise self._error(
"MODEL_CHANGE_IDENTITY_MISMATCH", "Candidate files belong to another version", 422
)
assessment = (
self.session.get(DiscoveryCandidateAssessment, request.discovery_assessment_id)
if request.discovery_assessment_id
else None
)
if request.discovery_assessment_id and assessment is None:
raise self._error(
"MODEL_CHANGE_ASSESSMENT_NOT_FOUND", "Candidate source review was not found", 404
)
if assessment is not None:
if assessment.model_id is not None and model_id is not None and assessment.model_id != model_id:
raise self._error(
"MODEL_CHANGE_ASSESSMENT_MISMATCH", "Source review belongs to another model", 422
)
if revision is not None and assessment.resolved_commit_sha != revision.resolved_commit_sha:
raise self._error(
"MODEL_CHANGE_ASSESSMENT_MISMATCH",
"Source review belongs to another exact version",
422,
)
row.candidate_model_id = model_id
row.candidate_revision_id = revision_id
row.candidate_artifact_set_id = artifact_set_id
row.candidate_deployment_id = (
deployment.id if deployment else request.candidate_deployment_id or row.candidate_deployment_id
)
row.discovery_assessment_id = (
assessment.id if assessment else request.discovery_assessment_id or row.discovery_assessment_id
)
row.download_plan_id = plan.id if plan else request.download_plan_id or row.download_plan_id
row.artifact_job_id = job.id if job else request.artifact_job_id or row.artifact_job_id
row.version += 1
row.updated_by = self.audit_context.actor_id
self._remember_derived_status(row)
self._audit(row, "MODEL_CHANGE_CANDIDATE_LINKED")
self.session.commit()
self.session.refresh(row)
return self._response(row)
def link_evidence(
self, identifier: UUID, request: ModelChangeEvidenceLink
) -> ModelChangeResponse:
row = self._row(identifier, lock=True)
if row.closed_at is not None:
raise self._error("MODEL_CHANGE_CLOSED", "This model change is already closed")
if row.version != request.expected_version:
raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before editing it")
binding, project, _contract, _capability = self._binding_context(row.project_binding_id)
comparison = (
self.session.get(SelectedModelComparison, request.selected_comparison_id)
if request.selected_comparison_id
else None
)
if request.selected_comparison_id:
if comparison is None:
raise self._error("MODEL_CHANGE_COMPARISON_NOT_FOUND", "Comparison was not found", 404)
if (
comparison.project_binding_id != binding.id
or comparison.current_deployment_id != row.current_deployment_id
or row.candidate_deployment_id is None
or comparison.candidate_deployment_id != row.candidate_deployment_id
):
raise self._error(
"MODEL_CHANGE_COMPARISON_MISMATCH",
"Comparison does not belong to this project and exact current/candidate pair",
422,
)
migration = (
self.session.get(MigrationPlan, request.migration_plan_id)
if request.migration_plan_id
else None
)
if request.migration_plan_id:
if migration is None:
raise self._error("MODEL_CHANGE_MIGRATION_NOT_FOUND", "Migration plan was not found", 404)
if migration.project_binding_id != binding.id or migration.project_id != project.id:
raise self._error(
"MODEL_CHANGE_MIGRATION_MISMATCH", "Migration plan belongs to another project task", 422
)
if row.candidate_deployment_id:
candidate = self.session.get(CapabilityDeployment, row.candidate_deployment_id)
if (
candidate
and candidate.embedding_space_id
and migration.target_space_id != candidate.embedding_space_id
):
raise self._error(
"MODEL_CHANGE_MIGRATION_MISMATCH",
"Migration target does not match the candidate embedding space",
422,
)
lifecycle = (
self.session.get(LifecyclePromotionPlan, request.lifecycle_plan_id)
if request.lifecycle_plan_id
else None
)
if request.lifecycle_plan_id:
if lifecycle is None:
raise self._error("MODEL_CHANGE_LIFECYCLE_NOT_FOUND", "Activation plan was not found", 404)
if (
row.candidate_deployment_id is None
or lifecycle.candidate_deployment_id != row.candidate_deployment_id
):
raise self._error(
"MODEL_CHANGE_LIFECYCLE_MISMATCH", "Activation plan belongs to another candidate", 422
)
if (
str(project.id) not in {str(value) for value in lifecycle.project_consumers}
and project.name not in lifecycle.project_consumers
):
impacted = lifecycle.impact_analysis if isinstance(lifecycle.impact_analysis, dict) else {}
project_ids = impacted.get("project_ids", [])
project_refs = (
{str(value) for value in project_ids} if isinstance(project_ids, list) else set()
)
if project_refs and str(project.id) not in project_refs:
raise self._error(
"MODEL_CHANGE_LIFECYCLE_MISMATCH",
"Activation plan does not affect this project",
422,
)
if comparison is not None:
row.selected_comparison_id = comparison.id
if migration is not None:
row.migration_plan_id = migration.id
if lifecycle is not None:
row.lifecycle_plan_id = lifecycle.id
row.version += 1
row.updated_by = self.audit_context.actor_id
self._remember_derived_status(row)
self._audit(row, "MODEL_CHANGE_EVIDENCE_LINKED")
self.session.commit()
self.session.refresh(row)
return self._response(row)
def refresh(self, identifier: UUID, expected_version: int) -> ModelChangeResponse:
row = self._row(identifier, lock=True)
if row.version != expected_version:
raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before updating status")
before = row.status
derived = self._derived_status(row)
if derived != row.status:
row.status = derived
row.status_reason = self._next_action(derived)
row.version += 1
row.updated_by = self.audit_context.actor_id
if derived in {"active", "checked"} and row.activated_at is None:
row.activated_at = _now()
if derived == "checked" and row.checked_at is None:
row.checked_at = _now()
if derived == "rolled_back":
row.closed_at = _now()
self._audit(row, "MODEL_CHANGE_STATUS_REFRESHED", {"from": before, "to": derived})
self.session.commit()
self.session.refresh(row)
return self._response(row)
def reject(self, identifier: UUID, request: ModelChangeReject) -> ModelChangeResponse:
row = self._row(identifier, lock=True)
if row.version != request.expected_version:
raise self._error("MODEL_CHANGE_VERSION_CONFLICT", "Refresh the change before rejecting it")
if row.closed_at is not None:
return self._response(row)
if self._derived_status(row) in {"active", "checked"}:
raise self._error(
"MODEL_CHANGE_ALREADY_ACTIVE",
"An active change must be rolled back through the controlled lifecycle flow",
)
row.status = "rejected"
row.status_reason = request.reason
row.closed_at = _now()
row.updated_by = self.audit_context.actor_id
row.version += 1
self._audit(row, "MODEL_CHANGE_CASE_REJECTED", {"reason": request.reason})
self.session.commit()
self.session.refresh(row)
return self._response(row)
@@ -0,0 +1,76 @@
"""Low-volume metadata checks for explicitly selected models; never model execution."""
import asyncio
import threading
import uuid
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import Engine, or_, select
from sqlalchemy.orm import Session
from modelforge_api.persistence.models import ModelUpdateWatch
from modelforge_api.services.acquisition import AcquisitionService
from modelforge_api.services.audit import AuditContext
from modelforge_api.services.model_updates import ModelUpdateService
from modelforge_api.services.registry import RegistryConflict
from modelforge_api.services.secret_slots import SecretAwareHuggingFaceProvider
from modelforge_api.settings import Settings
logger = structlog.get_logger()
class ModelUpdatePollingService:
"""One due watch per minute per API process, with database-fenced per-watch admission."""
def __init__(self, settings: Settings, engine: Engine) -> None:
self.settings = settings
self.engine = engine
self._stop = threading.Event()
def stop(self) -> None:
self._stop.set()
def service(self, session: Session) -> ModelUpdateService:
provider = SecretAwareHuggingFaceProvider(session, self.settings)
acquisition = AcquisitionService(
session, self.settings, provider,
credential_configured=provider.credential_configured,
)
return ModelUpdateService(session, acquisition, AuditContext(
"automation", "selected-model-update-monitor", str(uuid.uuid4()),
))
def run_once(self) -> None:
if self._stop.is_set() or not self.settings.model_update_monitor_enabled:
return
now = datetime.now(UTC)
cutoff = now - timedelta(seconds=self.settings.model_update_check_interval_seconds)
try:
with Session(self.engine) as session:
watch_id = session.scalar(select(ModelUpdateWatch.id).where(
ModelUpdateWatch.enabled.is_(True),
or_(ModelUpdateWatch.last_checked_at.is_(None),
ModelUpdateWatch.last_checked_at <= cutoff),
or_(ModelUpdateWatch.check_until.is_(None),
ModelUpdateWatch.check_until < now),
).order_by(ModelUpdateWatch.last_checked_at.asc().nulls_first(),
ModelUpdateWatch.id).limit(1))
if watch_id is None:
return
# The service rechecks due/enabled state in its atomic claim, so another
# API process completing this watch cannot cause an immediate second poll.
self.service(session).check(watch_id, if_checked_before=cutoff)
except RegistryConflict:
# A manual action or another process won admission. Its state remains authoritative.
return
except Exception as error: # noqa: BLE001 - keep the background loop alive; never log credentials
logger.error("model_update_monitor_check_failed", error_type=type(error).__name__)
async def run(self) -> None:
while not self._stop.is_set():
await asyncio.to_thread(self.run_once)
for _ in range(60):
if self._stop.is_set():
return
await asyncio.sleep(1)
@@ -0,0 +1,195 @@
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from modelforge_api.domain.model_updates import (
ModelUpdateNoticeResponse,
ModelWatchCreate,
ModelWatchResponse,
ModelWatchUpdate,
)
from modelforge_api.persistence.models import (
Model,
ModelRevision,
ModelUpdateNotice,
ModelUpdateWatch,
)
from modelforge_api.services.acquisition import AcquisitionService, ProviderBoundaryError
from modelforge_api.services.audit import AuditContext, AuditWriter
from modelforge_api.services.registry import RegistryConflict, RegistryNotFound
class ModelUpdateService:
def __init__(self, session: Session, acquisition: AcquisitionService, context: AuditContext) -> None:
self.session = session
self.acquisition = acquisition
self.audit = AuditWriter(session, context=context)
self.acquisition.audit = self.audit
def watches(self, limit: int = 50, offset: int = 0) -> list[ModelWatchResponse]:
return [ModelWatchResponse.model_validate(item) for item in self.session.scalars(
select(ModelUpdateWatch).order_by(ModelUpdateWatch.id).limit(limit).offset(offset)
)]
def create(self, request: ModelWatchCreate) -> ModelWatchResponse:
model = self.session.get(Model, request.model_id)
revision = self.session.get(ModelRevision, request.baseline_revision_id)
if model is None or revision is None:
raise RegistryNotFound("model or baseline revision not found")
if model.source_type != "huggingface" or revision.model_id != model.id:
raise RegistryConflict("watch requires a Hugging Face model and its own exact revision")
existing = self.session.scalar(select(ModelUpdateWatch).where(
ModelUpdateWatch.model_id == model.id,
ModelUpdateWatch.upstream_ref == request.upstream_ref,
))
if existing:
if existing.baseline_revision_id != revision.id:
raise RegistryConflict("watch baseline requires an explicit tracking update; review the existing watch")
return ModelWatchResponse.model_validate(existing)
item = ModelUpdateWatch(id=uuid.uuid4(), model_id=model.id,
baseline_revision_id=revision.id, baseline_commit_sha=revision.resolved_commit_sha,
upstream_ref=request.upstream_ref)
# The audit chain lock serializes admission, including the global watch limit.
self.audit.write("MODEL_WATCH_CREATED", "model_update_watch", str(item.id),
{"model_id": str(model.id), "baseline_revision_id": str(revision.id)})
if (self.session.scalar(select(func.count()).select_from(ModelUpdateWatch)) or 0) >= 100:
self.session.rollback()
raise RegistryConflict("maximum of 100 explicitly selected model watches reached")
self.session.add(item)
try:
self.session.commit()
except IntegrityError as exc:
self.session.rollback()
raise RegistryConflict("watch already exists; refresh the watch list") from exc
return ModelWatchResponse.model_validate(item)
def change(self, watch_id: uuid.UUID, request: ModelWatchUpdate) -> ModelWatchResponse:
item = self.session.scalar(select(ModelUpdateWatch).where(
ModelUpdateWatch.id == watch_id).with_for_update().execution_options(populate_existing=True))
if item is None:
raise RegistryNotFound("model watch not found")
if item.version != request.expected_version or item.baseline_revision_id != request.expected_baseline_revision_id:
raise RegistryConflict("model watch changed; refresh before changing tracking")
revision = self.session.get(ModelRevision, request.baseline_revision_id)
if revision is None:
raise RegistryNotFound("baseline revision not found")
if revision.model_id != item.model_id:
raise RegistryConflict("watch baseline must belong to the same model")
baseline_changed = revision.id != item.baseline_revision_id
until = item.check_until
if until is not None and until.tzinfo is None:
until = until.replace(tzinfo=UTC)
if baseline_changed and item.check_token is not None and until is not None and until > datetime.now(UTC):
raise RegistryConflict("wait for the active check or pause the watch before changing its baseline")
if not baseline_changed and item.enabled == request.enabled:
return ModelWatchResponse.model_validate(item)
previous = {"baseline_revision_id": str(item.baseline_revision_id),
"baseline_commit_sha": item.baseline_commit_sha, "enabled": item.enabled,
"version": item.version}
item.enabled = request.enabled
item.baseline_revision_id = revision.id
item.baseline_commit_sha = revision.resolved_commit_sha
item.version += 1
if not item.enabled or baseline_changed:
# Any result from the old check must fail its token fence, even after resume.
item.check_token = None
item.check_until = None
if baseline_changed:
item.last_checked_at = None
item.last_error_code = None
self.audit.write("MODEL_WATCH_TRACKING_CHANGED", "model_update_watch", str(item.id), {
"previous": previous, "baseline_revision_id": str(revision.id),
"baseline_commit_sha": item.baseline_commit_sha, "enabled": item.enabled,
"version": item.version, "reason": request.reason.strip(),
})
self.session.commit()
return ModelWatchResponse.model_validate(item)
def check(self, watch_id: uuid.UUID, *, if_checked_before: datetime | None = None) -> ModelWatchResponse:
item = self.session.get(ModelUpdateWatch, watch_id)
if item is None:
raise RegistryNotFound("model watch not found")
now = datetime.now(UTC)
token = uuid.uuid4()
claim = update(ModelUpdateWatch).where(
ModelUpdateWatch.id == watch_id,
ModelUpdateWatch.enabled.is_(True),
or_(ModelUpdateWatch.check_until.is_(None), ModelUpdateWatch.check_until < now),
)
if if_checked_before is not None:
claim = claim.where(or_(ModelUpdateWatch.last_checked_at.is_(None),
ModelUpdateWatch.last_checked_at <= if_checked_before))
claimed = self.session.execute(claim.values(check_until=now + timedelta(minutes=5), check_token=token)
.execution_options(synchronize_session="fetch").returning(ModelUpdateWatch.id)).first()
if claimed is None:
self.session.rollback()
raise RegistryConflict("model watch is paused, not due, or a check is already in progress")
self.session.commit()
self.session.refresh(item)
if not item.enabled or item.check_token != token:
raise RegistryConflict("model watch check superseded; refresh its status")
model_id, upstream_ref, baseline_sha = item.model_id, item.upstream_ref, item.baseline_commit_sha
error = None
candidate = None
try:
snapshot = self.acquisition.refresh_model(model_id, upstream_ref)
candidate = self.session.scalar(select(ModelRevision).where(
ModelRevision.model_id == model_id,
ModelRevision.resolved_commit_sha == snapshot.resolved_commit_sha,
))
if candidate is None:
raise RegistryConflict("upstream refresh did not resolve an exact revision")
except ProviderBoundaryError:
error = "upstream_unavailable"
except Exception:
self.session.rollback()
self.session.execute(update(ModelUpdateWatch).where(
ModelUpdateWatch.id == watch_id, ModelUpdateWatch.check_token == token,
).values(check_token=None, check_until=None, last_checked_at=datetime.now(UTC),
last_error_code="check_failed"))
self.audit.write("MODEL_WATCH_CHECK_FAILED", "model_update_watch", str(watch_id),
{"error_code": "check_failed"}, outcome="blocked")
self.session.commit()
raise
# An expired check cannot overwrite a subsequent check's state or create a stale notice.
finished = self.session.execute(update(ModelUpdateWatch).where(
ModelUpdateWatch.id == watch_id, ModelUpdateWatch.check_token == token,
).values(check_token=None, check_until=None, last_checked_at=datetime.now(UTC),
last_error_code=error).returning(ModelUpdateWatch.id)).first()
if finished is None:
self.session.rollback()
raise RegistryConflict("model watch check superseded; refresh its status")
if candidate and candidate.resolved_commit_sha != baseline_sha:
existing = self.session.scalar(select(ModelUpdateNotice).where(
ModelUpdateNotice.watch_id == watch_id,
ModelUpdateNotice.candidate_commit_sha == candidate.resolved_commit_sha,
))
if existing is None:
self.session.add(ModelUpdateNotice(watch_id=watch_id, model_id=model_id,
baseline_commit_sha=baseline_sha,
candidate_revision_id=candidate.id, candidate_commit_sha=candidate.resolved_commit_sha))
self.audit.write("MODEL_WATCH_CHECKED", "model_update_watch", str(watch_id),
{"error_code": error}, outcome="blocked" if error else "success")
self.session.commit()
self.session.refresh(item)
return ModelWatchResponse.model_validate(item)
def notices(self, limit: int = 50, offset: int = 0) -> list[ModelUpdateNoticeResponse]:
return [ModelUpdateNoticeResponse.model_validate(item) for item in self.session.scalars(
select(ModelUpdateNotice).order_by(ModelUpdateNotice.discovered_at.desc(), ModelUpdateNotice.id)
.limit(limit).offset(offset)
)]
def dismiss(self, notice_id: uuid.UUID) -> ModelUpdateNoticeResponse:
item = self.session.scalar(select(ModelUpdateNotice).where(
ModelUpdateNotice.id == notice_id).with_for_update())
if item is None:
raise RegistryNotFound("model update notice not found")
if item.status != "dismissed":
item.status = "dismissed"
self.audit.write("MODEL_UPDATE_DISMISSED", "model_update_notice", str(item.id), {})
self.session.commit()
return ModelUpdateNoticeResponse.model_validate(item)
@@ -361,6 +361,7 @@ class NodeAgentService:
raw: str,
*,
update_last_used: bool,
lock_node: bool = False,
) -> tuple[NodeCredential, ComputeNode]:
credential = self.repository.credential(credential_id)
if credential is None or credential.revoked_at is not None:
@@ -369,7 +370,11 @@ class NodeAgentService:
raise AgentAuthenticationError("invalid node credential")
if credential.scope != "node.publish":
raise AgentAuthorizationError("node credential scope is not authorized for publishing")
node = self.repository.node(credential.compute_node_id)
node = (
self.repository.node_for_update(credential.compute_node_id)
if lock_node
else self.repository.node(credential.compute_node_id)
)
if node is None:
raise AgentAuthenticationError("credential node no longer exists")
if node.decommissioned_at is not None:
@@ -396,8 +401,13 @@ class NodeAgentService:
)
def authenticate(self, authorization: str | None) -> tuple[NodeCredential, ComputeNode]:
identity, _evidence = self.authenticate_with_evidence(authorization)
return identity
credential_id, raw = self._parse_credential(authorization)
return self._resolve_authenticated_node(
credential_id,
raw,
update_last_used=True,
lock_node=True,
)
def reuse_authentication(
self,
@@ -415,6 +425,7 @@ class NodeAgentService:
credential_id,
raw,
update_last_used=False,
lock_node=True,
)
if node.id != evidence.node_id:
raise AgentAuthenticationError("credential node identity changed")
@@ -430,8 +441,23 @@ class NodeAgentService:
if node.decommissioned_at is not None:
raise AgentConflictError("decommissioned compute node cannot publish agent state")
def _locked_publishable_node(self, node: ComputeNode) -> ComputeNode:
"""Fence an authenticated agent write against concurrent lifecycle changes.
Agent routes also take this row lock while revalidating authentication. Keeping the same
node-first lock order here protects direct service callers and makes each state publisher
independently fail closed if its caller holds a stale ORM instance.
"""
current = self.repository.node_for_update(node.id)
if current is None:
raise AgentNotFoundError("compute node no longer exists")
self._assert_not_decommissioned(current)
if not current.enabled:
raise AgentAuthorizationError("compute node is disabled")
return current
def heartbeat(self, node: ComputeNode, request: HeartbeatRequest) -> ObservationAck:
self._assert_not_decommissioned(node)
node = self._locked_publishable_node(node)
self._assert_identity(node, request.identity_key)
if request.metadata.protocol_version != self.settings.agent_protocol_version:
raise AgentConflictError("agent protocol version is incompatible")
@@ -450,7 +476,7 @@ class NodeAgentService:
return ObservationAck(accepted=True, received_at=now)
def publish_inventory(self, node: ComputeNode, report: InventoryReport) -> ObservationAck:
self._assert_not_decommissioned(node)
node = self._locked_publishable_node(node)
self._assert_identity(node, report.identity_key)
if report.host.identity_key != node.key:
raise AgentAuthorizationError("host inventory identity does not match credential node")
@@ -517,7 +543,7 @@ class NodeAgentService:
return observation.value if observation.availability.value == "known" else None
def publish_telemetry(self, node: ComputeNode, report: TelemetryReport) -> ObservationAck:
self._assert_not_decommissioned(node)
node = self._locked_publishable_node(node)
self._assert_identity(node, report.identity_key)
if report.protocol_version != self.settings.agent_protocol_version:
raise AgentConflictError("agent protocol version is incompatible")
@@ -640,19 +666,12 @@ class NodeAgentService:
if node.decommissioned_at is not None:
raise AgentConflictError("decommissioned compute node cannot be updated")
values = update.model_dump(exclude_unset=True)
was_enabled = node.enabled
if "enabled" in values:
raise AgentConflictError(
"node enabled state requires an impact preview and typed confirmation"
)
for key, value in values.items():
setattr(node, key, value)
if "enabled" in values and values["enabled"] != was_enabled:
node.liveness_state = (
NodeLiveness.OFFLINE if values["enabled"] else NodeLiveness.DISABLED
)
self._audit(
"NODE_ENABLED" if values["enabled"] else "NODE_DISABLED",
"compute_node",
str(node.id),
{},
)
self.session.commit()
return node
@@ -4,7 +4,7 @@ import hashlib
import json
import uuid
from datetime import UTC, datetime
from typing import Any
from typing import Any, Literal
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.exc import IntegrityError
@@ -18,6 +18,13 @@ from modelforge_api.domain.node_decommission import (
NodeDecommissionPreview,
NodeDecommissionResult,
)
from modelforge_api.domain.node_lifecycle import (
NodeLifecycleBlocker,
NodeLifecycleChange,
NodeLifecyclePreview,
NodeLifecyclePreviewRequest,
NodeLifecycleResult,
)
from modelforge_api.persistence.models import (
Accelerator,
AcceleratorTelemetryLatest,
@@ -102,7 +109,7 @@ class NodeDecommissionService:
def _node(self, node_id: uuid.UUID, *, lock: bool = False) -> ComputeNode:
statement = select(ComputeNode).where(ComputeNode.id == node_id)
if lock:
statement = statement.with_for_update()
statement = statement.with_for_update().execution_options(populate_existing=True)
node = self.session.scalar(statement)
if node is None:
raise NodeDecommissionError(
@@ -740,6 +747,199 @@ class NodeDecommissionService:
dependent_records=dependent_records,
)
def _lifecycle_preview(
self, node: ComputeNode, request: NodeLifecyclePreviewRequest
) -> NodeLifecyclePreview:
dependency_snapshot = self._snapshot(node)
blockers: list[NodeLifecycleBlocker] = []
active_credential_ids = sorted(
str(value)
for value in self.session.scalars(
select(NodeCredential.id).where(
NodeCredential.compute_node_id == node.id,
NodeCredential.revoked_at.is_(None),
)
)
)
if node.decommissioned_at is not None:
blockers.append(
NodeLifecycleBlocker(
code="node_decommissioned",
message="decommissioned node identities are terminal and cannot be enabled or disabled",
record_type="compute_node",
count=1,
resource_ids=[str(node.id)],
)
)
elif node.enabled == request.target_enabled:
blockers.append(
NodeLifecycleBlocker(
code="already_in_requested_state",
message="node is already in the requested enabled state",
record_type="compute_node",
count=1,
resource_ids=[str(node.id)],
)
)
elif not request.target_enabled:
# A live heartbeat is expected before an administrator disables a node. Every other
# decommission dependency is conservatively treated as a blocker for temporary disable.
blockers.extend(
NodeLifecycleBlocker.model_validate(item.model_dump())
for item in dependency_snapshot.blockers
if item.code != "node_online"
)
elif node.observation_source == "remote_agent" and not active_credential_ids:
blockers.append(
NodeLifecycleBlocker(
code="active_node_credential_missing",
message="remote node has no active credential; rotate or re-enroll before enabling",
record_type="node_credential",
count=1,
resource_ids=[],
)
)
digest_payload = {
"base_dependency_digest": dependency_snapshot.dependency_digest,
"node_id": str(node.id),
"target_enabled": request.target_enabled,
"active_credential_ids": active_credential_ids,
"blockers": [item.model_dump(mode="json") for item in blockers],
}
dependency_digest = hashlib.sha256(
json.dumps(digest_payload, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return NodeLifecyclePreview(
node_id=node.id,
persisted_identity=node.key,
hostname=node.hostname,
display_name=node.display_name or node.hostname,
current_enabled=node.enabled,
current_liveness=node.liveness_state,
target_enabled=request.target_enabled,
node_generation=node.generation,
dependency_digest=dependency_digest,
can_apply=not blockers,
blockers=blockers,
agent_evidence_after_enable=(
"awaiting_fresh_authenticated_heartbeat"
if request.target_enabled and node.observation_source == "remote_agent"
else "unchanged"
),
)
def preview_enabled_change(
self, node_id: uuid.UUID, request: NodeLifecyclePreviewRequest
) -> NodeLifecyclePreview:
return self._lifecycle_preview(self._node(node_id), request)
def apply_enabled_change(
self, node_id: uuid.UUID, request: NodeLifecycleChange
) -> NodeLifecycleResult:
node = self._node(node_id, lock=True)
if node.generation != request.expected_generation:
raise NodeDecommissionError(
409,
"node_generation_conflict",
"node generation changed after lifecycle preview",
{"expected": request.expected_generation, "actual": node.generation},
)
if request.confirmation not in {node.key, node.hostname, node.display_name}:
raise NodeDecommissionError(
409,
"node_lifecycle_confirmation_mismatch",
"typed confirmation must exactly match the persisted identity, hostname, or display name",
)
self._lock_dependency_tables()
preview = self._lifecycle_preview(
node, NodeLifecyclePreviewRequest(target_enabled=request.target_enabled)
)
if preview.dependency_digest != request.dependency_digest:
raise NodeDecommissionError(
409,
"node_lifecycle_preview_stale",
"node state or dependencies changed after impact review; generate a fresh preview",
{"preview": preview.model_dump(mode="json")},
)
if not preview.can_apply:
raise NodeDecommissionError(
409,
"node_lifecycle_blocked",
"node lifecycle transition is blocked until the listed dependencies are resolved",
{"blockers": [item.model_dump(mode="json") for item in preview.blockers]},
)
target_liveness: Literal["offline", "disabled"] = (
"offline" if request.target_enabled else "disabled"
)
values: dict[str, Any] = {
"enabled": request.target_enabled,
"generation": ComputeNode.generation + 1,
"liveness_state": target_liveness,
}
if request.target_enabled and node.observation_source == "remote_agent":
# Previous protocol/heartbeat facts must not make a re-enabled node look ready. A new
# authenticated, protocol-compatible heartbeat will publish current evidence again.
values.update(
agent_protocol_version=None,
agent_capabilities=[],
agent_version=None,
agent_started_at=None,
last_heartbeat_at=None,
last_connection_error="node enabled; awaiting fresh authenticated heartbeat",
)
claimed = rows_affected(
self.session.execute(
update(ComputeNode)
.where(
ComputeNode.id == node_id,
ComputeNode.generation == request.expected_generation,
ComputeNode.enabled == preview.current_enabled,
ComputeNode.decommissioned_at.is_(None),
)
.values(**values)
.execution_options(synchronize_session=False)
)
)
if not claimed:
self.session.rollback()
raise NodeDecommissionError(
409,
"node_generation_conflict",
"node changed while the lifecycle transition was being claimed",
{"expected": request.expected_generation},
)
AuditWriter(self.session, "operator", request.operator).write(
"NODE_ENABLED" if request.target_enabled else "NODE_DISABLED",
"compute_node",
str(node.id),
{
"operator": request.operator,
"reason": request.reason,
"previous_enabled": preview.current_enabled,
"enabled": request.target_enabled,
"generation": request.expected_generation + 1,
"dependency_digest": request.dependency_digest,
"liveness": target_liveness,
},
)
self.session.commit()
return NodeLifecycleResult(
node_id=node.id,
enabled=request.target_enabled,
generation=request.expected_generation + 1,
liveness=target_liveness,
agent_evidence_state=(
"awaiting_fresh_authenticated_heartbeat"
if request.target_enabled and node.observation_source == "remote_agent"
else "unchanged"
),
)
def preview(self, node_id: uuid.UUID) -> NodeDecommissionPreview:
return self._snapshot(self._node(node_id))
@@ -14,6 +14,8 @@ from sqlalchemy.orm import Session
from modelforge_api.db import rows_affected
from modelforge_api.domain.observability import (
ActiveSchedulerLeaseInventoryResponse,
ActiveSchedulerLeaseResponse,
AlertAction,
AlertResponse,
AlertRuleCreate,
@@ -74,6 +76,7 @@ from modelforge_api.persistence.models import (
StorageRoot,
StorageVolumeState,
)
from modelforge_api.services.alert_notifications import enqueue_alert_transition
from modelforge_api.services.audit import AuditWriter
from modelforge_api.settings import Settings, get_settings
@@ -1157,6 +1160,133 @@ class ObservabilityService:
)
]
def active_scheduler_leases(
self,
*,
limit: int = 100,
sort_by: Literal["expires_at", "created_at", "priority"] = "expires_at",
order: Literal["asc", "desc"] = "asc",
observed_at: datetime | None = None,
) -> ActiveSchedulerLeaseInventoryResponse:
"""Read current, unexpired scheduler leases without touching reconciliation state."""
now = observed_at or _now()
sort_column = {
"expires_at": ServingGpuLease.expires_at,
"created_at": ServingGpuLease.created_at,
"priority": ServingGpuLease.priority,
}[sort_by]
primary_order = sort_column.asc() if order == "asc" else sort_column.desc()
rows = self.session.execute(
select(
ServingGpuLease,
Accelerator.compute_node_id,
GatewayRequest.status,
GatewayRequest.capability_deployment_id,
GatewayRequest.compute_node_id,
ProjectBinding.project_id,
)
.outerjoin(Accelerator, Accelerator.id == ServingGpuLease.accelerator_id)
.outerjoin(GatewayRequest, GatewayRequest.request_id == ServingGpuLease.request_id)
.outerjoin(ServiceClient, ServiceClient.id == GatewayRequest.service_client_id)
.outerjoin(ProjectBinding, ProjectBinding.id == ServiceClient.project_binding_id)
.where(
ServingGpuLease.state.in_(("pending", "granted", "active", "releasing")),
ServingGpuLease.expires_at > now,
)
.order_by(primary_order, ServingGpuLease.id.asc())
.limit(limit + 1)
).all()
truncated = len(rows) > limit
selected = rows[:limit]
lease_states: dict[str, Literal["PENDING", "GRANTED", "ACTIVE", "RELEASING"]] = {
"pending": "PENDING",
"granted": "GRANTED",
"active": "ACTIVE",
"releasing": "RELEASING",
}
lease_types: dict[
str, Literal["RESIDENCY_RESERVATION", "REQUEST_EXECUTION", "LOAD_TRANSITION"]
] = {
"residency_reservation": "RESIDENCY_RESERVATION",
"request_execution": "REQUEST_EXECUTION",
"load_transition": "LOAD_TRANSITION",
}
known_priorities = {
"production",
"interactive",
"background",
"benchmark",
"lab",
"maintenance",
}
def linked_workload_status(
lease: ServingGpuLease,
request_status: str | None,
request_deployment_id: uuid.UUID | None,
request_node_id: uuid.UUID | None,
node_id: uuid.UUID | None,
) -> Literal["QUEUED", "RUNNING", "UNKNOWN"]:
if (
request_status is None
or request_deployment_id != lease.capability_deployment_id
or request_node_id is None
or request_node_id != node_id
or node_id is None
):
return "UNKNOWN"
status = request_status.casefold()
if status == "queued" and lease.state in {"pending", "granted"}:
return "QUEUED"
if status == "running" and lease.state == "active":
return "RUNNING"
return "UNKNOWN"
inventory = [
ActiveSchedulerLeaseResponse(
lease_id=lease.id,
project_id=project_id,
node_id=node_id,
accelerator_id=lease.accelerator_id,
deployment_id=lease.capability_deployment_id,
lease_state=lease_states[lease.state],
workload_status=linked_workload_status(
lease,
request_status,
request_deployment_id,
request_node_id,
node_id,
),
priority=(
lease.priority.upper()
if lease.priority in known_priorities
else "UNKNOWN"
),
lease_type=lease_types.get(lease.lease_type, "UNKNOWN"),
reserved_vram_bytes=lease.reserved_vram_bytes,
materialized_vram_bytes=lease.materialized_vram_bytes,
created_at=_aware(lease.created_at),
acquired_at=_aware(lease.acquired_at) if lease.acquired_at else None,
heartbeat_at=_aware(lease.heartbeat_at) if lease.heartbeat_at else None,
expires_at=_aware(lease.expires_at),
)
for (
lease,
node_id,
request_status,
request_deployment_id,
request_node_id,
project_id,
) in selected
]
return ActiveSchedulerLeaseInventoryResponse(
observed_at=now,
completeness="TRUNCATED" if truncated else "COMPLETE",
limit=limit,
returned_count=len(inventory),
leases=inventory,
)
def capacity_trend(self, node_id: uuid.UUID, hours: int = 24) -> TrendResponse:
start = _now() - timedelta(hours=hours)
rows = list(
@@ -1732,18 +1862,19 @@ class ObservabilityService:
)
self.session.add(alert)
self.session.flush()
self.session.add(
AlertHistoryEvent(
alert_id=alert.id,
from_state=None,
to_state=initial.value,
actor="observability-engine",
reason="Signal condition detected",
evidence=signal["details"],
occurred_at=now,
)
event = AlertHistoryEvent(
alert_id=alert.id,
from_state=None,
to_state=initial.value,
actor="observability-engine",
reason="Signal condition detected",
evidence=signal["details"],
occurred_at=now,
)
self.session.add(event)
self.session.flush()
if initial is AlertState.FIRING:
enqueue_alert_transition(self.session, self.settings, alert, event)
self._correlate_incident(alert, signal, now)
return
alert.last_seen_at = now
@@ -1817,17 +1948,19 @@ class ObservabilityService:
alert.resolved_at = now
rule = self.session.get(AlertRuleRevision, alert.rule_id)
alert.cooldown_until = now + timedelta(seconds=rule.cooldown_seconds if rule else 0)
self.session.add(
AlertHistoryEvent(
alert_id=alert.id,
from_state=previous,
to_state=state.value,
actor=actor,
reason=reason,
evidence=evidence,
occurred_at=now,
)
event = AlertHistoryEvent(
alert_id=alert.id,
from_state=previous,
to_state=state.value,
actor=actor,
reason=reason,
evidence=evidence,
occurred_at=now,
)
self.session.add(event)
if state in {AlertState.FIRING, AlertState.RESOLVED}:
self.session.flush()
enqueue_alert_transition(self.session, self.settings, alert, event)
def _suppressed_until(
self, rule: AlertRuleRevision, signal: dict[str, Any], now: datetime
@@ -9,6 +9,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from modelforge_api.domain.observability import metrics
from modelforge_api.services.alert_notifications import dispatch_pending
from modelforge_api.services.observability import ObservabilityService
from modelforge_api.settings import Settings
@@ -30,11 +31,20 @@ class ObservabilityPollingService:
try:
with Session(self.engine) as session:
service = ObservabilityService(session, self.settings)
service.ensure_defaults()
service.collect_capacity()
service.evaluate_slos()
service.evaluate_alerts()
service.apply_retention()
evaluation_enabled = (
self.settings.observability_monitor_enabled
or self.settings.alert_notifications_enabled
)
if evaluation_enabled:
service.ensure_defaults()
if self.settings.observability_monitor_enabled:
service.collect_capacity()
service.evaluate_slos()
if evaluation_enabled:
service.evaluate_alerts()
if self.settings.observability_monitor_enabled:
service.apply_retention()
dispatch_pending(session, self.settings)
metrics.gauge("modelforge_observability_degraded", {}, 0)
except (SQLAlchemyError, OSError, RuntimeError, ValueError) as error:
metrics.gauge("modelforge_observability_degraded", {}, 1)
Loaded 100 of 434 files, more files were not shown because too many files have changed in this diff. Show more