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
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
# Create data directory (volume mount point)
|
# Create data directory (volume mount point)
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
|
COPY VERSION ../VERSION
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: serve
|
# Stage 2: serve
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
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 collections.abc import AsyncGenerator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
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.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
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
|
# Ensure the data directory exists before SQLite tries to open the file
|
||||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -23,7 +27,22 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
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:
|
async def init_db() -> None:
|
||||||
|
_backup_db()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Add columns introduced after initial schema (idempotent)
|
# 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 path from 'path'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import pkg from './package.json'
|
|
||||||
|
const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
},
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user