mirror of
https://github.com/Joulenap/joulenap.git
synced 2026-08-11 13:21:43 +02:00
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.
247 lines
9.8 KiB
Python
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()
|