Files
stackpilot/backend/services/auto_update_service.py
T
menzeljandClaude Fable 5 9119f94536 Clear the stack update pill immediately after a manual/auto update (0.38.4)
The amber image-update indicator is fed from update_service._CACHE, which
only the background loop refreshed — after a per-stack Update/Pull the
stale digests kept the pill on until the next pass. Now the local digests
are reconciled with the cached remote digests right after a successful
pull/update (local backend, agent lifecycle, auto-update pass), and the
frontend invalidates the stack-updates queries after actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:52:31 +00:00

196 lines
7.5 KiB
Python

"""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.
Central-only / DB-aware. Image resolution + digest comparison live in the
DB-free ``update_service`` so the agent can answer ``/agent/stacks/{id}/updates``
with the same logic.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlmodel import Session, select
from database import engine
from models.agent import Agent
from models.auto_update import AutoUpdate
from models.setting import EVENT_PULL_FAILED, EVENT_STACK_AUTO_UPDATED
from services import agent_service, compose_service, notify_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 (shared by the stacks + agents routers)
# --------------------------------------------------------------------------- #
def get_policy(session: Session, stack_id: str, agent_id: int | None = None) -> AutoUpdate | None:
stmt = select(AutoUpdate).where(AutoUpdate.stack_id == stack_id)
stmt = stmt.where(AutoUpdate.agent_id == agent_id) if agent_id is not None \
else stmt.where(AutoUpdate.agent_id.is_(None))
return session.exec(stmt).first()
def upsert_policy(
session: Session, stack_id: str, enabled: bool, redeploy: bool, agent_id: int | None = None
) -> AutoUpdate:
policy = get_policy(session, stack_id, agent_id)
if policy is None:
policy = AutoUpdate(stack_id=stack_id, agent_id=agent_id)
policy.enabled = enabled
policy.redeploy = redeploy
session.add(policy)
session.commit()
session.refresh(policy)
return policy
def to_read(session: Session, policy: AutoUpdate | None, stack_id: str, agent_id: int | None = None) -> dict:
"""Build an AutoUpdateRead-shaped dict, defaulting to disabled when absent."""
agent_name = None
if agent_id is not None:
agent = session.get(Agent, agent_id)
agent_name = agent.name if agent else None
if policy is None:
return {
"id": None, "stack_id": stack_id, "agent_id": agent_id, "agent_name": agent_name,
"enabled": False, "redeploy": True,
"last_run": None, "last_status": None, "last_result": None,
}
return {
"id": policy.id, "stack_id": policy.stack_id, "agent_id": policy.agent_id,
"agent_name": agent_name, "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:
await compose_service.pull(stack_id)
await compose_service.up(stack_id)
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 _run_remote(session: Session, policy: AutoUpdate) -> None:
agent = session.get(Agent, policy.agent_id)
if not agent:
_record(session, policy, "error", "agent not found")
return
stack_id = policy.stack_id
try:
summary = await agent_service.call(
session, agent, "GET", f"/agent/stacks/{stack_id}/updates",
params={"refresh": "true"},
)
except Exception as exc: # noqa: BLE001
_record(session, policy, "error", f"agent check failed: {exc}")
return
if not summary or not summary.get("update_available"):
_record(session, policy, "up-to-date")
return
stale = ", ".join(summary.get("stale_images", []))
label = f"{agent.name}/{stack_id}"
prev = policy.last_status
if policy.redeploy:
try:
await agent_service.call(session, agent, "POST", f"/agent/stacks/{stack_id}/update")
except Exception as exc: # noqa: BLE001
_record(session, policy, "error", str(exc))
await _safe_notify(EVENT_PULL_FAILED, f"Auto-update of '{label}' failed", str(exc), session)
return
_record(session, policy, "updated", stale)
await _safe_notify(
EVENT_STACK_AUTO_UPDATED, f"Stack '{label}' auto-updated",
f"Pulled and redeployed: {stale}.", session,
)
else:
_record(session, policy, "update-available", stale)
if prev != "update-available":
await _safe_notify(
EVENT_STACK_AUTO_UPDATED, f"Update available for '{label}'",
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:
if policy.agent_id is None:
await _run_local(session, policy)
else:
await _run_remote(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)