Added automatic nightly SQLite backups

Resolves #22
This commit is contained in:
Donkie
2023-07-04 14:59:05 +02:00
parent 51783afe92
commit f24b9610e8
13 changed files with 221 additions and 4 deletions

View File

@@ -38,6 +38,8 @@ services:
- ./data:/home/app/.local/share/spoolman - ./data:/home/app/.local/share/spoolman
ports: ports:
- "7912:8000" - "7912:8000"
environment:
- TZ=Europe/Stockholm # Optional, defaults to UTC
``` ```
With this example, you should first create a folder called `data` in the same directory as the docker-compose.yml, then you should run `chown 1000:1000 data` on it in order to give it the correct permissions for the user inside the docker container. With this example, you should first create a folder called `data` in the same directory as the docker-compose.yml, then you should run `chown 1000:1000 data` on it in order to give it the correct permissions for the user inside the docker container.
@@ -56,6 +58,7 @@ If you want to connect with an external database instead, specify the `SPOOLMAN_
| SPOOLMAN_DB_PASSWORD | Database password | | SPOOLMAN_DB_PASSWORD | Database password |
| SPOOLMAN_DB_QUERY | Query parameters for the database connection, e.g. set to `unix_socket=/path/to/mysql.sock` to connect using a MySQL socket. | | SPOOLMAN_DB_QUERY | Query parameters for the database connection, e.g. set to `unix_socket=/path/to/mysql.sock` to connect using a MySQL socket. |
| SPOOLMAN_LOGGING_LEVEL | Logging level, any of: `CRITICAL`, `ERROR`, `WARNING`, `INFO`, `DEBUG`, defaults to `INFO`. | | SPOOLMAN_LOGGING_LEVEL | Logging level, any of: `CRITICAL`, `ERROR`, `WARNING`, `INFO`, `DEBUG`, defaults to `INFO`. |
| SPOOLMAN_AUTOMATIC_BACKUP | Automatic nightly DB backups for SQLite databases. Enabled by default, set to `FALSE` to disable. |
## Configuration ## Configuration
### Moonraker ### Moonraker

View File

@@ -8,6 +8,7 @@ SQLAlchemy[asyncio,aiomysql,postgresql_asyncpg,aiosqlite]==2.0.15
pydantic==1.10.7 pydantic==1.10.7
platformdirs==3.2.0 platformdirs==3.2.0
alembic==1.11.1 alembic==1.11.1
scheduler==0.8.4
# CockroachDB support # CockroachDB support
sqlalchemy-cockroachdb==2.0.1 sqlalchemy-cockroachdb==2.0.1

View File

@@ -159,3 +159,12 @@ class Spool(BaseModel):
class HealthCheck(BaseModel): class HealthCheck(BaseModel):
status: str = Field(example="healthy") status: str = Field(example="healthy")
class BackupResponse(BaseModel):
success: bool = Field(description="Whether the backup was created successfully.", example=True)
path: Optional[str] = Field(
default=None,
description="Path to the created backup file.",
example="/home/app/.local/share/spoolman/backups/spoolman.db",
)

View File

@@ -9,6 +9,7 @@ from fastapi.responses import JSONResponse
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
from spoolman.database.database import backup_task
from spoolman.exceptions import ItemNotFoundError from spoolman.exceptions import ItemNotFoundError
from . import filament, models, spool, vendor from . import filament, models, spool, vendor
@@ -39,6 +40,17 @@ async def health() -> models.HealthCheck:
return models.HealthCheck(status="healthy") return models.HealthCheck(status="healthy")
# Add endpoint for triggering a db backup
@app.post("/backup", summary="Trigger a database backup. Only applicable for SQLite databases.")
async def backup() -> models.BackupResponse:
"""Trigger a database backup."""
path = await backup_task()
if path is None:
return models.BackupResponse(success=False)
return models.BackupResponse(success=True, path=str(path))
# Add routers
app.include_router(filament.router) app.include_router(filament.router)
app.include_router(spool.router) app.include_router(spool.router)
app.include_router(vendor.router) app.include_router(vendor.router)

View File

@@ -1,9 +1,15 @@
"""SQLAlchemy database setup.""" """SQLAlchemy database setup."""
import datetime
import logging import logging
import shutil
import sqlite3
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Optional from os import PathLike
from pathlib import Path
from typing import Optional, Union
from scheduler.asyncio.scheduler import Scheduler
from sqlalchemy import URL from sqlalchemy import URL
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
@@ -56,6 +62,14 @@ class Database:
"""Construct the Database wrapper and set config parameters.""" """Construct the Database wrapper and set config parameters."""
self.connection_url = connection_url self.connection_url = connection_url
def is_file_based_sqlite(self: "Database") -> 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: "Database") -> None: def connect(self: "Database") -> None:
"""Connect to the database.""" """Connect to the database."""
if env.get_logging_level() == logging.DEBUG: if env.get_logging_level() == logging.DEBUG:
@@ -72,6 +86,69 @@ class Database:
) )
self.session_maker = async_sessionmaker(self.engine, autocommit=False, autoflush=True, expire_on_commit=False) self.session_maker = async_sessionmaker(self.engine, autocommit=False, autoflush=True, expire_on_commit=False)
def backup(self: "Database", target_path: Union[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: "Database",
backup_folder: Union[str, PathLike[str]],
num_backups: int = 5,
) -> Optional[Path]:
"""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: Optional[Database] = None __db: Optional[Database] = None
@@ -87,8 +164,39 @@ def setup_db(connection_url: URL) -> None:
__db.connect() __db.connect()
async def backup_task() -> Optional[Path]:
"""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_data_dir().joinpath("backups"), num_backups=5)
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 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, None]: async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Get a DB session to be used with FastAPI's dependency system.""" """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: if __db is None or __db.session_maker is None:
raise RuntimeError("DB is not setup.") raise RuntimeError("DB is not setup.")
async with __db.session_maker() as session: async with __db.session_maker() as session:

View File

@@ -196,6 +196,24 @@ def is_debug_mode() -> bool:
raise ValueError(f"Failed to parse SPOOLMAN_DEBUG_MODE variable: Unknown debug mode '{debug_mode}'.") raise ValueError(f"Failed to parse SPOOLMAN_DEBUG_MODE variable: Unknown debug mode '{debug_mode}'.")
def is_automatic_backup_enabled() -> bool:
"""Get whether automatic backup is enabled from environment variables.
Returns True if no environment variable was set for automatic backup.
Returns:
bool: Whether automatic backup is enabled.
"""
automatic_backup = os.getenv("SPOOLMAN_AUTOMATIC_BACKUP", "TRUE").upper()
if automatic_backup == "FALSE" or automatic_backup == "0":
return False
if automatic_backup == "TRUE" or automatic_backup == "1":
return True
raise ValueError(
f"Failed to parse SPOOLMAN_AUTOMATIC_BACKUP variable: Unknown automatic backup '{automatic_backup}'.",
)
def get_data_dir() -> Path: def get_data_dir() -> Path:
"""Get the data directory. """Get the data directory.
@@ -205,3 +223,15 @@ def get_data_dir() -> Path:
data_dir = Path(user_data_dir("spoolman")) data_dir = Path(user_data_dir("spoolman"))
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
return data_dir return data_dir
def get_backups_dir() -> Path:
"""Get the backups directory.
Returns:
Path: The backups directory.
"""
data_dir = get_data_dir()
backups_dir = data_dir.joinpath("backups")
backups_dir.mkdir(parents=True, exist_ok=True)
return backups_dir

View File

@@ -11,10 +11,11 @@ import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from scheduler.asyncio.scheduler import Scheduler
from spoolman import env from spoolman import env
from spoolman.api.v1.router import app as v1_app from spoolman.api.v1.router import app as v1_app
from spoolman.database.database import get_connection_url, setup_db from spoolman.database import database
# Define a file logger with log rotation # Define a file logger with log rotation
log_file = env.get_data_dir().joinpath("spoolman.log") log_file = env.get_data_dir().joinpath("spoolman.log")
@@ -75,7 +76,7 @@ if env.is_debug_mode():
async def startup() -> None: async def startup() -> None:
"""Run the service's startup sequence.""" """Run the service's startup sequence."""
logger.info("Setting up database...") logger.info("Setting up database...")
setup_db(get_connection_url()) database.setup_db(database.get_connection_url())
logger.info("Performing migrations...") logger.info("Performing migrations...")
# Run alembic in a subprocess. # Run alembic in a subprocess.
@@ -84,6 +85,10 @@ async def startup() -> None:
project_root = Path(__file__).parent.parent project_root = Path(__file__).parent.parent
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC101 subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC101
# Setup scheduler
schedule = Scheduler()
database.schedule_tasks(schedule)
logger.info("Startup complete.") logger.info("Startup complete.")

View File

@@ -33,5 +33,7 @@ services:
image: donkie/spoolman-tester:latest image: donkie/spoolman-tester:latest
volumes: volumes:
- ./tests:/tester/tests - ./tests:/tester/tests
environment:
- DB_TYPE=cockroachdb
depends_on: depends_on:
- spoolman - spoolman

View File

@@ -37,5 +37,7 @@ services:
image: donkie/spoolman-tester:latest image: donkie/spoolman-tester:latest
volumes: volumes:
- ./tests:/tester/tests - ./tests:/tester/tests
environment:
- DB_TYPE=mysql
depends_on: depends_on:
- spoolman - spoolman

View File

@@ -20,5 +20,7 @@ services:
image: donkie/spoolman-tester:latest image: donkie/spoolman-tester:latest
volumes: volumes:
- ./tests:/tester/tests - ./tests:/tester/tests
environment:
- DB_TYPE=postgres
depends_on: depends_on:
- spoolman - spoolman

View File

@@ -8,5 +8,7 @@ services:
image: donkie/spoolman-tester:latest image: donkie/spoolman-tester:latest
volumes: volumes:
- ./tests:/tester/tests - ./tests:/tester/tests
environment:
- DB_TYPE=sqlite
depends_on: depends_on:
- spoolman - spoolman

View File

@@ -1,6 +1,8 @@
"""Test fixtures for integration tests.""" """Test fixtures for integration tests."""
import os
import time import time
from enum import Enum
from typing import Any from typing import Any
import httpx import httpx
@@ -11,6 +13,27 @@ TIMEOUT = 10
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
class DbType(str, Enum):
"""Enum for database types."""
SQLITE = "sqlite"
POSTGRES = "postgres"
MYSQL = "mysql"
COCKROACHDB = "cockroachdb"
def get_db_type() -> DbType:
"""Return the database type from environment variables."""
env_db_type = os.environ.get("DB_TYPE")
if env_db_type is None:
raise RuntimeError("DB_TYPE environment variable not set")
try:
db_type = DbType(env_db_type)
except ValueError as e:
raise RuntimeError(f"Unknown database type: {env_db_type}") from e
return db_type
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def _wait_for_server(): # noqa: ANN202 def _wait_for_server(): # noqa: ANN202
"""Wait for the server to start up.""" """Wait for the server to start up."""

View File

@@ -0,0 +1,18 @@
"""Integration tests for the Vendor API endpoint."""
import httpx
from .conftest import DbType, get_db_type
URL = "http://spoolman:8000"
def test_backup():
"""Test triggering an automatic database backup."""
if get_db_type() != DbType.SQLITE:
return
# Trigger backup
result = httpx.post(f"{URL}/api/v1/backup")
result.raise_for_status()
assert result.json()["success"] is True