246 lines
8.4 KiB
Python
246 lines
8.4 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import create_engine
|
||
|
|
from sqlalchemy.orm import Session, sessionmaker
|
||
|
|
from sqlalchemy.pool import StaticPool
|
||
|
|
|
||
|
|
from app.db.base import Base
|
||
|
|
from app.models.agent_asset import AgentAsset, AgentAssetTestRun, AgentAssetVersion
|
||
|
|
from app.services.agent_asset_release_guard import (
|
||
|
|
AgentAssetReleaseGuardService,
|
||
|
|
ReleaseEvaluationInput,
|
||
|
|
ReleaseGuardPolicy,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _session() -> Session:
|
||
|
|
engine = create_engine(
|
||
|
|
"sqlite+pysqlite:///:memory:",
|
||
|
|
connect_args={"check_same_thread": False},
|
||
|
|
poolclass=StaticPool,
|
||
|
|
)
|
||
|
|
Base.metadata.create_all(engine)
|
||
|
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||
|
|
|
||
|
|
|
||
|
|
def _seed_asset(db: Session) -> AgentAsset:
|
||
|
|
asset = AgentAsset(
|
||
|
|
id="asset-release",
|
||
|
|
asset_type="task",
|
||
|
|
code="task.release.guard",
|
||
|
|
name="发布门禁任务",
|
||
|
|
domain="expense",
|
||
|
|
owner="tester",
|
||
|
|
status="active",
|
||
|
|
current_version="v1",
|
||
|
|
working_version="v2",
|
||
|
|
published_version="v1",
|
||
|
|
config_json={"business_config": "preserved"},
|
||
|
|
)
|
||
|
|
db.add(asset)
|
||
|
|
db.add_all(
|
||
|
|
[
|
||
|
|
AgentAssetVersion(
|
||
|
|
asset_id=asset.id,
|
||
|
|
version=version,
|
||
|
|
content="{}",
|
||
|
|
content_type="json",
|
||
|
|
created_by="tester",
|
||
|
|
)
|
||
|
|
for version in ("v1", "v2")
|
||
|
|
]
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
return asset
|
||
|
|
|
||
|
|
|
||
|
|
def _policy() -> ReleaseGuardPolicy:
|
||
|
|
return ReleaseGuardPolicy(
|
||
|
|
shadow_min_samples=2,
|
||
|
|
canary_min_samples=3,
|
||
|
|
max_error_rate=0.1,
|
||
|
|
min_precision=0.9,
|
||
|
|
max_precision_drop=0.05,
|
||
|
|
canary_traffic_percent=10,
|
||
|
|
recall_gate_enabled=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_release_moves_shadow_canary_active_and_preserves_previous_version() -> None:
|
||
|
|
with _session() as db:
|
||
|
|
asset = _seed_asset(db)
|
||
|
|
service = AgentAssetReleaseGuardService(db)
|
||
|
|
|
||
|
|
shadow = service.start_shadow(asset.id, "v2", actor="manager", policy=_policy())
|
||
|
|
assert shadow["stage"] == "shadow"
|
||
|
|
assert shadow["previous_version"] == "v1"
|
||
|
|
assert db.get(AgentAsset, asset.id).config_json["business_config"] == "preserved"
|
||
|
|
assert service.get_serving_plan(asset.id) == {
|
||
|
|
"stage": "shadow",
|
||
|
|
"primary_version": "v1",
|
||
|
|
"candidate_version": "v2",
|
||
|
|
"candidate_traffic_percent": 0,
|
||
|
|
"shadow_evaluation": True,
|
||
|
|
}
|
||
|
|
|
||
|
|
service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(total=2, failure_count=0, precision=0.99),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
canary = service.promote(asset.id, actor="manager")
|
||
|
|
assert canary["stage"] == "canary"
|
||
|
|
assert canary["previous_version"] == "v1"
|
||
|
|
assert service.get_serving_plan(asset.id)["candidate_traffic_percent"] == 10
|
||
|
|
|
||
|
|
service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(
|
||
|
|
total=3,
|
||
|
|
failure_count=0,
|
||
|
|
precision=0.98,
|
||
|
|
baseline_precision=0.99,
|
||
|
|
),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
active = service.promote(asset.id, actor="manager")
|
||
|
|
|
||
|
|
refreshed = db.get(AgentAsset, asset.id)
|
||
|
|
assert active["stage"] == "active"
|
||
|
|
assert active["previous_version"] == "v1"
|
||
|
|
assert refreshed.published_version == "v2"
|
||
|
|
assert service.get_serving_plan(asset.id)["primary_version"] == "v2"
|
||
|
|
|
||
|
|
|
||
|
|
def test_release_cannot_promote_while_samples_are_collecting() -> None:
|
||
|
|
with _session() as db:
|
||
|
|
asset = _seed_asset(db)
|
||
|
|
service = AgentAssetReleaseGuardService(db)
|
||
|
|
service.start_shadow(asset.id, "v2", actor="manager", policy=_policy())
|
||
|
|
|
||
|
|
result = service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(total=1, failure_count=0, precision=0.99),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["status"] == "collecting"
|
||
|
|
try:
|
||
|
|
service.promote(asset.id, actor="manager")
|
||
|
|
except PermissionError as exc:
|
||
|
|
assert "尚无通过" in str(exc)
|
||
|
|
else:
|
||
|
|
raise AssertionError("collecting 状态不应允许晋级")
|
||
|
|
|
||
|
|
|
||
|
|
def test_active_quality_regression_automatically_restores_previous_version() -> None:
|
||
|
|
with _session() as db:
|
||
|
|
asset = _seed_asset(db)
|
||
|
|
service = AgentAssetReleaseGuardService(db)
|
||
|
|
service.start_shadow(asset.id, "v2", actor="manager", policy=_policy())
|
||
|
|
service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(total=2, failure_count=0, precision=0.99),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
service.promote(asset.id, actor="manager")
|
||
|
|
service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(total=3, failure_count=0, precision=0.99),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
service.promote(asset.id, actor="manager")
|
||
|
|
|
||
|
|
result = service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(
|
||
|
|
total=10,
|
||
|
|
failure_count=2,
|
||
|
|
precision=0.8,
|
||
|
|
baseline_precision=0.99,
|
||
|
|
),
|
||
|
|
actor="monitor",
|
||
|
|
)
|
||
|
|
|
||
|
|
refreshed = db.get(AgentAsset, asset.id)
|
||
|
|
assert result["status"] == "failed"
|
||
|
|
assert result["release_stage"] == "rolled_back"
|
||
|
|
assert refreshed.published_version == "v1"
|
||
|
|
assert refreshed.config_json["release_guard"]["previous_version"] == "v1"
|
||
|
|
assert refreshed.config_json["release_guard"]["rollback"]["automatic"] is True
|
||
|
|
assert service.get_serving_plan(asset.id)["primary_version"] == "v1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_invalid_or_missing_precision_fails_closed_and_writes_test_run() -> None:
|
||
|
|
with _session() as db:
|
||
|
|
asset = _seed_asset(db)
|
||
|
|
service = AgentAssetReleaseGuardService(db)
|
||
|
|
service.start_shadow(asset.id, "v2", actor="manager", policy=_policy())
|
||
|
|
|
||
|
|
result = service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(total=2, failure_count=0, precision=None),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
|
||
|
|
run = db.query(AgentAssetTestRun).filter_by(asset_id=asset.id).one()
|
||
|
|
assert result["status"] == "failed"
|
||
|
|
assert result["release_stage"] == "rolled_back"
|
||
|
|
assert run.passed is False
|
||
|
|
assert "precision_metric_missing" in run.result_json["reasons"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_malformed_policy_and_metric_counts_fail_closed() -> None:
|
||
|
|
with _session() as db:
|
||
|
|
asset = _seed_asset(db)
|
||
|
|
service = AgentAssetReleaseGuardService(db)
|
||
|
|
service.start_shadow(
|
||
|
|
asset.id,
|
||
|
|
"v2",
|
||
|
|
actor="manager",
|
||
|
|
policy=ReleaseGuardPolicy(
|
||
|
|
shadow_min_samples="invalid", # type: ignore[arg-type]
|
||
|
|
min_precision="invalid", # type: ignore[arg-type]
|
||
|
|
reviewer_quorum=99,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
state = service.get_state(asset.id)
|
||
|
|
assert state["policy"]["shadow_min_samples"] == 20
|
||
|
|
assert state["policy"]["min_precision"] == 0.98
|
||
|
|
assert state["policy"]["reviewer_quorum"] == 2
|
||
|
|
assert ReleaseGuardPolicy(reviewer_quorum=0).to_dict()["reviewer_quorum"] == 1
|
||
|
|
|
||
|
|
result = service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(
|
||
|
|
total="invalid", # type: ignore[arg-type]
|
||
|
|
failure_count=0,
|
||
|
|
precision=0.99,
|
||
|
|
),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["status"] == "failed"
|
||
|
|
assert result["release_stage"] == "rolled_back"
|
||
|
|
assert "invalid_evaluation_metrics" in result["reasons"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_restarted_release_cannot_reuse_previous_attempt_quality_gate() -> None:
|
||
|
|
with _session() as db:
|
||
|
|
asset = _seed_asset(db)
|
||
|
|
service = AgentAssetReleaseGuardService(db)
|
||
|
|
first = service.start_shadow(asset.id, "v2", actor="manager", policy=_policy())
|
||
|
|
service.record_evaluation(
|
||
|
|
asset.id,
|
||
|
|
ReleaseEvaluationInput(total=2, failure_count=0, precision=0.99),
|
||
|
|
actor="evaluator",
|
||
|
|
)
|
||
|
|
service.rollback(asset.id, actor="manager", reason="restart")
|
||
|
|
second = service.start_shadow(asset.id, "v2", actor="manager", policy=_policy())
|
||
|
|
|
||
|
|
assert first["release_id"] != second["release_id"]
|
||
|
|
with pytest.raises(PermissionError, match="尚无通过"):
|
||
|
|
service.promote(asset.id, actor="manager")
|