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
@@ -153,6 +153,20 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||||
/ `/disconnect`).
|
/ `/disconnect`).
|
||||||
|
|
||||||
|
### Phase 22 — Auto-update (Watchtower-style)
|
||||||
|
|
||||||
|
- A per-stack **Auto-update** policy (on the stack Overview tab): when the
|
||||||
|
background image-update check finds a newer registry digest for one of the
|
||||||
|
stack's images, the stack is either **pulled + redeployed** or merely
|
||||||
|
**flagged** ("notify only"), with a **Check now** button for an on-demand run.
|
||||||
|
- Runs inside the existing image-update-check cycle (reuses the freshly-computed
|
||||||
|
digest cache, no extra registry calls). Only **running** stacks are
|
||||||
|
auto-redeployed — a stopped stack is never silently started ("skipped").
|
||||||
|
- New `stack_auto_updated` notification event. Works for **remote stacks** too
|
||||||
|
(policy stored centrally; the agent answers `/agent/stacks/{id}/updates` and
|
||||||
|
performs the redeploy). Last run + status (updated / up-to-date /
|
||||||
|
update-available / skipped / error) are shown inline.
|
||||||
|
|
||||||
### Phase 21 — Container terminal (web exec)
|
### Phase 21 — Container terminal (web exec)
|
||||||
|
|
||||||
- An **interactive terminal** into any running, compose-managed container,
|
- An **interactive terminal** into any running, compose-managed container,
|
||||||
|
|||||||
+9
-1
@@ -83,7 +83,15 @@ root-equivalent).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 22 — Auto-Update (Watchtower-style) ☐ NOT STARTED → target 0.28.0
|
## Phase 22 — Auto-Update (Watchtower-style) ☑ DONE — shipped 0.28.0
|
||||||
|
|
||||||
|
**Result:** All four orchestration paths live-verified against real compose in
|
||||||
|
the 0.28.0 backend image (seeding `update_service._CACHE`): redeploy→`updated`
|
||||||
|
(pull+up, stack stays running), notify-only→`update-available`,
|
||||||
|
no-update→`up-to-date`, stopped-stack→`skipped`. Routes present (local +
|
||||||
|
agent + `/agent/stacks/{id}/updates`). Hooked into `update_service.background_loop`
|
||||||
|
(lazy import, no circular). Remote path reuses the proven `agent_service.call`;
|
||||||
|
not separately e2e'd against a live agent this round.
|
||||||
|
|
||||||
Per-stack policy: when an image used by the stack has a newer registry digest,
|
Per-stack policy: when an image used by the stack has a newer registry digest,
|
||||||
either auto pull+redeploy or just notify. Builds on the existing
|
either auto pull+redeploy or just notify. Builds on the existing
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.27.0"
|
AGENT_VERSION = "0.28.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -369,6 +369,12 @@ async def stack_logs(stack_id: str, tail: int = Query(200, le=2000)) -> dict:
|
|||||||
return {"logs": result.get("stdout", "") + result.get("stderr", "")}
|
return {"logs": result.get("stdout", "") + result.get("stderr", "")}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent/stacks/{stack_id}/updates", dependencies=[Depends(verify_token)])
|
||||||
|
async def stack_updates(stack_id: str, refresh: bool = Query(True)) -> dict:
|
||||||
|
"""Update status for this stack's images (used by central auto-update)."""
|
||||||
|
return await update_service.stack_updates(stack_id, refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)])
|
@app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)])
|
||||||
async def backup_stack(
|
async def backup_stack(
|
||||||
stack_id: str,
|
stack_id: str,
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.27.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.28.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""SQLModel table models. Importing this package registers all tables."""
|
"""SQLModel table models. Importing this package registers all tables."""
|
||||||
from models.agent import Agent
|
from models.agent import Agent
|
||||||
from models.audit import AuditLog
|
from models.audit import AuditLog
|
||||||
|
from models.auto_update import AutoUpdate
|
||||||
from models.backup_destination import BackupDestination
|
from models.backup_destination import BackupDestination
|
||||||
from models.backup_schedule import BackupSchedule
|
from models.backup_schedule import BackupSchedule
|
||||||
from models.setting import Setting, Webhook
|
from models.setting import Setting, Webhook
|
||||||
@@ -10,5 +11,5 @@ from models.user import User
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
|
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
|
||||||
"BackupDestination", "BackupSchedule",
|
"BackupDestination", "BackupSchedule", "AutoUpdate",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoUpdate(SQLModel, table=True):
|
||||||
|
"""Per-stack auto-update policy (Watchtower-style).
|
||||||
|
|
||||||
|
When the background image-update check finds a newer registry digest for one
|
||||||
|
of the stack's images, the stack is either pulled + redeployed
|
||||||
|
(``redeploy=True``) or merely notified about (``redeploy=False``).
|
||||||
|
``agent_id`` None = local host, otherwise a remote agent's stack.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
stack_id: str
|
||||||
|
agent_id: Optional[int] = None
|
||||||
|
enabled: bool = True
|
||||||
|
redeploy: bool = True # True = pull + up -d; False = notify only
|
||||||
|
last_run: Optional[datetime] = None
|
||||||
|
last_status: Optional[str] = None # "updated" | "up-to-date" | "update-available" | "skipped" | "error"
|
||||||
|
last_result: Optional[str] = None # detail (stale images / error text)
|
||||||
|
created_at: datetime = Field(default_factory=_now)
|
||||||
|
|
||||||
|
|
||||||
|
# --- API schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class AutoUpdateWrite(SQLModel):
|
||||||
|
enabled: bool = True
|
||||||
|
redeploy: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AutoUpdateRead(SQLModel):
|
||||||
|
id: Optional[int]
|
||||||
|
stack_id: str
|
||||||
|
agent_id: Optional[int]
|
||||||
|
agent_name: Optional[str] = None
|
||||||
|
enabled: bool
|
||||||
|
redeploy: bool
|
||||||
|
last_run: Optional[datetime]
|
||||||
|
last_status: Optional[str]
|
||||||
|
last_result: Optional[str]
|
||||||
@@ -17,6 +17,7 @@ EVENT_STACK_STOP = "stack_stop"
|
|||||||
EVENT_STACK_ERROR = "stack_error"
|
EVENT_STACK_ERROR = "stack_error"
|
||||||
EVENT_PULL_FAILED = "pull_failed"
|
EVENT_PULL_FAILED = "pull_failed"
|
||||||
EVENT_BACKUP_FAILED = "backup_failed"
|
EVENT_BACKUP_FAILED = "backup_failed"
|
||||||
|
EVENT_STACK_AUTO_UPDATED = "stack_auto_updated"
|
||||||
|
|
||||||
ALL_EVENTS = [
|
ALL_EVENTS = [
|
||||||
EVENT_UPDATE_AVAILABLE,
|
EVENT_UPDATE_AVAILABLE,
|
||||||
@@ -25,6 +26,7 @@ ALL_EVENTS = [
|
|||||||
EVENT_STACK_ERROR,
|
EVENT_STACK_ERROR,
|
||||||
EVENT_PULL_FAILED,
|
EVENT_PULL_FAILED,
|
||||||
EVENT_BACKUP_FAILED,
|
EVENT_BACKUP_FAILED,
|
||||||
|
EVENT_STACK_AUTO_UPDATED,
|
||||||
]
|
]
|
||||||
|
|
||||||
WEBHOOK_TYPES = ["ntfy", "discord", "slack", "gotify", "generic"]
|
WEBHOOK_TYPES = ["ntfy", "discord", "slack", "gotify", "generic"]
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ from models.stack import StackCreate, StackUpdate
|
|||||||
from models.user import User
|
from models.user import User
|
||||||
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
|
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
|
||||||
from routers.networks import ContainerRef, NetworkCreate
|
from routers.networks import ContainerRef, NetworkCreate
|
||||||
|
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
|
||||||
from services import (
|
from services import (
|
||||||
agent_service,
|
agent_service,
|
||||||
audit_service,
|
audit_service,
|
||||||
|
auto_update_service,
|
||||||
backup_destination_service as dest_service,
|
backup_destination_service as dest_service,
|
||||||
backup_service,
|
backup_service,
|
||||||
compose_service,
|
compose_service,
|
||||||
@@ -983,3 +985,57 @@ async def agent_container_action(
|
|||||||
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
|
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Auto-update policy (Watchtower-style) — remote stacks (stored centrally)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
|
||||||
|
def agent_get_auto_update(
|
||||||
|
agent_id: int,
|
||||||
|
stack_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
_get_or_404(session, agent_id)
|
||||||
|
policy = auto_update_service.get_policy(session, stack_id, agent_id)
|
||||||
|
return auto_update_service.to_read(session, policy, stack_id, agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
|
||||||
|
def agent_set_auto_update(
|
||||||
|
agent_id: int,
|
||||||
|
stack_id: str,
|
||||||
|
body: AutoUpdateWrite,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
policy = auto_update_service.upsert_policy(
|
||||||
|
session, stack_id, body.enabled, body.redeploy, agent_id=agent_id
|
||||||
|
)
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="agent.stack.auto_update",
|
||||||
|
target=f"{agent.name}/{stack_id}",
|
||||||
|
detail=f"enabled={body.enabled} redeploy={body.redeploy}", ip=_ip(request),
|
||||||
|
)
|
||||||
|
return auto_update_service.to_read(session, policy, stack_id, agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{agent_id}/stacks/{stack_id}/auto-update/run", response_model=AutoUpdateRead)
|
||||||
|
async def agent_run_auto_update(
|
||||||
|
agent_id: int,
|
||||||
|
stack_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
_get_or_404(session, agent_id)
|
||||||
|
policy = auto_update_service.get_policy(session, stack_id, agent_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, agent_id)
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ from models.setting import (
|
|||||||
EVENT_STACK_START,
|
EVENT_STACK_START,
|
||||||
EVENT_STACK_STOP,
|
EVENT_STACK_STOP,
|
||||||
)
|
)
|
||||||
|
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
|
||||||
from models.user import User
|
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
|
from services.convert_service import convert_docker_run
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
|
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
|
||||||
@@ -398,3 +399,49 @@ def convert(
|
|||||||
return ConvertResponse(yaml=convert_docker_run(body.command))
|
return ConvertResponse(yaml=convert_docker_run(body.command))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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)
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""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
|
||||||
|
_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)
|
||||||
@@ -198,6 +198,45 @@ def _all_running_images() -> set[str]:
|
|||||||
return images
|
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]:
|
async def check_all() -> dict[str, dict]:
|
||||||
images = _all_running_images()
|
images = _all_running_images()
|
||||||
for image in images:
|
for image in images:
|
||||||
@@ -218,6 +257,14 @@ async def background_loop():
|
|||||||
logger.info("Image update check complete (%d images)", len(_CACHE))
|
logger.info("Image update check complete (%d images)", len(_CACHE))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Image update check failed: %s", exc)
|
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.
|
# Re-read the interval each cycle so Settings changes take effect.
|
||||||
interval = max(settings_service.get_update_interval(), 5) * 60
|
interval = max(settings_service.get_update_interval(), 5) * 60
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.27.0",
|
"version": "0.28.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import api from "./client";
|
||||||
|
|
||||||
|
export interface AutoUpdatePolicy {
|
||||||
|
id: number | null;
|
||||||
|
stack_id: string;
|
||||||
|
agent_id: number | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
redeploy: boolean;
|
||||||
|
last_run: string | null;
|
||||||
|
last_status: string | null;
|
||||||
|
last_result: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local host, or a remote agent's stack when agentId is given.
|
||||||
|
const base = (stackId: string, agentId?: number) =>
|
||||||
|
agentId != null
|
||||||
|
? `/api/agents/${agentId}/stacks/${stackId}/auto-update`
|
||||||
|
: `/api/stacks/${stackId}/auto-update`;
|
||||||
|
|
||||||
|
export const autoUpdateApi = {
|
||||||
|
get: (stackId: string, agentId?: number) =>
|
||||||
|
api.get<AutoUpdatePolicy>(base(stackId, agentId)).then((r) => r.data),
|
||||||
|
set: (stackId: string, body: { enabled: boolean; redeploy: boolean }, agentId?: number) =>
|
||||||
|
api.put<AutoUpdatePolicy>(base(stackId, agentId), body).then((r) => r.data),
|
||||||
|
run: (stackId: string, agentId?: number) =>
|
||||||
|
api.post<AutoUpdatePolicy>(`${base(stackId, agentId)}/run`).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { RefreshCw, ArrowUpCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge, Button, Card } from "@/components/ui";
|
||||||
|
import { autoUpdateApi, type AutoUpdatePolicy } from "@/api/autoUpdate";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, string> = {
|
||||||
|
updated: "text-green-500",
|
||||||
|
"up-to-date": "text-slate-500",
|
||||||
|
"update-available": "text-amber-500",
|
||||||
|
skipped: "text-slate-500",
|
||||||
|
error: "text-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watchtower-style auto-update control for one stack (local or, with agentId,
|
||||||
|
* a remote agent's stack). When a newer image digest is found by the background
|
||||||
|
* check, the stack is pulled + redeployed or merely flagged, per the policy.
|
||||||
|
*/
|
||||||
|
export function AutoUpdatePanel({
|
||||||
|
stackId,
|
||||||
|
agentId,
|
||||||
|
isAdmin,
|
||||||
|
}: {
|
||||||
|
stackId: string;
|
||||||
|
agentId?: number;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const key = ["auto-update", agentId ?? "local", stackId];
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: () => autoUpdateApi.get(stackId, agentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (body: { enabled: boolean; redeploy: boolean }) =>
|
||||||
|
autoUpdateApi.set(stackId, body, agentId),
|
||||||
|
onSuccess: (p) => qc.setQueryData(key, p),
|
||||||
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runNow = useMutation({
|
||||||
|
mutationFn: () => autoUpdateApi.run(stackId, agentId),
|
||||||
|
onSuccess: (p) => {
|
||||||
|
qc.setQueryData(key, p);
|
||||||
|
toast.success(`Auto-update: ${p.last_status ?? "done"}`);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) return null;
|
||||||
|
const p: AutoUpdatePolicy = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUpCircle className="h-4 w-4 text-accent dark:text-accent-dark" />
|
||||||
|
<span className="font-medium">Auto-update</span>
|
||||||
|
{p.enabled ? (
|
||||||
|
<Badge>{p.redeploy ? "pull + redeploy" : "notify only"}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">off</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.enabled}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onChange={(e) => save.mutate({ enabled: e.target.checked, redeploy: p.redeploy })}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.enabled && isAdmin && (
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`mode-${agentId ?? "l"}-${stackId}`}
|
||||||
|
checked={p.redeploy}
|
||||||
|
onChange={() => save.mutate({ enabled: true, redeploy: true })}
|
||||||
|
/>
|
||||||
|
Pull & redeploy
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`mode-${agentId ?? "l"}-${stackId}`}
|
||||||
|
checked={!p.redeploy}
|
||||||
|
onChange={() => save.mutate({ enabled: true, redeploy: false })}
|
||||||
|
/>
|
||||||
|
Notify only
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => runNow.mutate()}
|
||||||
|
loading={runNow.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" /> Check now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.last_run && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Last run {new Date(p.last_run).toLocaleString()} —{" "}
|
||||||
|
<span className={STATUS_TONE[p.last_status ?? ""] ?? "text-slate-500"}>
|
||||||
|
{p.last_status}
|
||||||
|
</span>
|
||||||
|
{p.last_result ? ` (${p.last_result})` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
|||||||
import { HostDot } from "@/components/hosts/HostDot";
|
import { HostDot } from "@/components/hosts/HostDot";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||||
|
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
@@ -132,6 +133,7 @@ export function RemoteStackDetail() {
|
|||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{tab === "Overview" && (
|
{tab === "Overview" && (
|
||||||
<Overview
|
<Overview
|
||||||
|
stackId={id}
|
||||||
containers={data.containers}
|
containers={data.containers}
|
||||||
host={agentHost}
|
host={agentHost}
|
||||||
agentId={aid}
|
agentId={aid}
|
||||||
@@ -170,12 +172,14 @@ export function RemoteStackDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Overview({
|
function Overview({
|
||||||
|
stackId,
|
||||||
containers,
|
containers,
|
||||||
host,
|
host,
|
||||||
agentId,
|
agentId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
|
stackId: string;
|
||||||
containers: ContainerInfo[];
|
containers: ContainerInfo[];
|
||||||
host?: string;
|
host?: string;
|
||||||
agentId: number;
|
agentId: number;
|
||||||
@@ -184,6 +188,7 @@ function Overview({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 overflow-auto">
|
<div className="space-y-2 overflow-auto">
|
||||||
|
<AutoUpdatePanel stackId={stackId} agentId={agentId} isAdmin={isAdmin} />
|
||||||
{containers.length === 0 && (
|
{containers.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-slate-500">No containers running.</p>
|
<p className="text-sm text-slate-500">No containers running.</p>
|
||||||
|
|||||||
@@ -686,6 +686,8 @@ const EVENT_LABELS: Record<string, string> = {
|
|||||||
stack_stop: "Stack stopped",
|
stack_stop: "Stack stopped",
|
||||||
stack_error: "Stack error",
|
stack_error: "Stack error",
|
||||||
pull_failed: "Pull/update failed",
|
pull_failed: "Pull/update failed",
|
||||||
|
backup_failed: "Backup failed",
|
||||||
|
stack_auto_updated: "Stack auto-updated",
|
||||||
};
|
};
|
||||||
|
|
||||||
function NotificationsSection() {
|
function NotificationsSection() {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
|||||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||||
|
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
@@ -130,6 +131,7 @@ function Overview({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 overflow-auto">
|
<div className="space-y-2 overflow-auto">
|
||||||
|
<AutoUpdatePanel stackId={data.id} isAdmin={isAdmin} />
|
||||||
{data.containers.length === 0 && (
|
{data.containers.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
|
|||||||
Reference in New Issue
Block a user