"""Auto-update (Watchtower-style) orchestration. Runs once per image-update-check cycle (called from ``update_service.background_loop`` so it reuses the freshly-populated digest cache). For each enabled policy whose stack has a newer image available, either pulls + redeploys the stack or just notifies, recording the outcome. Image resolution and digest comparison live in ``update_service``; this module adds the policy layer on top. """ from __future__ import annotations import logging from datetime import datetime, timezone from sqlmodel import Session, select from database import engine from models.auto_update import AutoUpdate from models.setting import EVENT_PULL_FAILED, EVENT_STACK_AUTO_UPDATED from services import ( compose_service, notify_service, stack_lock_service, update_service, ) logger = logging.getLogger("stackpilot.autoupdate") # Stack states we'll act on. We never auto-start a stopped stack. _LIVE_STATES = {"running", "partial", "updating"} def _now() -> datetime: return datetime.now(timezone.utc) # --------------------------------------------------------------------------- # # Policy CRUD helpers # --------------------------------------------------------------------------- # def get_policy(session: Session, stack_id: str) -> AutoUpdate | None: return session.exec(select(AutoUpdate).where(AutoUpdate.stack_id == stack_id)).first() def upsert_policy(session: Session, stack_id: str, enabled: bool, redeploy: bool) -> AutoUpdate: policy = get_policy(session, stack_id) if policy is None: policy = AutoUpdate(stack_id=stack_id) policy.enabled = enabled policy.redeploy = redeploy session.add(policy) session.commit() session.refresh(policy) return policy def to_read(policy: AutoUpdate | None, stack_id: str) -> dict: """Build an AutoUpdateRead-shaped dict, defaulting to disabled when absent.""" if policy is None: return { "id": None, "stack_id": stack_id, "enabled": False, "redeploy": True, "last_run": None, "last_status": None, "last_result": None, } return { "id": policy.id, "stack_id": policy.stack_id, "enabled": policy.enabled, "redeploy": policy.redeploy, "last_run": policy.last_run, "last_status": policy.last_status, "last_result": policy.last_result, } def _record(session: Session, policy: AutoUpdate, status: str, result: str = "") -> None: policy.last_run = _now() policy.last_status = status policy.last_result = result[:300] if result else None session.add(policy) session.commit() async def _run_local(session: Session, policy: AutoUpdate) -> None: stack_id = policy.stack_id try: state = compose_service.compute_status(stack_id) except Exception: # noqa: BLE001 state = "unknown" if state not in _LIVE_STATES: _record(session, policy, "skipped", f"stack not running ({state})") return summary = await update_service.stack_updates(stack_id, refresh=False) if not summary["update_available"]: _record(session, policy, "up-to-date") return stale = ", ".join(summary["stale_images"]) prev = policy.last_status if policy.redeploy: try: # Never redeploy underneath somebody: if a user is mid-deploy on # this stack, skip and pick it up next cycle rather than racing # them over the same containers. with stack_lock_service.hold(session, stack_id, "auto-update", "auto-update"): await compose_service.pull(stack_id) await compose_service.up(stack_id) except stack_lock_service.StackBusy as exc: _record(session, policy, "skipped", str(exc)) return except Exception as exc: # noqa: BLE001 _record(session, policy, "error", str(exc)) await _safe_notify(EVENT_PULL_FAILED, f"Auto-update of '{stack_id}' failed", str(exc), session) return update_service.refresh_stack_local(stack_id) _record(session, policy, "updated", stale) await _safe_notify( EVENT_STACK_AUTO_UPDATED, f"Stack '{stack_id}' auto-updated", f"Pulled and redeployed: {stale}.", session, ) else: _record(session, policy, "update-available", stale) if prev != "update-available": # notify once per transition, not every cycle await _safe_notify( EVENT_STACK_AUTO_UPDATED, f"Update available for '{stack_id}'", f"Newer images: {stale} (auto-redeploy is off).", session, ) async def _safe_notify(event: str, title: str, message: str, session: Session) -> None: try: await notify_service.notify(event, title, message, session) except Exception as exc: # noqa: BLE001 - notifications are best-effort logger.debug("auto-update notify failed: %s", exc) async def run_policy(session: Session, policy: AutoUpdate) -> None: await _run_local(session, policy) async def run_due() -> None: """Process every enabled policy. Best-effort: one failure never aborts the rest.""" with Session(engine) as session: policies = list(session.exec(select(AutoUpdate).where(AutoUpdate.enabled == True))) # noqa: E712 for policy in policies: try: with Session(engine) as session: fresh = session.get(AutoUpdate, policy.id) if fresh and fresh.enabled: await run_policy(session, fresh) except Exception as exc: # noqa: BLE001 logger.warning("Auto-update policy %s failed: %s", policy.id, exc)