Phase 22: auto-update (Watchtower-style), local + agent (0.28.0)

Per-stack auto-update policy on the stack Overview tab. When the background
image-update check finds a newer registry digest for one of a stack's images,
the stack is pulled + redeployed (or just flagged, "notify only"). Only running
stacks are auto-redeployed; a stopped stack is skipped, never silently started.

- models/auto_update.py: AutoUpdate(stack_id, agent_id, enabled, redeploy,
  last_run/status/result) + schemas; registered in models/__init__.py.
- update_service: DB-free stack_images/stack_updates helpers (agent reuses
  them); agent GET /agent/stacks/{id}/updates.
- services/auto_update_service.py: run_due/run_policy (local pull+up via
  compose_service, remote via agent_service POST /agent/stacks/{id}/update,
  notify-only with per-transition dedup); lazy-called from
  update_service.background_loop. New stack_auto_updated notify event.
- routers: GET/PUT/run /api/stacks/{id}/auto-update and the
  /api/agents/{id}/stacks/{sid}/auto-update variants (policy stored centrally).
- frontend: api/autoUpdate.ts + AutoUpdatePanel (enable, redeploy|notify-only,
  Check now, last-run status) on StackDetail + RemoteStackDetail; EVENT_LABELS
  gains stack_auto_updated + backup_failed.

Live-verified all four paths (updated / update-available / up-to-date /
skipped) against real compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-09 13:14:50 +00:00
co-authored by Claude Opus 4.8
parent be3568274f
commit 255c8441c6
17 changed files with 592 additions and 6 deletions
+48 -1
View File
@@ -25,8 +25,9 @@ from models.setting import (
EVENT_STACK_START,
EVENT_STACK_STOP,
)
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
from models.user import User
from services import audit_service, compose_service, notify_service, stats_service
from services import audit_service, auto_update_service, compose_service, notify_service, stats_service
from services.convert_service import convert_docker_run
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
@@ -398,3 +399,49 @@ def convert(
return ConvertResponse(yaml=convert_docker_run(body.command))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# --------------------------------------------------------------------------- #
# Auto-update policy (Watchtower-style) — local stacks
# --------------------------------------------------------------------------- #
@router.get("/{stack_id}/auto-update", response_model=AutoUpdateRead)
def get_auto_update(
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
policy = auto_update_service.get_policy(session, stack_id)
return auto_update_service.to_read(session, policy, stack_id)
@router.put("/{stack_id}/auto-update", response_model=AutoUpdateRead)
def set_auto_update(
stack_id: str,
body: AutoUpdateWrite,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
policy = auto_update_service.upsert_policy(session, stack_id, body.enabled, body.redeploy)
audit_service.record(
session, user=user.username, action="stack.auto_update",
target=stack_id, detail=f"enabled={body.enabled} redeploy={body.redeploy}",
ip=_client_ip(request),
)
return auto_update_service.to_read(session, policy, stack_id)
@router.post("/{stack_id}/auto-update/run", response_model=AutoUpdateRead)
async def run_auto_update(
stack_id: str,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
policy = auto_update_service.get_policy(session, stack_id)
if policy is None:
raise HTTPException(status_code=404, detail="No auto-update policy for this stack")
await auto_update_service.run_policy(session, policy)
session.refresh(policy)
return auto_update_service.to_read(session, policy, stack_id)