feat(web): Remote Updates API — dashboard endpoints for pushing code to workers
Adds /api/v1/swarm-updates/{hosts,push,push-self,rollback} behind
require_admin. Reuses the existing UpdaterClient + tar_working_tree + the
per-host asyncio.gather pattern from api_deploy_swarm.py; tarball is
built exactly once per /push request and fanned out to every selected
worker. /hosts filters out decommissioned hosts and agent-only
enrollments (no updater bundle = not a target).
Connection drops during /update-self are treated as success — the
updater re-execs itself mid-response, so httpx always raises.
Pydantic models live in decnet/web/db/models.py (single source of
truth). 24 tests cover happy paths, rollback, transport failures,
include_self ordering (skip on rolled-back agents), validation, and
RBAC gating.
This commit is contained in:
@@ -373,3 +373,72 @@ class SwarmHostHealth(BaseModel):
|
||||
|
||||
class SwarmCheckResponse(BaseModel):
|
||||
results: list[SwarmHostHealth]
|
||||
|
||||
|
||||
# --- Remote Updates (master → worker /updater) DTOs ---
|
||||
# Powers the dashboard's Remote Updates page. The master dashboard calls
|
||||
# these (auth-gated) endpoints; internally they fan out to each worker's
|
||||
# updater daemon over mTLS via UpdaterClient.
|
||||
|
||||
class HostReleaseInfo(BaseModel):
|
||||
host_uuid: str
|
||||
host_name: str
|
||||
address: str
|
||||
reachable: bool
|
||||
# These fields mirror the updater's /health payload when reachable; they
|
||||
# are all Optional so an unreachable host still serializes cleanly.
|
||||
agent_status: Optional[str] = None
|
||||
current_sha: Optional[str] = None
|
||||
previous_sha: Optional[str] = None
|
||||
releases: list[dict[str, Any]] = PydanticField(default_factory=list)
|
||||
detail: Optional[str] = None # populated when unreachable
|
||||
|
||||
|
||||
class HostReleasesResponse(BaseModel):
|
||||
hosts: list[HostReleaseInfo]
|
||||
|
||||
|
||||
class PushUpdateRequest(BaseModel):
|
||||
host_uuids: Optional[list[str]] = PydanticField(
|
||||
default=None,
|
||||
description="Target specific hosts; mutually exclusive with 'all'.",
|
||||
)
|
||||
all: bool = PydanticField(default=False, description="Target every non-decommissioned host with an updater bundle.")
|
||||
include_self: bool = PydanticField(
|
||||
default=False,
|
||||
description="After a successful /update, also push /update-self to upgrade the updater itself.",
|
||||
)
|
||||
exclude: list[str] = PydanticField(
|
||||
default_factory=list,
|
||||
description="Additional tarball exclude globs (on top of the built-in defaults).",
|
||||
)
|
||||
|
||||
|
||||
class PushUpdateResult(BaseModel):
|
||||
host_uuid: str
|
||||
host_name: str
|
||||
# updated = /update 200. rolled-back = /update 409 (auto-recovered).
|
||||
# failed = transport error or non-200/409 response. self-updated = /update-self succeeded.
|
||||
status: Literal["updated", "rolled-back", "failed", "self-updated", "self-failed"]
|
||||
http_status: Optional[int] = None
|
||||
sha: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
stderr: Optional[str] = None
|
||||
|
||||
|
||||
class PushUpdateResponse(BaseModel):
|
||||
sha: str
|
||||
tarball_bytes: int
|
||||
results: list[PushUpdateResult]
|
||||
|
||||
|
||||
class RollbackRequest(BaseModel):
|
||||
host_uuid: str = PydanticField(..., description="Host to roll back to its previous release slot.")
|
||||
|
||||
|
||||
class RollbackResponse(BaseModel):
|
||||
host_uuid: str
|
||||
host_name: str
|
||||
status: Literal["rolled-back", "failed"]
|
||||
http_status: Optional[int] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
Reference in New Issue
Block a user