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.
This commit is contained in:
Catubba
2026-08-03 00:56:40 +02:00
parent 5dc8c5719a
commit 0ad459e14c
19 changed files with 578 additions and 142 deletions
+21 -3
View File
@@ -21,6 +21,25 @@ from ..core.config_store import ConfigStore
from ..core.scheduler import Scheduler, validate_cron
def validation_error(exc: ValidationError) -> HTTPException:
"""A 422 carrying pydantic's error list — **without** the values that failed.
``include_input=False`` is the whole point: a ``model_validator(mode="after")`` on
``Config`` raises at ``loc=()``, so pydantic attaches the entire validated config as the
error's ``input``. Serialised, that ships every API token, ``app.secret_key``, the
password hash, the SMTP password and the bot token to the browser's network tab and any
proxy log in between. The config-level cross-checks (a route pointing at a device that
doesn't exist, an External route onto an unmanaged PBS) are ordinary user mistakes, so
this is a body users really do see. ``include_url`` only drops a docs link.
Every config-shaped 422 goes through here so a new endpoint inherits the safe default.
"""
return HTTPException(
status_code=422, # the literal avoids the deprecated HTTP_422_UNPROCESSABLE_ENTITY
detail=jsonable_encoder(exc.errors(include_input=False, include_url=False)),
)
def save_section(
store: ConfigStore, scheduler: Scheduler, section: str, value: list[dict[str, Any]]
) -> Config:
@@ -34,9 +53,8 @@ def save_section(
try:
new_config = Config.model_validate(raw)
except ValidationError as exc:
# 422 mirrors FastAPI's own body-validation responses (the literal avoids the
# deprecated HTTP_422_UNPROCESSABLE_ENTITY constant name).
raise HTTPException(status_code=422, detail=jsonable_encoder(exc.errors())) from exc
# 422 mirrors FastAPI's own body-validation responses.
raise validation_error(exc) from exc
check_route_crons(new_config, store.config)
try:
store.replace(new_config)
+3 -5
View File
@@ -11,7 +11,6 @@ from typing import Any
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ValidationError
from ..config import (
@@ -25,7 +24,7 @@ from ..config import (
from ..connectors.errors import WolError
from ..connectors.wol import normalize_mac
from ..core.config_store import ConfigStore
from ._config_edit import check_route_crons
from ._config_edit import check_route_crons, validation_error
from .deps import Scheduler, get_config_store, get_scheduler, require_auth
router = APIRouter(dependencies=[Depends(require_auth)], tags=["config"])
@@ -66,9 +65,8 @@ def _apply_config(
try:
new_config = Config.model_validate(merged)
except ValidationError as exc:
# 422 to mirror FastAPI's own body-validation responses (literal avoids the
# deprecated HTTP_422_UNPROCESSABLE_ENTITY constant name).
raise HTTPException(status_code=422, detail=jsonable_encoder(exc.errors())) from exc
# 422 to mirror FastAPI's own body-validation responses.
raise validation_error(exc) from exc
# Reject a newly-set unparseable route cron before it can be persisted (BE-B1): the
# route would silently never fire, which is the failure mode hardest to notice.
+39 -18
View File
@@ -17,15 +17,14 @@ from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ValidationError
from ..config import PbsDevice, PveDevice, redact, restore_secrets_from
from ..config import PbsDevice, PveDevice, RedactionError, redact, restore_secrets_from
from ..connectors.errors import ConnectorError, WolError
from ..core.config_store import ConfigStore
from ..db.models import RunTrigger
from ..jobs import AlreadyRunningError
from ._config_edit import save_section
from ._config_edit import save_section, validation_error
from .deps import (
JobService,
Scheduler,
@@ -101,7 +100,12 @@ def create_device(
scheduler: Scheduler = Depends(get_scheduler),
) -> dict[str, Any]:
section, model = _kind(kind)
device = _validate(model, body)
# Against an empty stored mapping: a *new* device has no secrets to restore, so any
# ***REDACTED*** in the body — a "duplicate this device" action, or a body copy-pasted
# from GET /api/devices — fails loudly here instead of being stored as the literal
# placeholder, which GET then re-masks so the corruption is invisible until a
# connection test fails with a 502 that gives no hint why.
device = _validate(model, _resolve(body, {}))
if any(d.id == device.id for d in getattr(store.config, section)):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=f"Device '{device.id}' already exists"
@@ -124,7 +128,7 @@ def update_device(
index = _find(store, section, device_id)
# Resolve ***REDACTED*** against *this* device's stored values: the client only ever
# echoes back a secret it didn't change.
device = _validate(model, restore_secrets_from(body, _dump(store, section)[index]))
device = _validate(model, _resolve(body, _dump(store, section)[index]))
devices = _dump(store, section)
devices[index] = device.model_dump(mode="python")
save_section(store, scheduler, section, devices)
@@ -238,19 +242,27 @@ def power(
) from exc
return PowerResult(ok=True)
# Power-off. The lease is the authority on "is anything using this box": a run holding
# it would be cut off mid-vzdump.
# ponytail: a run could still take the lease in the gap between this check and the SSH
# command. Harmless in practice — the lease probes the box, finds it down and wakes it
# again — and closing it properly means holding the lease from here, which would let a
# failed HTTP request strand it.
if job_service.lease.state(pbs_id).holders:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A run is using '{pbs_id}'; cannot power it off",
)
# Power-off, under the single-run lock. `exclusive()` exists for exactly this: a run
# holds that lock for its whole life, so while we hold it none can start — and
# `power_off_now` has no idle-wait and no refcount check by design ("a click means
# now"). Without the lock the check is a check-then-act: a scheduled route starts in
# the gap, `_bring_up` probes, finds the box still up, skips the Wake-on-LAN, vzdump
# begins, and the SSH poweroff lands mid-backup. The lease's own self-healing can't
# help there — the probe has already happened.
# ponytail: the lock is held for the seconds an SSH connect takes, so a scheduled run
# firing in that window waits its turn instead of being rejected. Fine for a button.
try:
job_service.lease.power_off_now(device)
with job_service.exclusive():
# Belt and braces: a lease is only ever taken inside the lock we now hold, so
# this cannot fire in production — but it names the box when it does.
if job_service.lease.state(pbs_id).holders:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A run is using '{pbs_id}'; cannot power it off",
)
job_service.lease.power_off_now(device)
except AlreadyRunningError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ConnectorError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return PowerResult(ok=True)
@@ -304,10 +316,19 @@ def run_maintenance(
# --- helpers -----------------------------------------------------------------
def _resolve(body: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
"""Fill in the ***REDACTED*** placeholders the client echoed back, 422 if one can't be
resolved. ``stored`` is the device being edited, or ``{}`` for a create."""
try:
return restore_secrets_from(body, stored)
except RedactionError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _validate(model: type[PveDevice] | type[PbsDevice], body: dict[str, Any]):
"""Validate a device body by hand rather than as a typed parameter: the handlers are
shared between the two kinds, so which model applies is only known at call time."""
try:
return model.model_validate(body)
except ValidationError as exc:
raise HTTPException(status_code=422, detail=jsonable_encoder(exc.errors())) from exc
raise validation_error(exc) from exc
+7
View File
@@ -14,6 +14,7 @@ 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
@@ -91,6 +92,10 @@ class StatusResponse(BaseModel):
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)
@@ -139,6 +144,8 @@ def get_status(
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,
)
+52 -12
View File
@@ -38,6 +38,13 @@ SECRET_KEYS: frozenset[str] = frozenset(
REDACTED = "***REDACTED***"
#: Why the 0.9 -> 1.0 migration was refused on the last :func:`load_config`, or ``None``.
#: A failed migration boots on a config with no devices and no routes, which looks exactly
#: like a fresh install — so the reason has to outlive the log line: ``GET /api/status``
#: reports it to the UI and startup writes it to the activity log. Process-wide because the
#: fact is process-wide; every load clears it and decides again.
MIGRATION_ERROR: str | None = None
class _Base(BaseModel):
# Reject unknown keys so typos in config.yaml surface as clear validation errors.
@@ -460,6 +467,8 @@ def load_config(path: Path | None = None) -> Config:
raise FileNotFoundError(
f"Config file not found at {p}. Copy config.example.yaml to config.yaml."
)
global MIGRATION_ERROR
MIGRATION_ERROR = None # re-decided below; a reload after a fix must clear it
with p.open("r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
if not isinstance(raw, dict):
@@ -479,19 +488,28 @@ def _migrate_0_9(raw: dict[str, Any], path: Path) -> Config | None:
"""Convert a 0.9 config to the route model, back it up and save it.
Returns ``None`` — meaning "carry on with the config as it is on disk" — if anything at
all goes wrong. A failed migration must never stop the app from booting (BE-B1), and it
costs nothing to defer: the 0.9 sections are still what the app reads, and the next
start will try again.
all goes wrong. A failed migration must never stop the app from booting (BE-B1).
But it is not a harmless deferral any more: in 0.9 the app kept running on the old
sections, while in 1.0 nothing reads them, so the fallback boots a config with no
devices and no routes — indistinguishable from a fresh install, with every schedule
silently gone. Hence :data:`MIGRATION_ERROR` (surfaced by ``GET /api/status`` and the
activity log) and the ``.bak``: the user can still rewrite ``config.yaml`` from the
Advanced tab while running empty, and without the copy that would destroy the only
0.9 config they have.
"""
try:
cfg = Config.model_validate(config_migrate.migrate(raw))
except Exception as exc: # noqa: BLE001 — any failure here degrades to "don't migrate"
log.warning(
"config: could not convert %s to the 1.0 route model (%s); starting on the "
"existing config and leaving it untouched",
path,
exc,
global MIGRATION_ERROR
MIGRATION_ERROR = (
f"Could not convert {path} to the 1.0 route model: {exc}. Joulenap started "
"with no devices and no routes — nothing is scheduled and no backup will run "
"until this is fixed. The file is untouched; a copy is at "
f"{path.name}{config_migrate.BACKUP_SUFFIX}."
)
log.error("config: %s", MIGRATION_ERROR)
config_migrate.write_backup(path)
return None
config_migrate.write_backup(path)
try:
@@ -661,7 +679,7 @@ def _restore_in_place(node: Any, current: Any) -> Any:
for key, value in node.items():
cur = current.get(key) if isinstance(current, dict) else None
if key in SECRET_KEYS:
node[key] = _unmask(value, cur)
node[key] = _unmask(value, cur, key)
else:
_restore_in_place(value, cur)
elif isinstance(node, list):
@@ -687,7 +705,15 @@ def _match(item: Any, current: Any, index: int) -> Any:
return current[index] if index < len(current) else None
def _unmask(value: Any, current: Any) -> Any:
def _unmask(value: Any, current: Any, key: str = "") -> Any:
"""Resolve one secret field: ``REDACTED`` -> the stored value, anything else verbatim.
An unresolvable placeholder is an **error**, never an empty string. ``current`` is None
when the incoming item has no stored counterpart — a device whose ``id`` was renamed in
the YAML editor, or one being created — and the client is then echoing back a mask over
a secret we do not have. Turning that into ``""`` validates fine, returns 200, and
leaves the credential gone with nothing on screen to suggest it.
"""
if isinstance(value, list):
# List secrets (custom_urls) are write-only and all-or-nothing to avoid the
# index-positional corruption of the old per-entry masking:
@@ -701,7 +727,9 @@ def _unmask(value: Any, current: Any) -> Any:
if not value:
return []
if all(v == REDACTED for v in value):
return list(current) if isinstance(current, list) else []
if not isinstance(current, list):
raise _unresolvable(key)
return list(current)
if any(v == REDACTED for v in value):
raise RedactionError(
"custom_urls must be sent in full (all real values) or left unchanged "
@@ -709,5 +737,17 @@ def _unmask(value: Any, current: Any) -> Any:
)
return value
if value == REDACTED:
return current if current is not None else ""
if current is None:
raise _unresolvable(key)
return current
return value
def _unresolvable(key: str) -> RedactionError:
field = f"'{key}'" if key else "a secret"
return RedactionError(
f"{field} was sent as {REDACTED} but there is no stored value to restore it from. "
"This happens when an entry's id is renamed (the old entry's secrets don't follow "
"the new id) or when a new one is created from a copy. Send the real value, or an "
"empty string to clear it."
)
+13 -2
View File
@@ -87,16 +87,27 @@ def migrate(raw: dict[str, Any]) -> dict[str, Any]:
def write_backup(path: Path) -> None:
"""Copy the pre-migration config beside itself as the rollback parachute.
Written whether the migration succeeded or was refused: a refused one leaves the app
running on an empty config, and the next save from the Advanced tab would overwrite
the 0.9 original with it.
An existing backup is never overwritten: the first migration wins, so re-running this
can't lose the true 0.9 original. A backup that can't be written (read-only mount) is
logged, not fatal the migration only *adds* sections, so the old ones stay as the
in-file fallback either way.
logged, not fatal the same mount stops the migrated config from being saved too, so
the file on disk stays 0.9 either way.
"""
bak = path.with_name(path.name + BACKUP_SUFFIX)
if bak.exists():
return
# Local import: config.py imports this module, so a top-level one would be circular.
from .config import restrict_secret_file
try:
shutil.copyfile(path, bak)
# copyfile copies contents, not mode bits, so the copy would land 0644 under a
# container's default umask — holding every API token, secret_key, password hash,
# SMTP password and bot token, indefinitely (an existing .bak is never overwritten).
restrict_secret_file(bak)
log.info("config: saved a pre-migration copy at %s", bak)
except OSError as exc:
log.warning("config: could not write %s (%s) — migrating anyway", bak, exc)
+37 -10
View File
@@ -18,6 +18,7 @@ import ssl
import threading
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from ..config import PbsDevice
from ..connectors import net, tls
@@ -32,6 +33,21 @@ class PbsUnreachableError(RuntimeError):
"""The PBS never answered: it could not be woken, or it is an unmanaged box that is off."""
class ReleaseOutcome(StrEnum):
"""What became of a box when a run dropped its lease.
Only :attr:`LEFT_ON` means "still burning power, and nothing is going to fix that"
the others are boxes Joulenap either put to sleep, never powers, or will power off at
the end of the run that still holds them. The value doubles as the POWEROFF step's
detail in the run timeline.
"""
POWERED_OFF = "powered off"
STILL_NEEDED = "left on: still needed by another run"
UNMANAGED = "left on: Joulenap does not manage this box's power"
LEFT_ON = "left powered on"
# --- device-shaped connector calls -------------------------------------------
# jobs/deps.py has the same four operations bound to the 0.9 single-PBS ``Config``. These
# take the device a route points at; the Config-shaped originals die with the old cycle.
@@ -180,37 +196,48 @@ class PowerLease:
held.holders += 1
return was_awake
def release(self, pbs: PbsDevice, *, power_off: bool = True) -> bool:
"""Drop this run's hold and power the box down if it is now safe to. Returns whether
it actually powered off.
def release(self, pbs: PbsDevice, *, power_off: bool = True) -> ReleaseOutcome:
"""Drop this run's hold and power the box down if it is now safe to. Returns *why*
the box ended up in the state it did.
``power_off`` is the caller's policy (a failed run leaves the PBS on for inspection,
a manual run honours the "power off when finished" toggle). The lease adds the
conditions the caller cannot see: no other holder, and no *queued* route that still
needs this box.
Four outcomes rather than a bool because only two of them cost the user power, and
the lease is the only place that can tell them apart a caller re-deriving "was
that a real 'left on'?" would have to duplicate every condition below.
"""
with self._lock:
held = self._state.get(pbs.id)
if held is None or not held.holders:
log.warning("Release of an unheld lease on PBS %s — ignoring", pbs.id)
return False
# A bug, not a policy: say "left on" so it is at least visible.
return ReleaseOutcome.LEFT_ON
held.holders -= 1
remaining = held.holders
if not remaining:
del self._state[pbs.id]
# Order matters for the *reason*, not for the action — every branch below stops the
# power-off equally. The caller's policy is checked last on purpose: "this run
# doesn't want to power off" is only the real explanation once the box is one we
# could have powered off and nobody else needs it.
if remaining:
log.info("PBS %s still held by %d run(s); leaving it on", pbs.id, remaining)
return False
if not power_off:
return False
return ReleaseOutcome.STILL_NEEDED
if not pbs.managed_power:
# An always-on / cloud-hosted PBS: Joulenap never touches its power.
return False
return ReleaseOutcome.UNMANAGED
if pbs.id in self._pending():
log.info("PBS %s is needed by a queued route; leaving it on", pbs.id)
return False
return self._power_off(pbs)
return ReleaseOutcome.STILL_NEEDED
if not power_off:
return ReleaseOutcome.LEFT_ON
# False here is a busy box we declined to cut off, or a poweroff that errored — both
# leave it burning power.
return ReleaseOutcome.POWERED_OFF if self._power_off(pbs) else ReleaseOutcome.LEFT_ON
# --- internals -----------------------------------------------------------
+5 -3
View File
@@ -265,9 +265,11 @@ def run_pbs_maintenance(
if action == "gc":
_route_gc_step(pbs, recorder, deps)
elif action == "verify":
# Everything, not just the recently-changed: an ad-hoc verify is a deliberate
# "check this box now", and reverify_days is a per-route pacing knob.
_route_verify_step(pbs, recorder, deps, outdated_after=None)
# 0 means "everything" — see _route_verify_step. An ad-hoc verify is a deliberate
# "check this box now" after a restore or a disk scare, so it must re-read the
# older snapshots too; None would mean "only never-verified", i.e. skip exactly
# the ones the user is worried about. reverify_days is the per-route pacing knob.
_route_verify_step(pbs, recorder, deps, outdated_after=0)
else:
raise CycleAbort(f"unsupported maintenance action '{action}'")
return _route_read_datastore(pbs, recorder, deps)
+25 -11
View File
@@ -31,7 +31,7 @@ from ..db.models import LogLevel, RunKind, RunStatus, RunTrigger, StepName, Step
from ..db.prune import PruneResult, prune_history
from ..notify.messages import RunContext
from .deps import CycleDeps
from .lease import LeaseDeps, PbsUnreachableError, PowerLease
from .lease import LeaseDeps, PbsUnreachableError, PowerLease, ReleaseOutcome
from .recorder import RunRecorder
from .route_cycle import RUN_KINDS, run_pbs_maintenance, run_route
@@ -276,6 +276,11 @@ class JobService:
finally:
with self._state_lock:
self._current = None
# Cleared with it, or a stop aimed at the run that just ended would pass
# cancel()'s guard and land on the next one (or be swallowed): _start
# sets the new id only after the DB insert, leaving a window where the
# stale id still matches while another run holds the lock.
self._current_run_id = None
def _execute(self, item: QueuedRun) -> None:
"""Take the run's power leases, do its job, release them, then notify.
@@ -314,9 +319,10 @@ class JobService:
# The cycle sets the run's final status itself and hands back what to say
# about it (None = cancelled, say nothing).
ctx = item.job(config, subject, recorder, self.deps)
self._release_all(item, held, recorder)
left_on = self._release_all(item, held, recorder)
held = []
if ctx is not None:
ctx.left_on = left_on
self._notify(ctx, recorder)
finally:
# Only reached with leases still held when the job raised out of the block.
@@ -364,30 +370,38 @@ class JobService:
def _release_all(
self, item: QueuedRun, devices: list[PbsDevice], recorder: RunRecorder | None
) -> None:
"""Drop every lease this run holds, recording each power-off decision.
) -> list[str]:
"""Drop every lease this run holds, recording each power-off decision. Returns the
ids of the boxes genuinely left burning power, for the notification's warning.
Devices are released independently: a sync route holds two leases and each box may
have a different answer (another holder, a queued route, unmanaged power).
have a different answer (another holder, a queued route, unmanaged power) which
is why the warning cannot be a single fact about the run.
``recorder=None`` is the crash path the leases must still be dropped, but the run
row is already being finalised, so there is nothing left to record against.
"""
succeeded = recorder is not None and recorder.run.status == RunStatus.SUCCESS
power_off = self._power_off_policy(item, succeeded=succeeded)
multi = len(devices) > 1
left_on: list[str] = []
for device in devices:
if recorder is None:
self.lease.release(device, power_off=power_off)
if self.lease.release(device, power_off=power_off) is ReleaseOutcome.LEFT_ON:
left_on.append(device.id)
continue
with recorder.step(StepName.POWEROFF, label=device.id if multi else None) as step:
if self.lease.release(device, power_off=power_off):
outcome = self.lease.release(device, power_off=power_off)
step.detail = str(outcome)
if outcome is ReleaseOutcome.POWERED_OFF:
continue
# Not a failure: leaving the box on is the *correct* outcome after a failed
# run, for an unmanaged device, or while another route still needs it. The
# notification's "PBS left powered on" warning keys on this step not being
# SUCCESS, so the user still hears about it when it costs them power.
# run, for an unmanaged device, or while another route still needs it — the
# detail says which. Only LEFT_ON costs the user power with nobody left to
# fix it, so only that one earns the notification's warning.
step.status = StepStatus.SKIPPED
step.detail = "left powered on"
if outcome is ReleaseOutcome.LEFT_ON:
left_on.append(device.id)
return left_on
def _notify(self, ctx: RunContext, recorder: RunRecorder) -> None:
"""Send the result notification. A delivery failure is logged, never fatal — the run
+6
View File
@@ -19,6 +19,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from . import __version__
from . import config as config_mod
from .api import api_router
from .api import metrics as metrics_api
from .config import Config
@@ -26,6 +27,7 @@ from .core.config_store import ConfigStore
from .core.ratelimit import LoginRateLimiter
from .core.scheduler import Scheduler
from .db import init_db, session_scope
from .db.models import LogEvent, LogLevel
from .db.startup import sweep_orphaned_runs
from .jobs import JobService
from .notify import NotificationService
@@ -59,6 +61,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
with session_scope() as session:
swept = sweep_orphaned_runs(session)
interrupted_alerts = [build_interrupted_message(store.config, run) for run in swept]
# The config was loaded before the DB existed, so a refused 0.9 migration can only
# reach the activity log from here. Run-less row: it belongs to no cycle.
if config_mod.MIGRATION_ERROR:
session.add(LogEvent(level=LogLevel.ERROR, message=config_mod.MIGRATION_ERROR))
if swept:
log.warning("Marked %d interrupted run(s) as failed at startup", len(swept))
service = JobService(store)
+55 -21
View File
@@ -58,6 +58,11 @@ class RunContext:
guests: GuestSummary | None = None
#: When this route next fires, for the "Next scheduled run" line.
next_at: datetime | None = None
#: PBS ids this run left awake and burning power, with nothing queued to shut them
#: down. Filled in by ``JobService`` after the leases are released: it is the only
#: place that knows *why* a box stayed on, and only some of the reasons cost energy
#: (an always-on box, or one another run still holds, cost nothing).
left_on: list[str] = field(default_factory=list)
# event keys: success | failure | aborted | test
@@ -279,25 +284,52 @@ def _step_is(step: RunStep, name: StepName) -> bool:
return step.name.split(":", 1)[0] == name.value
def _pbs_left_on(run: Run) -> bool:
"""True if the cycle woke a PBS but never powered it back off — so a box is still
burning energy and the user should check it.
def _step_label(step: RunStep) -> str | None:
"""The device a step names (``poweroff:pbs-02`` -> ``pbs-02``), or None when unlabelled
a single-device run doesn't repeat which box it means."""
_, _, label = step.name.partition(":")
return label or None
The rule: a WAIT step succeeded (a PBS actually came up) **and** no POWEROFF step
succeeded. That single condition covers every "left on" case uniformly:
* success but power-off failed / was skipped (PBS busy) POWEROFF present, not SUCCESS;
* failure after the PBS woke (vzdump/GC/verify errored) no POWEROFF step at all;
* abort after wake (preflight free-space, no guests selected) no POWEROFF step.
def _pbs_left_on(config: Config, run: Run) -> bool:
"""True if a PBS came up and nothing ever powered it back off — a box still burning
energy that the user should go and check.
An abort *before* the box came up (wake/wait timeout) leaves the WAIT step non-SUCCESS, so
the PBS is off and this correctly returns False hence why it keys on WAIT, not on the
run status."""
woke = any(_step_is(s, StepName.WAIT) and s.status == StepStatus.SUCCESS for s in run.steps)
powered_off = any(
_step_is(s, StepName.POWEROFF) and s.status == StepStatus.SUCCESS for s in run.steps
)
return woke and not powered_off
Only for a run a restart interrupted: there the run row is all there is, and no
POWEROFF step was ever reached. A run that *finished* knows the answer exactly and
reports it through ``RunContext.left_on``, because "was it left on?" depends on facts
the timeline doesn't carry (whether another queued route still needs the box).
Two things the steps alone get wrong, both introduced by this same release:
* an **unmanaged** box (``managed_power: false``) is never Joulenap's to power down,
so a run against one must not warn hence taking ``config``;
* a run holding **several** leases needs the WAIT and POWEROFF steps paired *per
device*, or one box's successful power-off hides another's that stayed up.
An abort *before* the box came up leaves the WAIT step non-SUCCESS, so the PBS is off
and this correctly returns False hence why it keys on WAIT, not on the run status.
"""
managed = {p.id for p in config.pbss if p.managed_power}
route = next((r for r in config.routes if r.id == run.route_id), None)
# An unlabelled step belongs to the run's only box — a route's target (a sync route
# labels both sides). An ad-hoc GC/verify records neither a label nor a route, so its
# box cannot be named: warn rather than stay silent about a box that may be awake.
default = route.target if route else None
powered_off = {
_step_label(s)
for s in run.steps
if _step_is(s, StepName.POWEROFF) and s.status == StepStatus.SUCCESS
}
for step in run.steps:
if not _step_is(step, StepName.WAIT) or step.status != StepStatus.SUCCESS:
continue
pbs_id = _step_label(step) or default
if pbs_id is not None and pbs_id not in managed:
continue
if _step_label(step) not in powered_off:
return True
return False
#: Route kinds, for the body's ``Route:`` line. Localized because the kind is a user-facing
@@ -368,7 +400,9 @@ def build_run_message(ctx: RunContext) -> tuple[str, str]:
if run.error:
lines.append(f"{labels['error']}: {run.error}")
if _pbs_left_on(run):
# From the service, not from the steps: a finished run knows exactly which boxes it
# left burning power, and only those warrant the warning.
if ctx.left_on:
lines.append(labels["pbs_left_on"])
if next_at is not None:
@@ -432,15 +466,15 @@ def build_interrupted_message(config: Config, run: Run) -> tuple[str, str]:
"""``(title, body)`` for a run that a restart interrupted (swept to FAILURE at startup,
BE-R2), in the configured language.
Adds the "PBS left powered on" warning when the box had actually woken before the crash
(WAIT succeeded, no POWEROFF) the whole point of the alert: a normally-off box that a
crash left awake and burning power."""
Adds the "PBS left powered on" warning when a box Joulenap powers had actually woken
before the crash (WAIT succeeded, no matching POWEROFF) the whole point of the alert:
a normally-off box that a crash left awake and burning power."""
pack = _pack(config.app.language)
labels = pack["_labels"]
lines = [pack["interrupted"]["intro"]]
if run.error:
lines.append(f"{labels['error']}: {run.error}")
if _pbs_left_on(run):
if _pbs_left_on(config, run):
lines.append(labels["pbs_left_on"])
# This alert has no Duration line (the run's own span would span the whole downtime,
# not the work), so the one interval worth reporting is how long the box has been
+9
View File
@@ -12,6 +12,7 @@ import pytest
from fakes import FakeBox, FakePve, UnreachablePve, make_deps
from fastapi.testclient import TestClient
from app import config
from app.config import load_config
from app.connectors.pve import Guest
from app.db import session_scope
@@ -106,6 +107,14 @@ def test_status_lists_the_next_run_of_every_armed_route(app_ctx):
assert times == sorted(times)
def test_status_reports_a_refused_config_migration(app_ctx, monkeypatch):
"""An empty config looks exactly like a fresh install, so the UI has to be told why."""
client, _app = app_ctx
assert client.get("/api/status").json()["config_error"] is None
monkeypatch.setattr(config, "MIGRATION_ERROR", "routes.0.options.bwlimit: bad")
assert client.get("/api/status").json()["config_error"] == "routes.0.options.bwlimit: bad"
def test_status_pill_says_paused_when_the_kill_switch_is_off(app_ctx):
client, app = app_ctx
app.state.config_store.update(lambda c: setattr(c.app, "scheduler_enabled", False))
+62 -1
View File
@@ -161,6 +161,45 @@ def test_delete_404s_on_an_unknown_device(app_ctx):
assert client.delete("/api/devices/pbss/nope").status_code == 404
def test_a_validation_failure_does_not_echo_the_config_back(app_ctx):
"""A config-level cross-check raises at loc=(), so pydantic attaches the *whole*
validated config as the error's ``input`` — every API token, the secret key, the
password hash, the SMTP and bot tokens and the 422 shipped it to the browser."""
client, app = app_ctx
client.post(
"/api/routes",
json={"id": "watch", "kind": "external", "target": "pbs-02", "schedule": {"time": "03:00"}},
)
# An External route watches a box Joulenap wakes, so making that box unmanaged is
# exactly the cross-check that fires.
r = client.put("/api/devices/pbss/pbs-02", json={"id": "pbs-02", "host": "192.0.2.21",
"datastore": "offsite",
"api_token_id": "root@pam!joulenap",
"api_token_secret": REDACTED,
"managed_power": False})
assert r.status_code == 422
assert "managed_power" in r.text # the reason still reaches the user
stored = app.state.config_store.config
for secret in (
stored.app.secret_key,
stored.app.auth.password_hash,
*[d.api_token_secret for d in stored.pves],
*[d.api_token_secret for d in stored.pbss],
):
assert secret and secret not in r.text
def test_create_rejects_the_redaction_placeholder(app_ctx):
"""A body copy-pasted from GET /api/devices carries ***REDACTED*** as the token. Storing
it verbatim is invisible GET re-masks it (it is non-empty) until a connection test
fails with a 502 that gives no hint the stored token is the placeholder text itself."""
client, app = app_ctx
r = client.post("/api/devices/pbss", json={**NEW_PBS, "api_token_secret": REDACTED})
assert r.status_code == 422
assert "api_token_secret" in r.text
assert [d.id for d in app.state.config_store.config.pbss] == ["pbs-01", "pbs-02"]
# --- test ---------------------------------------------------------------------
@@ -228,6 +267,25 @@ def test_power_off_409s_while_a_run_holds_the_box(app_ctx):
assert "pbs-01" in r.json()["detail"]
def test_power_off_409s_while_a_run_holds_the_single_run_lock(app_ctx):
"""The lease check alone was a check-then-act: a scheduled route could start in the gap,
find the box still up (so no wake), and begin vzdump then the SSH poweroff, which has
no idle-wait and no refcount check, cut it off mid-backup. Holding the same lock a run
holds for its whole life is what closes that window."""
client, app = app_ctx
box = FakeBox()
service = _inject(app, box)
service._lock.acquire() # stand in for a run in flight; no lease taken yet
try:
r = client.post("/api/devices/pbss/pbs-01/power", json={"action": "poweroff"})
finally:
service._lock.release()
assert r.status_code == 409
assert "in progress" in r.json()["detail"]
assert box.poweroffs == [] # the SSH command never ran
def test_power_409s_on_an_unmanaged_device(app_ctx):
client, app = app_ctx
app.state.config_store.update(
@@ -296,7 +354,10 @@ def test_verify_queues_a_verify_run(app_ctx):
assert pbs.verify_started is True
# An ad-hoc verify checks everything, rather than pacing itself like a Verify route.
assert pbs.verify_args["outdated_after"] is None
# "Everything" reaches PBS as ignore-verified: 0. The bug was asking for
# outdated_after=None, which means "only never-verified" — skipping precisely the old
# snapshots the user clicked the button about.
assert pbs.verify_args["ignore_verified"] is False
def test_maintenance_works_on_an_always_on_box(app_ctx):
+25
View File
@@ -231,6 +231,31 @@ def test_a_new_device_keeps_the_secret_it_was_sent():
assert out["pbss"][0]["api_token_secret"] == "brand-new"
def test_renaming_a_device_id_rejects_its_unresolvable_placeholder():
"""The Advanced tab shows ``id: pbs-01`` with ``api_token_secret: ***REDACTED***``. Fix a
typo in the id and save: nothing matches by id any more, so the placeholder used to
resolve to "" 200 OK, credential gone, nothing on screen to suggest it."""
cfg = Config()
cfg.pbss = [PbsDevice(id="pbs-01", api_token_secret="first", managed_power=False)]
incoming = {"pbss": [{"id": "pbs01", "api_token_secret": cfgmod.REDACTED}]}
with pytest.raises(cfgmod.RedactionError, match="api_token_secret"):
restore_secrets(incoming, cfg)
def test_a_placeholder_with_nothing_stored_is_rejected():
# Same guard one level up: restore_secrets_from({}) is what a *create* resolves against.
with pytest.raises(cfgmod.RedactionError):
cfgmod.restore_secrets_from({"api_token_secret": cfgmod.REDACTED}, {})
def test_an_empty_string_still_clears_a_secret():
# The escape hatch the rejection points at: "" means clear, and must keep working.
cfg = Config()
cfg.pbss = [PbsDevice(id="pbs-01", api_token_secret="first", managed_power=False)]
out = restore_secrets({"pbss": [{"id": "pbs-01", "api_token_secret": ""}]}, cfg)
assert out["pbss"][0]["api_token_secret"] == ""
def test_session_defaults():
s = Config().app.session
assert s.https_only is False and s.max_age_days == 14
+34 -2
View File
@@ -7,12 +7,14 @@ quietly disappearing from a real user's config.
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
import yaml
from app import config_migrate
from app import config, config_migrate
from app.config import REDACTED, Config, load_config, save_config
FIXTURE = Path(__file__).parent / "fixtures" / "config-0.9.yaml"
@@ -21,6 +23,7 @@ BAK = "config.yaml" + config_migrate.BACKUP_SUFFIX
def _write(tmp_path: Path, **overrides) -> Path:
"""Drop the 0.9 fixture into tmp_path, deep-merging any section overrides."""
tmp_path.mkdir(parents=True, exist_ok=True)
raw = yaml.safe_load(FIXTURE.read_text(encoding="utf-8"))
for section, values in overrides.items():
for key, value in values.items():
@@ -163,7 +166,36 @@ def test_a_config_that_cannot_be_converted_is_left_alone(tmp_path: Path, monkeyp
# dropped at load, so the app boots on defaults rather than failing to start.
assert cfg.routes == []
assert path.read_text(encoding="utf-8") == original
assert not (tmp_path / BAK).exists()
# ...but the parachute still opens: the app is now running on an empty config, and the
# next save from the Advanced tab would overwrite the only 0.9 config the user has.
assert (tmp_path / BAK).read_text(encoding="utf-8") == original
def test_a_refused_migration_is_reported_not_just_logged(tmp_path: Path):
# An empty config is indistinguishable from a fresh install, so the reason has to
# outlive the log line: GET /api/status and the activity log both read this.
path = _write(tmp_path, backup={"bwlimit": -1})
cfg = load_config(path)
assert (cfg.pves, cfg.pbss, cfg.routes) == ([], [], [])
assert config.MIGRATION_ERROR is not None
assert "bwlimit" in config.MIGRATION_ERROR
assert str(path) in config.MIGRATION_ERROR
def test_a_later_good_load_clears_the_reported_error(tmp_path: Path):
load_config(_write(tmp_path, backup={"bwlimit": -1}))
assert config.MIGRATION_ERROR is not None
load_config(_write(tmp_path / "fixed", backup={}))
assert config.MIGRATION_ERROR is None
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
def test_the_backup_is_owner_only(tmp_path: Path):
# It holds every API token, secret_key, password hash, SMTP and bot token — and is
# never overwritten, so a 0644 copy would sit there forever.
path = _write(tmp_path)
load_config(path)
assert stat.S_IMODE((tmp_path / BAK).stat().st_mode) == 0o600
def test_an_unwritable_config_still_boots(tmp_path: Path, monkeypatch):
+12 -12
View File
@@ -6,7 +6,7 @@ import pytest
from fakes import FakeBox
from app.config import PbsDevice
from app.jobs.lease import PbsUnreachableError, PowerLease
from app.jobs.lease import PbsUnreachableError, PowerLease, ReleaseOutcome
def make_pbs(**over) -> PbsDevice:
@@ -84,7 +84,7 @@ def test_unmanaged_pbs_is_only_probed():
pbs = make_pbs(managed_power=False, mac="")
assert lease.acquire(pbs) is True
assert lease.release(pbs) is False # never powered off
assert lease.release(pbs) is ReleaseOutcome.UNMANAGED # never Joulenap's to power
assert box.wol == []
assert box.poweroffs == []
@@ -108,7 +108,7 @@ def test_last_holder_powers_the_box_off():
pbs = make_pbs()
lease.acquire(pbs)
assert lease.release(pbs) is True
assert lease.release(pbs) is ReleaseOutcome.POWERED_OFF
assert box.poweroffs == ["pbs1"]
assert lease.state("pbs1").holders == 0
@@ -120,9 +120,9 @@ def test_release_with_another_holder_leaves_the_box_on():
lease.acquire(pbs)
lease.acquire(pbs)
assert lease.release(pbs) is False
assert lease.release(pbs) is ReleaseOutcome.STILL_NEEDED
assert box.poweroffs == []
assert lease.release(pbs) is True
assert lease.release(pbs) is ReleaseOutcome.POWERED_OFF
def test_a_queued_route_on_the_same_pbs_keeps_it_on():
@@ -131,7 +131,7 @@ def test_a_queued_route_on_the_same_pbs_keeps_it_on():
pbs = make_pbs()
lease.acquire(pbs)
assert lease.release(pbs) is False
assert lease.release(pbs) is ReleaseOutcome.STILL_NEEDED
assert box.poweroffs == []
@@ -141,7 +141,7 @@ def test_power_off_false_leaves_the_box_on():
pbs = make_pbs()
lease.acquire(pbs)
assert lease.release(pbs, power_off=False) is False
assert lease.release(pbs, power_off=False) is ReleaseOutcome.LEFT_ON
assert box.poweroffs == []
@@ -151,7 +151,7 @@ def test_a_busy_pbs_is_not_interrupted():
pbs = make_pbs()
lease.acquire(pbs)
assert lease.release(pbs) is False
assert lease.release(pbs) is ReleaseOutcome.LEFT_ON
assert box.poweroffs == []
@@ -162,7 +162,7 @@ def test_a_failing_idle_check_fails_open():
pbs = make_pbs()
lease.acquire(pbs)
assert lease.release(pbs) is True
assert lease.release(pbs) is ReleaseOutcome.POWERED_OFF
assert box.poweroffs == ["pbs1"]
@@ -172,7 +172,7 @@ def test_the_idle_check_is_skipped_when_the_wait_is_zero():
pbs = make_pbs(poweroff_task_wait=0)
lease.acquire(pbs)
assert lease.release(pbs) is True
assert lease.release(pbs) is ReleaseOutcome.POWERED_OFF
def test_a_failed_power_off_is_swallowed():
@@ -182,7 +182,7 @@ def test_a_failed_power_off_is_swallowed():
pbs = make_pbs()
lease.acquire(pbs)
assert lease.release(pbs) is False
assert lease.release(pbs) is ReleaseOutcome.LEFT_ON
assert box.poweroffs == []
@@ -190,7 +190,7 @@ def test_releasing_an_unheld_lease_is_ignored():
box = FakeBox()
lease = PowerLease(box.deps())
assert lease.release(make_pbs()) is False
assert lease.release(make_pbs()) is ReleaseOutcome.LEFT_ON
assert lease.state("pbs1").holders == 0
assert box.poweroffs == []
+58 -41
View File
@@ -187,7 +187,7 @@ def _route(route_id: str = "nightly", name: str = "Nightly", **overrides) -> Rou
)
def _msg(config, run, datastore=None, guests=None, next_at=None, route=None):
def _msg(config, run, datastore=None, guests=None, next_at=None, route=None, left_on=()):
"""``build_run_message`` with the old positional tail, so these tests stay readable.
The production seam is a single :class:`RunContext`; spelling that out at ~25 call sites
@@ -201,6 +201,7 @@ def _msg(config, run, datastore=None, guests=None, next_at=None, route=None):
datastore=datastore,
guests=guests,
next_at=next_at,
left_on=list(left_on),
)
)
@@ -271,6 +272,7 @@ def test_run_message_field_order():
ds,
GuestSummary(total=2, ok=1, failed=["web01"]),
datetime(2026, 6, 29, 4, 0, tzinfo=UTC),
left_on=["pbs-01"],
)
assert [line.split(":")[0] for line in body.splitlines()] == [
"Trigger",
@@ -359,51 +361,23 @@ def _woke() -> RunStep:
return RunStep(name=StepName.WAIT, status=StepStatus.SUCCESS)
def test_run_message_flags_pbs_left_on_when_poweroff_failed():
run = _run(RunStatus.SUCCESS)
run.steps = [_woke(), RunStep(name=StepName.POWEROFF, status=StepStatus.FAILURE)]
_title, body = _msg(Config(), run)
assert "left powered on" in body
def test_run_message_flags_pbs_left_on_when_poweroff_skipped():
run = _run(RunStatus.SUCCESS)
run.steps = [_woke(), RunStep(name=StepName.POWEROFF, status=StepStatus.SKIPPED)]
_title, body = _msg(Config(), run)
assert "left powered on" in body
def test_run_message_no_pbs_line_when_poweroff_succeeded():
run = _run(RunStatus.SUCCESS)
run.steps = [_woke(), RunStep(name=StepName.POWEROFF, status=StepStatus.SUCCESS)]
_title, body = _msg(Config(), run)
assert "left powered on" not in body
def test_run_message_flags_pbs_left_on_when_backup_fails_after_wake():
# Failure after the PBS woke: no POWEROFF step at all, box is left on for inspection.
def test_run_message_flags_the_boxes_the_run_left_awake():
# A finished run doesn't guess from its own timeline: JobService hands it the ids of the
# boxes still burning power (RunContext.left_on), because only the lease knows whether
# "not powered off" meant an always-on box, one another run still holds, or a real
# left-on. Which reason applies is decided (and tested) in tests/test_queue.py.
run = _run(RunStatus.FAILURE, error="vzdump failed")
run.steps = [_woke(), RunStep(name=StepName.BACKUP, status=StepStatus.FAILURE)]
_title, body = _msg(Config(), run)
_title, body = _msg(Config(), run, left_on=["pbs-01"])
assert "left powered on" in body
def test_run_message_flags_pbs_left_on_when_aborted_after_wake():
# An abort after wake (e.g. free-space preflight) also leaves the box on.
run = _run(RunStatus.ABORTED, error="datastore too full")
run.steps = [_woke(), RunStep(name=StepName.PRECHECK, status=StepStatus.FAILURE)]
_title, body = _msg(Config(), run)
assert "left powered on" in body
def test_run_message_no_pbs_line_when_wait_timed_out():
# Aborted before the PBS came up (WAIT failed): the box never turned on, so no warning.
run = _run(RunStatus.ABORTED, error="PBS not reachable")
run.steps = [
RunStep(name=StepName.WAKE, status=StepStatus.SUCCESS),
RunStep(name=StepName.WAIT, status=StepStatus.FAILURE),
]
_title, body = _msg(Config(), run)
def test_run_message_no_pbs_line_when_nothing_was_left_awake():
# A SKIPPED power-off is not evidence on its own — an always-on PBS records exactly
# this on every successful run, and used to be warned about every night.
run = _run(RunStatus.SUCCESS)
run.steps = [_woke(), RunStep(name=StepName.POWEROFF, status=StepStatus.SKIPPED)]
_title, body = _msg(Config(), run, left_on=[])
assert "left powered on" not in body
@@ -501,6 +475,49 @@ def test_interrupted_message_flags_pbs_left_on_when_it_had_woken():
assert "left powered on" in body
def test_interrupted_message_ignores_a_box_joulenap_never_powers():
# A crash cannot leave an always-on PBS "burning power" — it was on before and after.
cfg = Config.model_validate(
{
"pbss": [{"id": "pbs-01", "host": "192.0.2.20", "managed_power": False}],
"routes": [{"id": "nightly", "kind": "verify", "target": "pbs-01"}],
}
)
run = _run(RunStatus.FAILURE, error="Interrupted")
run.route_id = "nightly"
run.steps = [
RunStep(name=StepName.WAIT, status=StepStatus.SUCCESS),
RunStep(name=StepName.BACKUP, status=StepStatus.FAILURE),
]
_title, body = build_interrupted_message(cfg, run)
assert "left powered on" not in body
def test_interrupted_message_pairs_wake_and_power_off_per_device():
"""A sync route holds two boxes. One powering off must not hide the other staying up —
the old rule ORed both steps across the whole run and went silent."""
cfg = Config.model_validate(
{
"pbss": [
{"id": "pbs-01", "host": "192.0.2.20", "mac": "00:11:22:33:44:55"},
{"id": "pbs-02", "host": "192.0.2.21", "mac": "00:11:22:33:44:66"},
],
"routes": [
{"id": "off", "kind": "sync", "source_pbs": "pbs-01", "target": "pbs-02"}
],
}
)
run = _run(RunStatus.FAILURE, error="Interrupted")
run.route_id = "off"
run.steps = [
RunStep(name="wait:pbs-01", status=StepStatus.SUCCESS),
RunStep(name="wait:pbs-02", status=StepStatus.SUCCESS),
RunStep(name="poweroff:pbs-02", status=StepStatus.SUCCESS),
]
_title, body = build_interrupted_message(cfg, run)
assert "left powered on" in body # pbs-01 is still up
def test_interrupted_message_no_pbs_line_when_it_never_woke():
# Crashed during WAIT (PBS never came up): no "left on" warning.
run = _run(RunStatus.FAILURE, error="Interrupted")
+98 -1
View File
@@ -12,8 +12,10 @@ from sqlalchemy import select
from app.config import PbsDevice, PveDevice, Route, RouteSource
from app.core.config_store import ConfigStore
from app.db import session_scope
from app.db.models import Run, RunKind, RunStatus, RunTrigger
from app.db.models import Run, RunKind, RunStatus, RunTrigger, StepName, StepStatus
from app.jobs.lease import ReleaseOutcome
from app.jobs.service import AlreadyQueuedError, JobService, QueuedRun
from app.notify.messages import RunContext
def make_service(box: FakeBox | None = None) -> tuple[JobService, FakeBox]:
@@ -284,6 +286,101 @@ def test_an_unreachable_pbs_fails_the_run_without_running_the_job(temp_config, t
assert "not reachable" in (run.error or "")
# --- what the run reports as left powered on ---------------------------------
#
# "PBS left powered on" is the notification's energy warning, so it must fire exactly when
# a box is still awake with nothing left to shut it down. The lease is the only place that
# can tell the reasons apart, and it hands its verdict to the message through
# RunContext.left_on (tests/test_notify.py covers the rendering).
def notifying_job(status=RunStatus.SUCCESS):
"""A job that finishes with ``status`` and asks to be notified about it."""
def job(config, subject, recorder, _deps):
recorder.finish(status)
return RunContext(config=config, run=recorder.run)
return job
def sent(service: JobService) -> list[RunContext]:
seen: list[RunContext] = []
service.deps.notify = seen.append
return seen
def test_an_always_on_pbs_is_never_reported_as_left_on(temp_config, temp_db):
# The common false positive: a managed_power: false box records a SKIPPED power-off on
# *every* successful run, so the old step-based rule warned about it every single night.
service, box = make_service()
service._store.config.pbss[0].managed_power = False
seen = sent(service)
enqueue(service, "r1", notifying_job(), trigger=RunTrigger.SCHEDULED)
drain(service)
assert box.poweroffs == []
assert seen[0].left_on == []
def test_a_failed_run_reports_the_box_it_left_awake(temp_config, temp_db):
service, box = make_service()
seen = sent(service)
enqueue(service, "r1", notifying_job(RunStatus.FAILURE), trigger=RunTrigger.SCHEDULED)
drain(service)
assert box.poweroffs == [] # left up for inspection
assert seen[0].left_on == ["pbs1"]
def test_a_sync_route_reports_only_the_box_that_stayed_up(temp_config, temp_db):
# The old false negative: one box powering off hid the other one staying awake, because
# the rule ORed the steps across the whole run instead of pairing them per device.
service, box = make_service()
service._store.config.pbss[1].managed_power = False # target pbs2 is always on
seen = sent(service)
enqueue(service, "sync", notifying_job(RunStatus.FAILURE), trigger=RunTrigger.SCHEDULED)
drain(service)
assert box.poweroffs == []
assert seen[0].left_on == ["pbs1"] # not pbs2: that one is never ours to power down
def test_a_box_a_queued_route_still_needs_is_not_reported_as_left_on(temp_config, temp_db):
# It stays awake on purpose, and the run that follows will close it.
service, box = make_service()
seen = sent(service)
gate = Gate()
enqueue(service, "r1", gate.job, trigger=RunTrigger.SCHEDULED)
assert gate.started.wait(timeout=5)
enqueue(service, "r2", notifying_job(), trigger=RunTrigger.SCHEDULED)
gate.release.set()
drain(service)
assert box.poweroffs == ["pbs1"] # only r2, the last holder, closed it
assert seen[0].left_on == []
def test_the_power_off_step_records_why_the_box_stayed_up(temp_config, temp_db):
# The timeline says which of the three "not powered off" reasons applied, so the run
# detail view doesn't just show a bare SKIPPED.
service, _box = make_service()
service._store.config.pbss[0].managed_power = False
enqueue(service, "r1", ok_job, trigger=RunTrigger.SCHEDULED)
drain(service)
with session_scope() as session:
run = session.scalars(select(Run)).one()
step = next(s for s in run.steps if s.name == StepName.POWEROFF)
assert step.status == StepStatus.SKIPPED
assert step.detail == ReleaseOutcome.UNMANAGED
def test_a_run_on_another_pbs_does_not_hold_the_first(temp_config, temp_db):
service, box = make_service()
+17
View File
@@ -275,6 +275,23 @@ def test_cancel_is_refused_when_nothing_is_running(temp_config, temp_db):
assert service.cancel(run_id) is False
def test_a_finished_run_stops_being_the_cancellable_one(temp_config, temp_db):
"""``cancel`` takes a run id precisely so a click landing between two runs can't hit the
wrong one but the id was only ever *assigned*, never cleared, so it kept naming the
finished run. ``_start`` sets the new id after acquiring the lock and doing the DB
insert, so in that window ``is_running`` is already True while the stale id still
matches: the stop would be swallowed, or set the cancel flag on a run the user never
targeted, which then aborts on its first poll."""
service, _box = _service()
service.run_route("nightly")
_drain(service)
with session_scope() as session:
run_id = session.scalars(select(Run)).one().id
assert service._current_run_id is None
assert service.cancel(run_id) is False
def test_a_stale_cancel_does_not_kill_the_next_run(temp_config, temp_db):
# Cancel arrives moments before the run ends on its own; the flag must not leak into
# the run that starts next.