feat: automatic DB backup before migrations using VERSION file
- 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ .
|
||||
COPY VERSION ../VERSION
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user