Files
joulenap/backend/app/main.py
T
Catubba 27dc791762 fix: container logging, preview label, and two documentation gaps
Four small ones found alongside the Gate 2 defects.

Nothing configured the root logger, so every `log.info` in the package went
nowhere: `docker logs` showed uvicorn's handful of lines and nothing else,
including across a 0.9 -> 1.0 config migration — the riskiest thing the app
ever does, and it left no trace of having run. Both entry points now set
logging up first, with `JOULENAP_LOG_LEVEL` to turn it up.

The route editor's preview chip printed the internal kind-prefixed key
(`pve:pve`) instead of the device id, ever since the draft started carrying
keys so a PVE and a backup server could share a name.

SECURITY.md said backup-server API traffic is pinned to a stored fingerprint
without saying where that fingerprint comes from. Adding a server through a
Proxmox host takes it from that host's storage configuration; adding one
directly reads it off the box over a connection nothing has authenticated,
which is trust on first use. Pinning protects everything after setup, not
setup itself, and the document now says so.

The changelog now warns upgraders that history is tracked per route, so
converted routes read "never run" and every guest reads "never backed up"
until the first 1.0 run — with the old runs still listed underneath, which
makes it look like data was lost when nothing was.
2026-08-05 13:24:30 +02:00

247 lines
9.8 KiB
Python

"""FastAPI application entrypoint.
Milestone 1 wires the app skeleton: health check, static frontend serving and the
``/api`` router (auth in M1; status/config/guests/etc. land in later milestones).
"""
from __future__ import annotations
import logging
import os
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse
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
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
from .notify.messages import build_interrupted_message
log = logging.getLogger("joulenap.main")
#: Container log verbosity. `JOULENAP_LOG_LEVEL=DEBUG` for a noisy run.
_LOG_LEVEL_ENV = "JOULENAP_LOG_LEVEL"
def setup_logging() -> None:
"""Give the app's own loggers somewhere to go.
Without this nothing configures the root logger, so every ``log.info`` in the package is
dropped and ``docker logs`` shows uvicorn's handful of lines and nothing else — including
across a 0.9 -> 1.0 config migration, which is the single riskiest thing this app ever
does and left no trace of having happened.
``basicConfig`` is a no-op once handlers exist, so calling this from both entry points is
safe, and uvicorn's own loggers (which configure themselves) are untouched.
"""
logging.basicConfig(
level=os.environ.get(_LOG_LEVEL_ENV, "INFO").upper(),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
#: How long shutdown waits for a startup thread. Both only make one notification round-trip, so
#: this is generous; it exists so a black-holing channel can't hang the process on exit. Kept
#: comfortably under Docker's 10s SIGTERM→SIGKILL grace, so a hung channel costs a slow stop
#: rather than a killed one.
_STARTUP_THREAD_JOIN_TIMEOUT = 5.0
def _frontend_dir() -> Path:
"""Directory of the built SPA (Vite output) served as static files.
``JOULENAP_FRONTEND_DIR`` wins (the Docker image sets it, since the installed package
lives in site-packages and can't resolve the repo layout). Otherwise fall back to the
repo checkout's ``frontend/dist``. When the dir is absent (dev without a build, or
tests) the mount is skipped and only the API is served.
"""
env = os.getenv("JOULENAP_FRONTEND_DIR")
if env:
return Path(env)
return Path(__file__).resolve().parents[2] / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Create the SQLite schema before serving requests, then start the in-process
# scheduler and arm the backup job from config.
init_db()
store: ConfigStore = app.state.config_store
# A previous process may have died mid-cycle, leaving runs stuck RUNNING; fail them
# so the dashboard doesn't show a run that never finishes. Build the alert text while the
# swept runs are still session-attached (build_interrupted_message reads run.steps).
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)
scheduler = Scheduler(
service.run_route,
service.run_prune,
timezone=store.config.app.timezone,
)
scheduler.start()
scheduler.rearm(store.config)
scheduler.arm_prune()
# Late-bound so a finished run can put its route's next fire in the notification: the
# Scheduler doesn't exist yet when JobService builds its deps (same reason
# cancelled/cancel_power_off are wired this way).
service.deps.next_run = scheduler.next_run_time
app.state.job_service = service
app.state.scheduler = scheduler
app.state.notifier = NotificationService()
# Detect scheduled runs missed while the process was down (BE-R1). Off the startup
# path on a daemon thread so a slow/black-holing notification channel can't delay the app
# becoming ready, and wrapped so it can never crash boot.
startup_threads = [
threading.Thread(
target=_startup_missed_run_check,
args=(store.config, scheduler, app.state.notifier),
daemon=True,
name="missed-backup-check",
)
]
# Alert on any run a restart interrupted (BE-R2) — same off-thread, boot-safe pattern.
if interrupted_alerts:
startup_threads.append(
threading.Thread(
target=_send_startup_alerts,
args=(store.config, app.state.notifier, interrupted_alerts),
daemon=True,
name="interrupted-run-alert",
)
)
for thread in startup_threads:
thread.start()
try:
yield
finally:
scheduler.shutdown()
# Both read the DB and/or send notifications, so shutdown waits for them instead of
# abandoning a half-sent alert — and, in tests, instead of leaving a thread that
# outlives its app and touches the *next* test's database.
# ponytail: the job service's queue worker is deliberately NOT joined — it may be
# mid-backup, and blocking shutdown on a running vzdump is worse than dropping the
# thread on process exit. Give JobService a stop() if that ever stops being true.
for thread in startup_threads:
thread.join(timeout=_STARTUP_THREAD_JOIN_TIMEOUT)
if thread.is_alive():
log.warning("Startup thread '%s' did not finish before shutdown", thread.name)
def _startup_missed_run_check(
config: Config, scheduler: Scheduler, notifier: NotificationService
) -> None:
from .core.catchup import check_missed_runs
try:
check_missed_runs(config, scheduler, notifier)
except Exception: # noqa: BLE001 - a startup safety net must never take the app down
log.exception("missed-run startup check failed")
def _send_startup_alerts(
config: Config, notifier: NotificationService, alerts: list[tuple[str, str]]
) -> None:
for title, body in alerts:
try:
notifier.send_alert(config, title, body)
except Exception: # noqa: BLE001 - a startup safety net must never take the app down
log.exception("startup alert notification failed")
def create_app() -> FastAPI:
setup_logging()
# Load (or first-run create) config before building the app: the session
# middleware needs the signing key, and routers read config via app.state.
store = ConfigStore.load_or_create()
app = FastAPI(title="Joulenap", version=__version__, lifespan=lifespan)
app.state.config_store = store
app.state.login_limiter = LoginRateLimiter()
# Signed session cookie. https_only stays off for LAN/HTTP; same_site=lax is
# fine for a same-origin SPA.
session_cfg = store.config.app.session
app.add_middleware(
SessionMiddleware,
secret_key=store.config.app.secret_key,
session_cookie="joulenap_session",
same_site="lax",
https_only=session_cfg.https_only,
max_age=session_cfg.max_age_days * 86400,
)
@app.get("/api/health", tags=["meta"])
def health() -> JSONResponse:
return JSONResponse({"status": "ok", "version": __version__})
app.include_router(api_router)
# Outside /api on purpose: /metrics is Prometheus's default metrics_path, so a scrape
# config needs no extra setting. Registered before the SPA mount so it isn't shadowed.
app.include_router(metrics_api.router)
_mount_frontend(app)
return app
def _mount_frontend(app: FastAPI) -> None:
"""Serve the built SPA's static files, with ``/`` returning index.html.
The SPA navigates via in-app state (no URL router), so every browser load hits ``/``
and there are no deep links to catch — unknown non-``/api`` paths simply 404, which is
fine. If URL-based routing is ever introduced, add a non-``/api`` catch-all to index.html.
"""
frontend_dir = _frontend_dir()
if not frontend_dir.exists():
return
index = frontend_dir / "index.html"
@app.get("/", include_in_schema=False)
def root() -> FileResponse:
return FileResponse(index)
# Mounted last so it doesn't shadow /api/* routes registered above.
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
def run() -> None:
"""Console-script entrypoint (`joulenap`).
Uses uvicorn's factory mode so the app — and its startup I/O (config load/create,
secret_key seeding) — is built only when the server actually boots, never on import.
The bind port comes from ``app.port`` (config-driven); load/create the
config here so a first run seeds config.yaml before the factory reads it again.
"""
import uvicorn
# Before the config load below, which is where a 0.9 -> 1.0 migration runs and logs.
setup_logging()
port = ConfigStore.load_or_create().config.app.port
uvicorn.run("app.main:create_app", factory=True, host="0.0.0.0", port=port, reload=False)
if __name__ == "__main__":
run()