mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 09:39:46 -05:00
Adds `python -m spoolman.cli`, the way back in when nobody can sign in: a
forgotten owner password, a disabled account, an owner who has left. This is
why the design has no per-user recovery codes.
docker exec -it spoolman python -m spoolman.cli auth status
Commands: auth status; user list/create/reset-password/set-level/enable/
disable; session revoke-all. Generated passwords are printed once.
Module form rather than a console script, because the Docker image runs
Spoolman from source and there is no installed distribution to hang an entry
point on. argparse rather than a dependency: eight subcommands do not
justify adding click or typer to the runtime image.
It never runs migrations. If the auth tables are missing it says so and
exits 2, because the fix is to start the server once and let it migrate --
having two things that can migrate the schema is how databases get hurt.
It deliberately works while SPOOLMAN_AUTH_ENABLED is unset, since the
intended first run is to create an owner and only then turn auth on.
Fixes a bug found by running it: "user create <name> --owner" produced an
owner at level read, because the --level default won over the flag. An owner
who cannot change anything is useless and would have sent someone straight
back to the CLI. --admin and --owner now imply manage, and an explicit lower
--level is reported rather than silently ignored.
Adds database.get_engine(), since the schema check needs the engine and the
Database instance is module-private.
Verified against a real database: refuses to run before migrations (exit 2);
reports an unclaimed instance; creates owner, edit and admin accounts with
the right levels; refuses a second owner and a duplicate username; refuses
to disable the owner; reset-password ends existing sessions and sets
must_change_password. Most importantly, end to end: a password generated by
the CLI signs in through the API, arrives with must_change_password set, and
carries the owner's manage level for both reads and writes.
255 lines
8.3 KiB
Python
255 lines
8.3 KiB
Python
"""SQLAlchemy database setup."""
|
|
|
|
import datetime
|
|
import logging
|
|
import shutil
|
|
import sqlite3
|
|
from collections.abc import AsyncGenerator
|
|
from os import PathLike
|
|
from pathlib import Path
|
|
|
|
from scheduler.asyncio.scheduler import Scheduler
|
|
from sqlalchemy import URL
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
from spoolman import env
|
|
from spoolman.prometheus.metrics import filament_metrics, spool_metrics
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_connection_url() -> URL:
|
|
"""Construct the connection URL for the database based on environment variables."""
|
|
db_type = env.get_database_type()
|
|
host = env.get_host()
|
|
port = env.get_port()
|
|
database = env.get_database()
|
|
query = env.get_query()
|
|
username = env.get_username()
|
|
password = env.get_password()
|
|
|
|
if db_type is None:
|
|
db_type = env.DatabaseType.SQLITE
|
|
|
|
database = str(env.get_data_dir().joinpath("spoolman.db"))
|
|
logger.info('No database type specified, using a default SQLite database located at "%s"', database)
|
|
elif db_type is env.DatabaseType.SQLITE:
|
|
if database is not None:
|
|
raise ValueError("Cannot specify a database name when using SQLite.")
|
|
|
|
database = str(env.get_data_dir().joinpath("spoolman.db"))
|
|
logger.info('Using SQLite database located at "%s"', database)
|
|
else:
|
|
logger.info('Connecting to database of type "%s" at "%s:%s"', db_type, host, port)
|
|
|
|
return URL.create(
|
|
drivername=db_type.to_drivername(),
|
|
host=host,
|
|
port=port,
|
|
database=database,
|
|
query=query or {},
|
|
username=username,
|
|
password=password,
|
|
)
|
|
|
|
|
|
class Database:
|
|
connection_url: URL
|
|
engine: AsyncEngine | None
|
|
session_maker: async_sessionmaker[AsyncSession] | None
|
|
|
|
def __init__(self, connection_url: URL) -> None:
|
|
"""Construct the Database wrapper and set config parameters."""
|
|
self.connection_url = connection_url
|
|
|
|
def is_file_based_sqlite(self) -> bool:
|
|
"""Return True if the database is file based."""
|
|
return (
|
|
self.connection_url.drivername[:6] == "sqlite"
|
|
and self.connection_url.database is not None
|
|
and self.connection_url.database != ":memory:"
|
|
)
|
|
|
|
def connect(self) -> None:
|
|
"""Connect to the database."""
|
|
if env.get_logging_level() == logging.DEBUG:
|
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
|
|
|
|
connect_args = {}
|
|
if self.connection_url.drivername == "sqlite+aiosqlite":
|
|
connect_args["timeout"] = 60
|
|
connection_options = {}
|
|
if self.connection_url.drivername == "mysql+aiomysql":
|
|
connection_options["pool_recycle"] = 3600
|
|
self.engine = create_async_engine(
|
|
self.connection_url,
|
|
connect_args=connect_args,
|
|
pool_pre_ping=True,
|
|
**connection_options,
|
|
)
|
|
self.session_maker = async_sessionmaker(self.engine, autocommit=False, autoflush=True, expire_on_commit=False)
|
|
|
|
def backup(self, target_path: str | PathLike[str]) -> None:
|
|
"""Backup the database."""
|
|
if not self.is_file_based_sqlite() or self.connection_url.database is None:
|
|
return
|
|
|
|
logger.info("Backing up SQLite database to %s", target_path)
|
|
|
|
def progress(_: int, remaining: int, total: int) -> None:
|
|
logger.info("Copied %d of %d pages.", total - remaining, total)
|
|
|
|
if self.connection_url.database == target_path:
|
|
raise ValueError("Cannot backup database to itself.")
|
|
if Path(target_path).exists():
|
|
raise ValueError("Backup target file already exists.")
|
|
|
|
with sqlite3.connect(self.connection_url.database) as src, sqlite3.connect(target_path) as dst:
|
|
src.backup(dst, pages=1, progress=progress)
|
|
|
|
logger.info("Backup complete.")
|
|
|
|
def backup_and_rotate(
|
|
self,
|
|
backup_folder: str | PathLike[str],
|
|
num_backups: int = 5,
|
|
) -> Path | None:
|
|
"""Backup the database and rotate existing backups.
|
|
|
|
Args:
|
|
backup_folder: The folder to store the backups in.
|
|
num_backups: The number of backups to keep.
|
|
|
|
Returns:
|
|
The path to the created backup or None if no backup was created.
|
|
|
|
"""
|
|
if not self.is_file_based_sqlite() or self.connection_url.database is None:
|
|
logger.info("Skipping backup as the database is not SQLite.")
|
|
return None
|
|
|
|
backup_folder = Path(backup_folder)
|
|
backup_folder.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Delete oldest backup
|
|
backup_path = backup_folder.joinpath(f"spoolman.db.{num_backups}")
|
|
if backup_path.exists():
|
|
logger.info("Deleting oldest backup %s", backup_path)
|
|
backup_path.unlink()
|
|
|
|
# Rotate existing backups
|
|
for i in range(num_backups - 1, -1, -1):
|
|
if i == 0:
|
|
backup_path = backup_folder.joinpath("spoolman.db")
|
|
else:
|
|
backup_path = backup_folder.joinpath(f"spoolman.db.{i}")
|
|
if backup_path.exists():
|
|
logger.debug("Rotating backup %s to %s", backup_path, backup_folder.joinpath(f"spoolman.db.{i + 1}"))
|
|
shutil.move(backup_path, backup_folder.joinpath(f"spoolman.db.{i + 1}"))
|
|
|
|
# Create new backup
|
|
backup_path = backup_folder.joinpath("spoolman.db")
|
|
self.backup(backup_path)
|
|
|
|
return backup_path
|
|
|
|
|
|
__db: Database | None = None
|
|
|
|
|
|
def setup_db(connection_url: URL) -> None:
|
|
"""Connect to the database.
|
|
|
|
Args:
|
|
connection_url: The URL to connect to the database.
|
|
|
|
"""
|
|
global __db # noqa: PLW0603
|
|
__db = Database(connection_url)
|
|
__db.connect()
|
|
|
|
|
|
def get_engine() -> AsyncEngine:
|
|
"""Get the connected engine.
|
|
|
|
Raises:
|
|
RuntimeError: If the database has not been set up.
|
|
|
|
Returns:
|
|
AsyncEngine: The engine.
|
|
|
|
"""
|
|
if __db is None or __db.engine is None:
|
|
raise RuntimeError("DB is not setup.")
|
|
return __db.engine
|
|
|
|
|
|
async def backup_global_db(num_backups: int = 5) -> Path | None:
|
|
"""Backup the database and rotate existing backups.
|
|
|
|
Returns:
|
|
The path to the created backup or None if no backup was created.
|
|
|
|
"""
|
|
if __db is None:
|
|
raise RuntimeError("DB is not setup.")
|
|
return __db.backup_and_rotate(env.get_backups_dir(), num_backups=num_backups)
|
|
|
|
|
|
async def _backup_task() -> Path | None:
|
|
"""Perform scheduled backup of the database."""
|
|
logger.info("Performing scheduled database backup.")
|
|
if __db is None:
|
|
raise RuntimeError("DB is not setup.")
|
|
return __db.backup_and_rotate(env.get_backups_dir(), num_backups=5)
|
|
|
|
|
|
async def _metrics() -> None:
|
|
"""Create some useful prometheus metrics."""
|
|
logger.debug("Start metrics collection")
|
|
async for session in get_db_session():
|
|
await filament_metrics(session)
|
|
await spool_metrics(session)
|
|
logger.debug("End metrics collection")
|
|
|
|
|
|
def schedule_tasks(scheduler: Scheduler) -> None:
|
|
"""Schedule tasks to be executed by the provided scheduler.
|
|
|
|
Args:
|
|
scheduler: The scheduler to use for scheduling tasks.
|
|
|
|
"""
|
|
if __db is None:
|
|
raise RuntimeError("DB is not setup.")
|
|
if env.is_metrics_enabled():
|
|
logger.info("Scheduling automatic metric collection.")
|
|
# Run every minute, may be needs specify timer
|
|
scheduler.minutely(datetime.time(second=0), _metrics) # type: ignore[arg-type]
|
|
if not env.is_automatic_backup_enabled():
|
|
return
|
|
if "sqlite" in __db.connection_url.drivername:
|
|
logger.info("Scheduling automatic database backup for midnight.")
|
|
# Schedule for midnight
|
|
scheduler.daily(datetime.time(hour=0, minute=0, second=0), _backup_task) # type: ignore[arg-type]
|
|
|
|
|
|
async def get_db_session() -> AsyncGenerator[AsyncSession]:
|
|
"""Get a DB session to be used with FastAPI's dependency system.
|
|
|
|
Yields:
|
|
The database session.
|
|
|
|
"""
|
|
if __db is None or __db.session_maker is None:
|
|
raise RuntimeError("DB is not setup.")
|
|
async with __db.session_maker() as session:
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except Exception as exc:
|
|
await session.rollback()
|
|
raise exc
|
|
finally:
|
|
await session.close()
|