From 0019c086cfead414522ee701ed415c5b96a7b651 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 01:21:09 +0200 Subject: [PATCH] feat: automatic DB backup before migrations using VERSION file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add VERSION file at repo root as single source of truth for app version - frontend/vite.config.ts reads VERSION file instead of package.json - backend config.py exposes APP_VERSION read from VERSION (dev) or /app/VERSION (Docker) - database.py backs up DB to homelab.db.back-{version} before running migrations (skipped if DB doesn't exist or backup already exists — fully idempotent) - Dockerfile.backend and Dockerfile.frontend copy VERSION into the image - Add test_db_backup.py with 4 tests covering create/skip/idempotent/version cases --- Dockerfile.backend | 1 + Dockerfile.frontend | 1 + VERSION | 1 + backend/app/core/config.py | 11 +++++++ backend/app/db/database.py | 21 +++++++++++- backend/tests/test_db_backup.py | 57 +++++++++++++++++++++++++++++++++ frontend/vite.config.ts | 6 ++-- 7 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 VERSION create mode 100644 backend/tests/test_db_backup.py diff --git a/Dockerfile.backend b/Dockerfile.backend index bb768ae..82bb73f 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -9,6 +9,7 @@ COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY backend/ . +COPY VERSION /app/VERSION # Create data directory (volume mount point) RUN mkdir -p /app/data diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 9644afa..745c359 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -12,6 +12,7 @@ COPY frontend/package*.json ./ RUN npm ci COPY frontend/ . +COPY VERSION ../VERSION RUN npm run build # Stage 2: serve diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..2e0e38c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.9 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 749382a..481b7bd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict logger = logging.getLogger(__name__) +def _read_version() -> str: + for candidate in [ + Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev) + Path("/app/VERSION"), # Docker image + ]: + if candidate.exists(): + return candidate.read_text().strip() + return "unknown" + +APP_VERSION = _read_version() + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 3831ef4..f7b3ac2 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -1,3 +1,5 @@ +import logging +import shutil from collections.abc import AsyncGenerator from contextlib import suppress from pathlib import Path @@ -6,7 +8,9 @@ from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase -from app.core.config import settings +from app.core.config import APP_VERSION, settings + +logger = logging.getLogger(__name__) # Ensure the data directory exists before SQLite tries to open the file Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True) @@ -23,7 +27,22 @@ class Base(DeclarativeBase): pass +def _backup_db() -> None: + db_path = Path(settings.sqlite_path) + if not db_path.exists(): + return + backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}") + if backup_path.exists(): + return + try: + shutil.copy2(db_path, backup_path) + logger.info("DB backup created: %s", backup_path.name) + except OSError: + logger.warning("Could not create DB backup at %s", backup_path) + + async def init_db() -> None: + _backup_db() async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Add columns introduced after initial schema (idempotent) diff --git a/backend/tests/test_db_backup.py b/backend/tests/test_db_backup.py new file mode 100644 index 0000000..ac54e92 --- /dev/null +++ b/backend/tests/test_db_backup.py @@ -0,0 +1,57 @@ +""" +Tests for automatic DB backup before migrations. +""" +import os + +os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production") + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from app.db.database import _backup_db + + +@pytest.fixture() +def tmp_db(tmp_path: Path): + db = tmp_path / "homelab.db" + db.write_bytes(b"SQLite placeholder") + return db + + +def test_backup_created_when_db_exists(tmp_db: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "1.9"): + mock_settings.sqlite_path = str(tmp_db) + _backup_db() + backup = tmp_db.parent / "homelab.db.back-1.9" + assert backup.exists() + assert backup.read_bytes() == b"SQLite placeholder" + + +def test_backup_skipped_when_db_missing(tmp_path: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "1.9"): + mock_settings.sqlite_path = str(tmp_path / "nonexistent.db") + _backup_db() + assert not any(tmp_path.glob("*.back-*")) + + +def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "1.9"): + mock_settings.sqlite_path = str(tmp_db) + _backup_db() + backup = tmp_db.parent / "homelab.db.back-1.9" + backup.write_bytes(b"original backup") + _backup_db() + assert backup.read_bytes() == b"original backup" + + +def test_backup_version_in_filename(tmp_db: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "2.0"): + mock_settings.sqlite_path = str(tmp_db) + _backup_db() + assert (tmp_db.parent / "homelab.db.back-2.0").exists() diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c7f1f73..fbca3fc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,14 @@ +import fs from 'fs' import path from 'path' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -import pkg from './package.json' + +const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim() export default defineConfig({ define: { - __APP_VERSION__: JSON.stringify(pkg.version), + __APP_VERSION__: JSON.stringify(appVersion), }, plugins: [react(), tailwindcss()], resolve: {