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:
co-authored by
Claude Opus 4.8
parent
be3568274f
commit
255c8441c6
@@ -198,6 +198,45 @@ def _all_running_images() -> set[str]:
|
||||
return images
|
||||
|
||||
|
||||
def stack_images(stack_id: str) -> set[str]:
|
||||
"""Images used by the containers of one compose project (== stack id)."""
|
||||
images: set[str] = set()
|
||||
try:
|
||||
client = get_client()
|
||||
for c in safe_call(client.containers.list, all=True):
|
||||
labels = c.labels or {}
|
||||
if labels.get("com.docker.compose.project") != stack_id:
|
||||
continue
|
||||
cfg_image = c.attrs.get("Config", {}).get("Image")
|
||||
if cfg_image:
|
||||
images.add(cfg_image)
|
||||
except DockerError:
|
||||
pass
|
||||
return images
|
||||
|
||||
|
||||
async def stack_updates(stack_id: str, refresh: bool = True) -> dict:
|
||||
"""Update status for one stack's images.
|
||||
|
||||
``refresh=True`` queries the registry now; ``False`` reads the cache the
|
||||
background loop already populated (so the auto-update pass adds no extra
|
||||
registry round-trips). DB-free, so the agent can reuse it verbatim.
|
||||
"""
|
||||
images = stack_images(stack_id)
|
||||
result: dict[str, dict] = {}
|
||||
for image in images:
|
||||
status = await check_image(image) if refresh else _CACHE.get(image)
|
||||
if status is not None:
|
||||
result[image] = status.to_dict()
|
||||
stale = [img for img, st in result.items() if st.get("update_available")]
|
||||
return {
|
||||
"stack_id": stack_id,
|
||||
"update_available": bool(stale),
|
||||
"stale_images": stale,
|
||||
"images": result,
|
||||
}
|
||||
|
||||
|
||||
async def check_all() -> dict[str, dict]:
|
||||
images = _all_running_images()
|
||||
for image in images:
|
||||
@@ -218,6 +257,14 @@ async def background_loop():
|
||||
logger.info("Image update check complete (%d images)", len(_CACHE))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Image update check failed: %s", exc)
|
||||
# Apply auto-update policies using the digest cache we just refreshed.
|
||||
# Lazy import avoids a circular import (auto_update_service imports us).
|
||||
try:
|
||||
from services import auto_update_service
|
||||
|
||||
await auto_update_service.run_due()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Auto-update pass failed: %s", exc)
|
||||
# Re-read the interval each cycle so Settings changes take effect.
|
||||
interval = max(settings_service.get_update_interval(), 5) * 60
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
Reference in New Issue
Block a user