Phase 8: back up & restore remote (agent) stacks (0.8.0)
- Agent: GET /agent/stacks/{id}/backup + POST /agent/stacks/restore (reuse
backup_service). backup_service gains backup_basename/backup_filename helpers.
- Main proxy streams agent <-> main <-> destination (creds stay central):
agent_service download_to_file/upload_file; routers/agents.py backup download,
backup/push, restore upload, restore-from.
- Schedules: BackupSchedule.agent_id; schedule_service downloads from the agent
when set; per-host filename prefix isolates retention across hosts.
- Frontend: agents api backup/restore; BackupButton/RestoreButton agent-aware
(Backup on remote stack detail, Restore per host section); schedule form host
selector (local or an online agent) + host shown on schedule rows.
Rough-verified (per request): py_compile, frontend tsc build, image imports
(main 99 / agent 16 routes). Full live agent round-trip to be tested post-deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
84ef3df59e
commit
5cd55382ed
@@ -101,6 +101,73 @@ async def call(
|
||||
return None
|
||||
|
||||
|
||||
def _handle_status(session: Session, agent: Agent, status_code: int, body_text: str = "") -> None:
|
||||
"""Update agent status from a response code; raise AgentError on failure."""
|
||||
if status_code in (401, 403):
|
||||
_mark(session, agent, "unauthorized")
|
||||
raise AgentError(status_code, "agent_unauthorized", "Invalid agent token")
|
||||
_mark(session, agent, "online")
|
||||
if status_code >= 400:
|
||||
raise AgentError(status_code, "agent_error", body_text[:500])
|
||||
|
||||
|
||||
async def download_to_file(
|
||||
session: Session,
|
||||
agent: Agent,
|
||||
path: str,
|
||||
dest_path: str,
|
||||
*,
|
||||
params: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""Stream a GET from the agent into ``dest_path``."""
|
||||
url = agent.url.rstrip("/") + path
|
||||
headers = {"Authorization": f"Bearer {agent.token}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
async with client.stream("GET", url, headers=headers, params=params, timeout=None) as resp:
|
||||
if resp.status_code >= 400:
|
||||
text = (await resp.aread()).decode("utf-8", "replace")
|
||||
_handle_status(session, agent, resp.status_code, text)
|
||||
_handle_status(session, agent, resp.status_code)
|
||||
with open(dest_path, "wb") as fh:
|
||||
async for chunk in resp.aiter_bytes(1024 * 256):
|
||||
fh.write(chunk)
|
||||
except httpx.HTTPError as exc:
|
||||
_mark(session, agent, "offline")
|
||||
raise AgentError(502, "agent_unreachable", str(exc)) from exc
|
||||
|
||||
|
||||
async def upload_file(
|
||||
session: Session,
|
||||
agent: Agent,
|
||||
path: str,
|
||||
file_path: str,
|
||||
filename: str,
|
||||
data: dict,
|
||||
) -> Any:
|
||||
"""Stream a multipart POST (file + form fields) to the agent, returning JSON."""
|
||||
url = agent.url.rstrip("/") + path
|
||||
headers = {"Authorization": f"Bearer {agent.token}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
with open(file_path, "rb") as fh:
|
||||
files = {"file": (filename, fh, "application/gzip")}
|
||||
resp = await client.post(url, headers=headers, files=files, data=data, timeout=None)
|
||||
except httpx.HTTPError as exc:
|
||||
_mark(session, agent, "offline")
|
||||
raise AgentError(502, "agent_unreachable", str(exc)) from exc
|
||||
|
||||
detail = ""
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
body = resp.json()
|
||||
detail = body.get("detail") if isinstance(body, dict) else str(body)
|
||||
except ValueError:
|
||||
detail = resp.text[:500]
|
||||
_handle_status(session, agent, resp.status_code, str(detail))
|
||||
return resp.json() if resp.content else None
|
||||
|
||||
|
||||
async def ping(session: Session, agent: Agent) -> dict:
|
||||
"""Health-check an agent and refresh its status + hostname. Never raises."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user