merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
186
decnet/updater/app.py
Normal file
186
decnet/updater/app.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Updater FastAPI app — mTLS-protected endpoints for self-update.
|
||||
|
||||
Mirrors the shape of ``decnet/agent/app.py``: bare FastAPI, docs disabled,
|
||||
handlers delegate to ``decnet.updater.executor``.
|
||||
|
||||
Mounted by uvicorn via ``decnet.updater.server`` with ``--ssl-cert-reqs 2``;
|
||||
the CN on the peer cert tells us which endpoints are legal (``updater@*``
|
||||
only — agent certs are rejected).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os as _os
|
||||
import pathlib
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from decnet.bus.factory import get_bus
|
||||
from decnet.bus.publish import run_health_heartbeat
|
||||
from decnet.logging import get_logger
|
||||
from decnet.swarm import pki
|
||||
from decnet.updater import executor as _exec
|
||||
|
||||
log = get_logger("updater.app")
|
||||
|
||||
|
||||
_bus_heartbeat_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
# Host-local bus heartbeat (system.updater.health). Lets the agent
|
||||
# and dashboard tell "updater's up" without hitting the HTTPS port.
|
||||
# Bus-disabled path is a no-op loop; the updater serves requests
|
||||
# either way.
|
||||
bus = None
|
||||
try:
|
||||
bus = get_bus(client_name="updater")
|
||||
await bus.connect()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("updater: bus unavailable, skipping heartbeat: %s", exc)
|
||||
bus = None
|
||||
|
||||
global _bus_heartbeat_task
|
||||
_bus_heartbeat_task = asyncio.create_task(
|
||||
run_health_heartbeat(bus, "updater"),
|
||||
name="updater-bus-heartbeat",
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _bus_heartbeat_task is not None:
|
||||
_bus_heartbeat_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await _bus_heartbeat_task
|
||||
_bus_heartbeat_task = None
|
||||
if bus is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await bus.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="DECNET Self-Updater",
|
||||
version="0.1.0",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
|
||||
class _Config:
|
||||
install_dir: pathlib.Path = pathlib.Path(
|
||||
_os.environ.get("DECNET_UPDATER_INSTALL_DIR") or str(_exec.DEFAULT_INSTALL_DIR)
|
||||
)
|
||||
updater_install_dir: pathlib.Path = pathlib.Path(
|
||||
_os.environ.get("DECNET_UPDATER_UPDATER_DIR")
|
||||
or str(_exec.DEFAULT_INSTALL_DIR / "updater")
|
||||
)
|
||||
agent_dir: pathlib.Path = pathlib.Path(
|
||||
_os.environ.get("DECNET_UPDATER_AGENT_DIR") or str(pki.DEFAULT_AGENT_DIR)
|
||||
)
|
||||
|
||||
|
||||
def configure(
|
||||
install_dir: pathlib.Path,
|
||||
updater_install_dir: pathlib.Path,
|
||||
agent_dir: pathlib.Path,
|
||||
) -> None:
|
||||
"""Inject paths from the server launcher; must be called before serving."""
|
||||
_Config.install_dir = install_dir
|
||||
_Config.updater_install_dir = updater_install_dir
|
||||
_Config.agent_dir = agent_dir
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- schemas
|
||||
|
||||
class RollbackResult(BaseModel):
|
||||
status: str
|
||||
release: dict
|
||||
probe: str
|
||||
|
||||
|
||||
class ReleasesResponse(BaseModel):
|
||||
releases: list[dict]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- routes
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"role": "updater",
|
||||
"releases": [r.to_dict() for r in _exec.list_releases(_Config.install_dir)],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/releases")
|
||||
async def releases() -> dict:
|
||||
return {"releases": [r.to_dict() for r in _exec.list_releases(_Config.install_dir)]}
|
||||
|
||||
|
||||
@app.post("/update")
|
||||
async def update(
|
||||
tarball: UploadFile = File(..., description="tar.gz of the working tree"),
|
||||
sha: str = Form("", description="git SHA of the tree for provenance"),
|
||||
sha256: str = Form("", description="hex SHA-256 of the tarball bytes; verified before extract"),
|
||||
) -> dict:
|
||||
body = await tarball.read()
|
||||
try:
|
||||
return _exec.run_update(
|
||||
body, sha=sha or None,
|
||||
install_dir=_Config.install_dir, agent_dir=_Config.agent_dir,
|
||||
expected_sha256=sha256 or None,
|
||||
)
|
||||
except _exec.UpdateError as exc:
|
||||
status = 409 if exc.rolled_back else 500
|
||||
raise HTTPException(
|
||||
status_code=status,
|
||||
detail={"error": str(exc), "stderr": exc.stderr, "rolled_back": exc.rolled_back},
|
||||
) from exc
|
||||
|
||||
|
||||
@app.post("/update-self")
|
||||
async def update_self(
|
||||
tarball: UploadFile = File(...),
|
||||
sha: str = Form(""),
|
||||
sha256: str = Form("", description="hex SHA-256 of the tarball bytes; verified before extract"),
|
||||
confirm_self: str = Form("", description="Must be 'true' to proceed"),
|
||||
) -> dict:
|
||||
if confirm_self.lower() != "true":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="self-update requires confirm_self=true (no auto-rollback)",
|
||||
)
|
||||
body = await tarball.read()
|
||||
try:
|
||||
return _exec.run_update_self(
|
||||
body, sha=sha or None,
|
||||
updater_install_dir=_Config.updater_install_dir,
|
||||
expected_sha256=sha256 or None,
|
||||
)
|
||||
except _exec.UpdateError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": str(exc), "stderr": exc.stderr},
|
||||
) from exc
|
||||
|
||||
|
||||
@app.post("/rollback")
|
||||
async def rollback() -> dict:
|
||||
try:
|
||||
return _exec.run_rollback(
|
||||
install_dir=_Config.install_dir, agent_dir=_Config.agent_dir,
|
||||
)
|
||||
except _exec.UpdateError as exc:
|
||||
status = 404 if "no previous" in str(exc) else 500
|
||||
raise HTTPException(
|
||||
status_code=status,
|
||||
detail={"error": str(exc), "stderr": exc.stderr},
|
||||
) from exc
|
||||
Reference in New Issue
Block a user