"""Realized cost-optimization savings tracker (audit post-log hook). Closes the efficiency loop from *estimated* → *realized*. When a server that WAS flagged idle/oversized is actually eliminated (``destroy``) or stopped (``power_off``), this hook snapshots the pre-action monthly cost baseline into ``cmdb_efficiency_savings_events``. The realized amount is derived at read time (see ``savings_service``) as baseline − actual post-action cost, so a restart self-corrects to ~0 saved. Conservative attribution (the user's explicit choice): an elimination is credited ONLY when the server carried an idle/oversized verdict — live in ``cmdb_server_efficiency`` OR captured in a triage row's ``verdict_at_decision`` (covers the case where the daily recompute already drifted the verdict after the box went quiet). Registered at startup in BOTH the API (``app.main``) and worker (``app.worker``) so it fires regardless of which process logged the action. Best-effort: any failure is swallowed by the audit-hook contract and must never break the lifecycle operation it observes. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime, timedelta from decimal import Decimal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.domains.cmdb.cost_view import fact_engineering_amount from app.domains.cmdb.models import ( SAVINGS_METHOD_DESTROY, SAVINGS_METHOD_STOP, SAVINGS_SOURCE_HELIX, CmdbCostFact, CmdbEfficiencySavingsEvent, CmdbServer, CmdbServerEfficiency, CmdbServerEfficiencyTriage, ) logger = logging.getLogger(__name__) _ACTIONABLE = ("idle", "oversized") # audit action → savings method _WATCHED_ACTIONS = { "cmdb.server.destroy": SAVINGS_METHOD_DESTROY, "cmdb.server.power_off": SAVINGS_METHOD_STOP, } def register() -> None: """Idempotently register the savings-tracker audit hook.""" from app.audit.service import register_audit_log_hook register_audit_log_hook(_on_audit_log) async def _was_flagged(db: AsyncSession, server_id: str) -> str | None: """Return the actionable verdict the server was flagged with, or None. Checks the live efficiency verdict first, then any triage decision that was made against an actionable verdict (survives verdict drift).""" v = ( await db.execute( select(CmdbServerEfficiency.verdict).where( CmdbServerEfficiency.server_id == server_id, ) ) ).scalar_one_or_none() if v in _ACTIONABLE: return v tv = ( await db.execute( select(CmdbServerEfficiencyTriage.verdict_at_decision).where( CmdbServerEfficiencyTriage.server_id == server_id, ) ) ).scalar_one_or_none() if tv in _ACTIONABLE: return tv return None async def _baseline_monthly( db: AsyncSession, integration_id: str | None, instance_id: str | None, before: datetime, ) -> tuple[Decimal | None, str | None, str | None]: """Trailing ~30d cost (≈ monthly) for the instance right before the action, plus the currency and the scope it attributes to. Returns ``(monthly_usd, currency, scope_id)`` — any may be None when the instance has no cost line (then no realized amount can be quantified).""" if not instance_id or not integration_id: return None, None, None from sqlalchemy import func window_start = (before - timedelta(days=30)).date() row = ( await db.execute( select( func.coalesce(func.sum(fact_engineering_amount()), 0), func.max(CmdbCostFact.currency), func.max(CmdbCostFact.scope_id), ).where( CmdbCostFact.integration_id == integration_id, CmdbCostFact.resource_id == instance_id, CmdbCostFact.date >= window_start, CmdbCostFact.date <= before.date(), ) ) ).one() total, currency, scope_id = row monthly = Decimal(str(total)) if total is not None else None if monthly is not None and monthly <= 0: monthly = None return monthly, currency, (str(scope_id) if scope_id else None) async def snapshot_owner( db: AsyncSession, server: CmdbServer, ) -> tuple[str | None, str | None]: """Owner + primary-department snapshot for a savings event (best-effort). Stored owner wins; a server without one snapshots the owner it inherits from its cloud subscription (same effective-owner semantics as the server list / by-department rollup). ``include_deleted`` because a destroy soft-deletes the row before the observer runs.""" owner_person_id = ( str(server.owner_person_id) if server.owner_person_id else None ) if owner_person_id is None: from app.domains.cmdb.service import resolve_inherited_server_owners inherited = await resolve_inherited_server_owners( db, [server], include_deleted=True, ) info = inherited.get(str(server.id)) if info and info.get("person_id"): owner_person_id = info["person_id"] owner_dept = None if owner_person_id: from app.identity.person_models import IdentityOrgMembership owner_dept = ( await db.execute( select(IdentityOrgMembership.org_node_name).where( IdentityOrgMembership.person_id == owner_person_id, IdentityOrgMembership.is_primary.is_(True), IdentityOrgMembership.deleted_at.is_(None), ) ) ).scalar_one_or_none() return owner_person_id, owner_dept async def _on_audit_log(entry: AuditLog, db: AsyncSession) -> None: method = _WATCHED_ACTIONS.get(entry.action or "") if method is None or entry.status != "success": return if entry.resource_type != "cmdb_server" or not entry.resource_id: return server_id = str(entry.resource_id) verdict = await _was_flagged(db, server_id) if verdict is None: return # conservative: only flagged machines count server = ( await db.execute(select(CmdbServer).where(CmdbServer.id == server_id)) ).scalar_one_or_none() if server is None: return acted_at = datetime.now(UTC) baseline, currency, scope_id = await _baseline_monthly( db, str(server.integration_id), server.instance_id, acted_at, ) # Owner + department snapshot (best-effort) for the leadership rollup. owner_person_id, owner_dept = await snapshot_owner(db, server) existing = ( await db.execute( select(CmdbEfficiencySavingsEvent).where( CmdbEfficiencySavingsEvent.server_id == server_id, ) ) ).scalar_one_or_none() if existing is None: db.add(CmdbEfficiencySavingsEvent( id=uuid.uuid4(), server_id=server_id, instance_id=server.instance_id, integration_id=str(server.integration_id), scope_id=scope_id, method=method, source=SAVINGS_SOURCE_HELIX, verdict_at_action=verdict, monthly_cost_baseline_usd=baseline, cost_currency=currency or "USD", owner_person_id=owner_person_id, owner_department=owner_dept, acted_by_person_id=( str(entry.actor_user_id) if entry.actor_user_id else None ), acted_at=acted_at, )) else: # Keep the original baseline (the "what it used to cost" anchor); # promote stop→destroy and refresh the action timestamp. if method == SAVINGS_METHOD_DESTROY: existing.method = SAVINGS_METHOD_DESTROY existing.acted_at = acted_at existing.verdict_at_action = existing.verdict_at_action or verdict