Files
joulenap/backend/app/api/status.py
T
Catubba 0ad459e14c fix(backend): resolve the nine findings of the backend-block review
The three silent ones first. A 0.9 config that fails to convert used to boot an
empty config that looks exactly like a fresh install: the reason now reaches the
UI (GET /api/status.config_error), the activity log and an ERROR line, and the
.bak parachute is written on the failure branch too, so a later save from the
Advanced tab cannot destroy the original.

"PBS left powered on" was wrong in both directions -- every successful run
against an always-on PBS warned, and a sync route that left its target awake did
not. lease.release() returned one False for four situations, only two of which
cost power; it now returns a ReleaseOutcome that names the reason, which becomes
both the POWEROFF step's detail and RunContext.left_on. The interrupted-run path
keeps a step-derived rule, now paired per device and filtered by managed_power.

422 bodies echoed the whole config, secrets included: a config-level validator
raises at loc=(), so pydantic attached every token, the secret key, the password
hash, the SMTP and bot tokens as the error's input. One helper with
include_input=False now serves all three config-shaped 422 sites.

Also: a redaction placeholder with nothing to resolve against is rejected instead
of silently clearing the credential (a renamed device id, or a create from a
copied body); the ad-hoc "Run verify" asks for outdated_after=0, since None meant
"only never-verified" and skipped exactly the snapshots the button exists for;
the manual power-off holds the single-run lock so it cannot cut a vzdump that
started in the check-then-act gap; _current_run_id is cleared when a run ends, so
a stop landing between two runs cannot hit the wrong one; and the pre-migration
.bak is chmod 0600 like every other secret-bearing file.

Tests: 617 passed, 2 skipped. Every finding was reproduced against the real code
before the fix, and each new test confirmed failing on the pre-fix code.
2026-08-03 00:56:40 +02:00

164 lines
5.1 KiB
Python

"""GET /api/status — everything the homepage polls: the state pill, the queue, the
upcoming runs, and one entry per device for the topology.
Describes *Joulenap's* state, not a PBS's: with several backup servers "on/off" is no
longer a single fact, so the pill says what the app is doing and the per-device rows carry
the rest.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from .. import config as config_mod
from ..core.config_store import ConfigStore
from ..db import get_session
from . import _probe
from .deps import (
JobService,
Scheduler,
get_config_store,
get_job_service,
get_scheduler,
require_auth,
)
from .schemas import RunSummary
router = APIRouter(dependencies=[Depends(require_auth)], tags=["status"])
class DatastoreInfo(BaseModel):
used: int # bytes
total: int
used_pct: float
class LoadInfo(BaseModel):
cpu: int # percent, 0-100
mem: int # percent, 0-100
uptime: int # seconds since the PBS booted
class PveState(BaseModel):
id: str
online: bool
class PbsState(BaseModel):
id: str
online: bool
managed_power: bool
#: How many runs currently hold this box's power lease. > 0 means the ⏻ button must be
#: disabled: a run is using it.
holders: int
datastore: DatastoreInfo | None = None
load: LoadInfo | None = None
class RunningRun(BaseModel):
run_id: int
kind: str
started_at: datetime
route_id: str | None = None
route_name: str | None = None
class QueuedRunInfo(BaseModel):
#: The queue key: a route id, or ``pbs:<id>:gc`` for an ad-hoc maintenance run.
key: str
route_id: str | None = None
pbs_id: str | None = None
class NextRun(BaseModel):
route_id: str
route_name: str
at: datetime
class StatusResponse(BaseModel):
#: The header pill: "running" (a run is in flight), "paused" (kill-switch off) or
#: "idle". Deliberately a small closed set — the frontend localizes it, never shows it.
state: str
scheduler_enabled: bool
running: RunningRun | None = None
queued: list[QueuedRunInfo] = []
#: Every armed route's next fire, soonest first.
next_runs: list[NextRun] = []
pves: list[PveState] = []
pbss: list[PbsState] = []
last_run: RunSummary | None = None
#: Set when the 0.9 -> 1.0 config migration was refused at startup: the config in use
#: has no devices and no routes, so the UI must say why instead of looking like a fresh
#: install. Rendered as a persistent banner under the header.
config_error: str | None = None
@router.get("/status", response_model=StatusResponse)
def get_status(
store: ConfigStore = Depends(get_config_store),
scheduler: Scheduler = Depends(get_scheduler),
job_service: JobService = Depends(get_job_service),
session: Session = Depends(get_session),
) -> StatusResponse:
config = store.config
running = _probe.running_run(session)
last = _probe.latest_finished_run(session)
pbs_probes = _probe.probe_pbss(config, job_service.deps.connect_pbs)
pve_online = _probe.probe_pves(config)
names = {r.id: (r.name or r.id) for r in config.routes}
if running is not None:
state = "running"
elif not config.app.scheduler_enabled:
state = "paused"
else:
state = "idle"
return StatusResponse(
state=state,
scheduler_enabled=config.app.scheduler_enabled,
running=(
RunningRun(
run_id=running.id,
kind=running.kind,
started_at=running.started_at,
route_id=running.route_id,
route_name=running.route_name,
)
if running
else None
),
queued=[
QueuedRunInfo(key=item.key, route_id=item.route_id, pbs_id=item.pbs_id)
for item in job_service.pending()
],
next_runs=[
NextRun(route_id=route_id, route_name=names.get(route_id, route_id), at=when)
for route_id, when in scheduler.next_runs()
],
pves=[PveState(id=pve.id, online=pve_online.get(pve.id, False)) for pve in config.pves],
pbss=[_pbs_state(pbs, pbs_probes, job_service) for pbs in config.pbss],
last_run=RunSummary.of(last) if last else None,
# Read live, not captured at import: a reload after the user fixes the file clears it.
config_error=config_mod.MIGRATION_ERROR,
)
def _pbs_state(pbs, probes: dict[str, _probe.PbsProbe], job_service: JobService) -> PbsState:
probe = probes.get(pbs.id)
ds = probe.datastore if probe else None
load = probe.load if probe else None
return PbsState(
id=pbs.id,
online=bool(probe and probe.online),
managed_power=pbs.managed_power,
holders=job_service.lease.state(pbs.id).holders,
datastore=DatastoreInfo(used=ds.used, total=ds.total, used_pct=ds.used_pct) if ds else None,
load=LoadInfo(cpu=load.cpu, mem=load.mem, uptime=load.uptime) if load else None,
)