diff --git a/.github/workflows/push_docker.yml b/.github/workflows/push_docker.yml index b0fcdb8b8..3e5b7dedc 100644 --- a/.github/workflows/push_docker.yml +++ b/.github/workflows/push_docker.yml @@ -43,7 +43,7 @@ jobs: nix build .#lomas-oci-raw -o lomas-oci-raw.tar - name: Push Image - if: github.event_name == 'push' || github.event_name == 'release' + # if: github.event_name == 'push' || github.event_name == 'release' run: | for tag in $DOCKER_METADATA_OUTPUT_TAGS; do skopeo copy docker-archive:lomas-oci.tar docker://dsccadminch/lomas:${tag##*:} diff --git a/client/lomas_client/tests/test_integrations.py b/client/lomas_client/tests/test_integrations.py index 6febdd0b2..8678a0194 100644 --- a/client/lomas_client/tests/test_integrations.py +++ b/client/lomas_client/tests/test_integrations.py @@ -1,8 +1,13 @@ import io +import os import re +import sqlite3 import sys +import tempfile import time +import zipfile from dataclasses import dataclass +from pathlib import Path from urllib.parse import urljoin import numpy as np @@ -15,7 +20,9 @@ from bs4 import BeautifulSoup from csvw_eo.constants import COL_NAME, TABLE_SCHEMA from diffprivlib import models +from fastapi.testclient import TestClient from opendp.mod import enable_features +from pydantic import ValidationError from sklearn.pipeline import Pipeline from lomas_client import Client @@ -27,7 +34,8 @@ del_all_dex_users, ) from lomas_server.administration.scripts.lomas_demo_setup import lomas_demo_setup -from lomas_server.models.config import AdminConfig, ServerConfig +from lomas_server.app import get_admin_app +from lomas_server.models.config import AdminConfig, BackupS3Config, LocalBackupConfig, ServerConfig enable_features("contrib") @@ -415,3 +423,85 @@ def test_demo_opendp_polars(dex_config, demo_setup) -> None: assert response_archives is not None assert response_archives.epsilon == DEFAULT_EPSILON assert response_archives.delta == pytest.approx(0.0, abs=0.1) + + +def test_backup(): + # With S3 + config = ServerConfig() + config.database.wipe() + config.database.set_bootstrap(config.bootstrap) + + with TestClient(get_admin_app(config), headers={"Authorization": f"Bearer {config.bootstrap}"}) as client: + response = client.get("/backup") + body = response.json() + assert body["is_s3"] is True + assert body["location"].startswith("s3://") + + # Raise an error if uri isn't correctly defined + # For instance missing user:password / bucket_name, etc. + with pytest.raises(ValidationError): + ServerConfig(backup=BackupS3Config(uri="https://localhost:3900/bucket")) + + # With local_directory setup + config = ServerConfig(backup=LocalBackupConfig(local_directory="/tmp/lomas-custom-backups")) + with TestClient(get_admin_app(config), headers={"Authorization": f"Bearer {config.bootstrap}"}) as client: + # Create one query to test backup content + lomas_demo_setup() + user_name = "Jack" + client_u = Client( + user_name=f"{user_name}@example.com", user_password=user_name.lower(), dataset_name="TITANIC" + ) + + context = client_u.get_context(epsilon=DEFAULT_EPSILON) + plan = context.query().select(pl.col("Age").dp.mean(bounds=(0, 120)), dp.len()) + client_u.opendp.query(plan, epsilon=DEFAULT_EPSILON) + + # Backup bew db state + response = client.get("/backup") + body = response.json() + + # Check if custom location works + assert body["is_s3"] is False + assert body["location"].startswith("/tmp/lomas-custom-backups/") + assert os.path.exists(body["location"]) + + # Test that we have correct tables saved in backup + # Load each sqlite db out of the backup zip and check its tables + backup_path = Path(body["location"]) + expected_tables_by_file = { + "db.sqlite3": {"jobs", "users", "misc", "datasets"}, + "archives.sqlite3": {"archives"}, + } + + with zipfile.ZipFile(backup_path) as archive, tempfile.TemporaryDirectory() as extract_dir: + assert set(archive.namelist()) == set(expected_tables_by_file) + + for filename, expected_tables in expected_tables_by_file.items(): + db_path = Path(extract_dir) / filename + db_path.write_bytes(archive.read(filename)) + + conn = sqlite3.connect(db_path) + try: + # Check tables are correctly saved in each db + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + + # Check that the client query is saved in backup + if tables == {"archives"}: + row = conn.execute( + "SELECT uid, user_name, dataset_name, status FROM archives;" + ).fetchone() + + assert row[1] == user_name + assert row[2] == "TITANIC" + assert row[3] == "complete" + + finally: + conn.close() + assert tables == expected_tables, ( + f"{filename} table mismatch: extra={tables - expected_tables}, missing={expected_tables - tables}" + ) diff --git a/deploy/charts/lomas/templates/server/_helpers.tpl b/deploy/charts/lomas/templates/server/_helpers.tpl index 46ac7397e..bad38890a 100644 --- a/deploy/charts/lomas/templates/server/_helpers.tpl +++ b/deploy/charts/lomas/templates/server/_helpers.tpl @@ -37,6 +37,9 @@ app.kubernetes.io/component: {{ include "lomas.worker.name" . }} {{- define "lomas.server.dataPVCName" -}} {{- printf "%s-%s" (include "lomas.server.fullname" .) "data" }} {{- end}} +{{- define "lomas.server.backupPVCName" -}} +{{- printf "%s-%s" (include "lomas.server.fullname" .) "backup" }} +{{- end}} {{- define "lomas.server.dbPVCName" -}} {{- printf "%s-%s" (include "lomas.server.fullname" .) "db" }} {{- end}} @@ -79,6 +82,24 @@ app.kubernetes.io/component: {{ include "lomas.worker.name" . }} {{- end -}} {{- end -}} +{{/* s3 backup uri secret */}} +{{- define "lomas.server.s3BackupUriSecretName" -}} +{{- $secretName := .Values.server.runtime_args.s3Backup.uri.existingSecret -}} +{{- if $secretName -}} + {{- printf "%s" (tpl $secretName $) -}} +{{- else -}} + {{- printf "%s-server-s3-backup-uri-secret" (include "lomas.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "lomas.server.s3BackupUriSecretKey" -}} + {{- if and .Values.server.runtime_args.s3Backup.uri.existingSecret .Values.server.runtime_args.s3Backup.uri.existingKey -}} + {{- printf "%s" (tpl .Values.server.runtime_args.s3Backup.uri.existingKey $) -}} + {{- else -}} + {{- printf "s3-backup-uri" -}} + {{- end -}} +{{- end -}} + {{/* Private DB credentials */}} {{- define "lomas.server.private-db-credentials-secrets" -}} {{- $result := list }} diff --git a/deploy/charts/lomas/templates/server/data_pvc.yaml b/deploy/charts/lomas/templates/server/data_pvc.yaml deleted file mode 100644 index d45a7e3ff..000000000 --- a/deploy/charts/lomas/templates/server/data_pvc.yaml +++ /dev/null @@ -1,23 +0,0 @@ -{{- if .Values.server.pvc.data.enabled }} -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: {{ include "lomas.server.dataPVCName" . }} - labels: - {{- include "lomas.server.labels" . | nindent 4 }} - annotations: - {{- if .Values.server.pvc.data.persistence.resourcePolicy }} - "helm.sh/resource-policy": {{ .Values.server.pvc.data.persistence.resourcePolicy }} - {{- end }} -spec: - accessModes: - - {{ .Values.server.pvc.data.accessMode }} - {{- if .Values.server.pvc.data.storageClassName }} - storageClassName: {{ .Values.server.pvc.data.storageClassName | quote }} - {{- end }} - resources: - limits: - storage: {{ .Values.server.pvc.data.sizeLimit | quote }} - requests: - storage: {{ .Values.server.pvc.data.sizeRequest | quote }} -{{- end }} \ No newline at end of file diff --git a/deploy/charts/lomas/templates/server/pvc.yaml b/deploy/charts/lomas/templates/server/pvc.yaml index 572a267ac..24f55abc5 100644 --- a/deploy/charts/lomas/templates/server/pvc.yaml +++ b/deploy/charts/lomas/templates/server/pvc.yaml @@ -18,4 +18,52 @@ spec: limits: storage: {{ .Values.server.pvc.db.sizeLimit | quote }} requests: - storage: {{ .Values.server.pvc.db.sizeRequest | quote }} \ No newline at end of file + storage: {{ .Values.server.pvc.db.sizeRequest | quote }} +--- +{{- if .Values.server.pvc.data.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "lomas.server.dataPVCName" . }} + labels: + {{- include "lomas.server.labels" . | nindent 4 }} + annotations: + {{- if .Values.server.pvc.data.persistence.resourcePolicy }} + "helm.sh/resource-policy": {{ .Values.server.pvc.data.persistence.resourcePolicy }} + {{- end }} +spec: + accessModes: + - {{ .Values.server.pvc.data.accessMode }} + {{- if .Values.server.pvc.data.storageClassName }} + storageClassName: {{ .Values.server.pvc.data.storageClassName | quote }} + {{- end }} + resources: + limits: + storage: {{ .Values.server.pvc.data.sizeLimit | quote }} + requests: + storage: {{ .Values.server.pvc.data.sizeRequest | quote }} +{{- end }} +--- +{{- if .Values.server.pvc.backup.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "lomas.server.backupPVCName" . }} + labels: + {{- include "lomas.server.labels" . | nindent 4 }} + annotations: + {{- if .Values.server.pvc.backup.persistence.resourcePolicy }} + "helm.sh/resource-policy": {{ .Values.server.pvc.backup.persistence.resourcePolicy }} + {{- end }} +spec: + accessModes: + - {{ .Values.server.pvc.backup.accessMode }} + {{- if .Values.server.pvc.backup.storageClassName }} + storageClassName: {{ .Values.server.pvc.backup.storageClassName | quote }} + {{- end }} + resources: + limits: + storage: {{ .Values.server.pvc.backup.sizeLimit | quote }} + requests: + storage: {{ .Values.server.pvc.backup.sizeRequest | quote }} +{{- end }} \ No newline at end of file diff --git a/deploy/charts/lomas/templates/server/secrets.yaml b/deploy/charts/lomas/templates/server/secrets.yaml index dad8f411a..a824c7256 100644 --- a/deploy/charts/lomas/templates/server/secrets.yaml +++ b/deploy/charts/lomas/templates/server/secrets.yaml @@ -21,3 +21,15 @@ type: Opaque data: {{ include "lomas.server.workerApiKeySecretKey" . }}: {{ required ".Values.server.runtime_args.worker_api_key.value or existing secret must be set." .Values.server.runtime_args.worker_api_key.value | b64enc }} {{- end }} +--- +{{- if and .Values.server.runtime_args.s3Backup.enabled (not .Values.server.runtime_args.s3Backup.uri.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "lomas.server.s3BackupUriSecretName" . }} + labels: + {{ include "lomas.labels" . | nindent 4}} +type: Opaque +data: + {{ include "lomas.server.s3BackupUriSecretKey" . }}: {{ required ".Values.server.runtime_args.s3Backup.uri.value or existing secret must be set." .Values.server.runtime_args.s3Backup.uri.value | b64enc }} +{{- end }} diff --git a/deploy/charts/lomas/templates/server/server_deployment.yaml b/deploy/charts/lomas/templates/server/server_deployment.yaml index 61d72ecd1..9acedd3de 100644 --- a/deploy/charts/lomas/templates/server/server_deployment.yaml +++ b/deploy/charts/lomas/templates/server/server_deployment.yaml @@ -42,6 +42,10 @@ spec: volumeMounts: - name: db mountPath: /db + {{- if .Values.server.pvc.backup.enabled }} + - name: backup + mountPath: /backup + {{- end }} {{- if .Values.server.pvc.data.enabled }} - name: data mountPath: /data @@ -84,6 +88,20 @@ spec: value: "/db/" - name: LOMAS_SERVER_CLEAN_ADMIN_DATABASE value: "{{ .Values.server.runtime_args.clean_admin_database }}" + {{- if not .Values.server.runtime_args.s3Backup.enabled }} + - name: LOMAS_SERVER_BACKUP__LOCAL_DIRECTORY + value: "/backup/" + {{- else }} + - name: LOMAS_SERVER_BACKUP__URI + valueFrom: + secretKeyRef: + name: {{ include "lomas.server.s3BackupUriSecretName" . }} + key: {{ include "lomas.server.s3BackupUriSecretKey" . }} + - name: AWS_REQUEST_CHECKSUM_CALCULATION + value: "when_required" + - name: AWS_RESPONSE_CHECKSUM_VALIDATION + value: "when_required" + {{- end }} - name: LOMAS_SERVER_DATA_DIRECTORY value: "/data" - name: LOMAS_SERVER_AUTHENTICATOR__AUTHENTICATION_TYPE @@ -126,6 +144,11 @@ spec: persistentVolumeClaim: claimName: {{ include "lomas.server.dataPVCName" . }} {{- end }} + {{- if .Values.server.pvc.backup.enabled }} + - name: backup + persistentVolumeClaim: + claimName: {{ include "lomas.server.backupPVCName" . }} + {{- end }} {{- if .Values.global.configCABundle.enabled }} - name: trusted-cabundle configMap: diff --git a/deploy/charts/lomas/values.yaml b/deploy/charts/lomas/values.yaml index 62aa994e6..1cde5d412 100644 --- a/deploy/charts/lomas/values.yaml +++ b/deploy/charts/lomas/values.yaml @@ -60,6 +60,12 @@ server: query_userinfo: true authentication_type: oidc clean_admin_database: false # careful! + s3Backup: + enable: false # If not enabled, local path backup will be enabled (see pvc.backup) + uri: + existingSecret: "" + existingSecretKey: "" + value: "" pvc: db: # used for sqlite admin db storageClassName: "" # is not set if empty string @@ -70,9 +76,19 @@ server: # If set to "keep", sets the helm/resource-policy of the pvc to keep, # so that the pvc is not deleted across reinstalls. resourcePolicy: "" + backup: # used for local database backups + enabled: true + storageClassName: "" # is not set if empty string + sizeLimit: 2Gi + sizeRequest: 1Gi + accessMode: "ReadWriteOnce" + persistence: + # If set to "keep", sets the helm/resource-policy of the pvc to keep, + # so that the pvc is not deleted across reinstalls. + resourcePolicy: "" data: # used for storing data (e.g. csv), mounted to /data at both server and worker. enabled: true - storageClassName: "nas-ssd-encrypt" # is not set if empty string + storageClassName: "" # is not set if empty string sizeLimit: 2Gi sizeRequest: 1Gi accessMode: "ReadWriteMany" diff --git a/devenv.nix b/devenv.nix index 6c8e0f1e0..879f3aa00 100644 --- a/devenv.nix +++ b/devenv.nix @@ -203,6 +203,14 @@ in # Too many unrelated (3party dep warnings for now) # PYTHONWARNDEFAULTENCODING = 1; + # Config for sqlite backup (S3) + LOMAS_SERVER_backup__uri = + with config.lomas.garage; + "http://${keyId}:${secretKey}@${host}:${toString port}/bucket/backup"; + + AWS_REQUEST_CHECKSUM_CALCULATION = "when_required"; + AWS_RESPONSE_CHECKSUM_VALIDATION = "when_required"; + # Lomas Runtime LOMAS_SERVER_log_level = "INFO"; LOMAS_SERVER_lomas_log_level = "DEBUG"; diff --git a/server/lomas_server/admin_database/admin_database.py b/server/lomas_server/admin_database/admin_database.py index ddb9ce751..187593c7d 100644 --- a/server/lomas_server/admin_database/admin_database.py +++ b/server/lomas_server/admin_database/admin_database.py @@ -486,3 +486,14 @@ def get_bootstrap_disabled(self) -> bool: Returns: bool: The bootstrap disabled value. False by default if not set in the DB. """ + + @abstractmethod + def backup(self) -> bytes: + """Creates a backup of the database and returns it as a Zip archive. + + The backup is a zip archive containing snapshots of the underlying storage (db, archives). + It can be stored locally or in a S3. + + Returns: + bytes: A zip archive containing the backup. + """ diff --git a/server/lomas_server/admin_database/local_database.py b/server/lomas_server/admin_database/local_database.py index c7920de44..77c652e92 100644 --- a/server/lomas_server/admin_database/local_database.py +++ b/server/lomas_server/admin_database/local_database.py @@ -1,9 +1,12 @@ +import io import json import sqlite3 +import zipfile from collections.abc import Generator from contextlib import AbstractContextManager, closing, contextmanager, nullcontext +from datetime import UTC, datetime from pathlib import Path -from tempfile import SpooledTemporaryFile +from tempfile import SpooledTemporaryFile, TemporaryDirectory from typing import Any, BinaryIO, override from uuid import UUID @@ -964,3 +967,54 @@ def get_bootstrap_disabled(self) -> bool: case _: ADMINDB_ERROR_COUNTER.add(1, {"operation": "invalid_misc_value_return"}) raise InternalServerException("Invalid Query Returns") + + # Backup + ########################################################################### + + def _sqlite_paths_to_backup(self) -> list[Path]: + """Paths of the sqlite files that make up the database state to snapshot.""" + return [self._db_path, self._archives_db_path] + + @staticmethod + def _snapshot_sqlite_file(src_path: Path, dest_path: Path) -> None: + """Writes a point-in-time copy of a live sqlite db to dest_path. + + Args: + src_path (Path): Path of the sqlite database to copy. + dest_path (Path): Path to write the snapshot to. + """ + with ( + closing(sqlite3.connect(src_path)) as src_conn, + closing(sqlite3.connect(dest_path)) as dest_conn, + ): + src_conn.backup(dest_conn) + + @override + @db_span("db.backup", table="admin-db") + def backup(self) -> bytes: + ADMINDB_QUERY_COUNTER.add(1, {"operation": "backup"}) + + with TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + buffer = io.BytesIO() + + with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + for src_path in self._sqlite_paths_to_backup(): + if not src_path.exists(): + # If nothing writtenm, we don't save + continue + + snapshot_path = tmp_path / src_path.name + self._snapshot_sqlite_file(src_path, snapshot_path) + archive.write(snapshot_path, arcname=src_path.name) + + return buffer.getvalue() + + def backup_filename(self) -> str: + """Generates a timestamped filename. + + Returns: + str: filename for the backup. + """ + timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + return f"lomas-admin-backup-{timestamp}.zip" diff --git a/server/lomas_server/administration/dashboard/database_administration.py b/server/lomas_server/administration/dashboard/database_administration.py index 47a2f5d19..dc4f92a4d 100644 --- a/server/lomas_server/administration/dashboard/database_administration.py +++ b/server/lomas_server/administration/dashboard/database_administration.py @@ -526,3 +526,14 @@ def del_all_lomas_users() -> ResultE: lambda: drop_lomas_collection(TK.ARCHIVE), "All Archives deleted.", ) + +st.divider() +st.title("Backup") +st.write("Creates a backup of the admin database (jobs, users, archives).") +if st.button("Backup now", key="btn_backup_admin_db"): + match query_lomas_auth("/backup", httpx2.get): + case Success(backup_info): + size_kb = backup_info["size_bytes"] / 1024 + st.success(f"Backup written to `{backup_info['location']}` ({size_kb:.1f} KB).") + case Failure(e): + st.error(f"Backup failed: {e}") diff --git a/server/lomas_server/models/config.py b/server/lomas_server/models/config.py index 3fe3a7f48..db8fa300e 100644 --- a/server/lomas_server/models/config.py +++ b/server/lomas_server/models/config.py @@ -1,11 +1,15 @@ from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated, Literal, Self +from urllib.parse import unquote from pydantic import ( + AnyUrl, BaseModel, Field, HttpUrl, + UrlConstraints, computed_field, + model_validator, ) from pydantic_core import Url from pydantic_settings import CLI_SUPPRESS, BaseSettings, SettingsConfigDict @@ -32,6 +36,68 @@ class S3CredentialsConfig(PrivateDBCredentials): secret_access_key: str +class BackupS3Config(BaseModel): + """S3 destination for admin database backups.""" + + uri: Annotated[ + AnyUrl, + UrlConstraints(allowed_schemes=["http", "https", "aws", "s3"]), + ] + + @model_validator(mode="after") + def check_uri_content(self) -> Self: + if self.uri.username is None: + raise ValueError("Backup S3 uri is missing access_key_id.") + if self.uri.password is None: + raise ValueError("Backup S3 uri is missing secret_access_key.") + + path = (self.uri.path or "").lstrip("/") + bucket, _, _ = path.partition("/") + if not bucket: + raise ValueError("Backup S3 uri is missing a bucket name.") + return self + + @computed_field + def access_key_id(self) -> str: + return unquote(self.uri.username) + + @computed_field + def secret_access_key(self) -> str: + return unquote(self.uri.password) + + @computed_field + def endpoint_url(self) -> str: + port = f":{self.uri.port}" if self.uri.port else "" + return f"{self.uri.scheme}://{self.uri.host}{port}" + + @computed_field + def bucket(self) -> str: + path = (self.uri.path or "").lstrip("/") + bucket, _, _ = path.partition("/") + return bucket + + @computed_field + def key_prefix(self) -> str: + path = (self.uri.path or "").lstrip("/") + _, _, prefix = path.partition("/") + return f"{prefix.rstrip('/')}/" if prefix else "lomas-backups/" + + +class LocalBackupConfig(BaseModel): + """Local destination for admin database backups.""" + + @model_validator(mode="after") + def is_absolute(self) -> Self: + if not self.local_directory.is_absolute(): + raise ValueError("Use an absolute path.") + return self + + local_directory: Path + + +BackupConfig = LocalBackupConfig | BackupS3Config + + class DexAdminConfig(BaseModel): url: Url = Field(description="Dex OIDC server addresse") @@ -98,6 +164,8 @@ class ServerConfig(Config): clean_admin_database: bool = Field(default=False) + backup: BackupConfig = Field(default=LocalBackupConfig(local_directory="/tmp/lomas-backups")) + data_directory: Path = Field(default=Path("../data")) @computed_field diff --git a/server/lomas_server/models/responses.py b/server/lomas_server/models/responses.py index e9146636c..7355236ec 100644 --- a/server/lomas_server/models/responses.py +++ b/server/lomas_server/models/responses.py @@ -11,3 +11,14 @@ class ConfigResponse(BaseModel): config: ServerConfig = Field(default_factory=ServerConfig) """The server config.""" + + +class BackupResponse(BaseModel): + """Model for response to an admin database backup request.""" + + location: str + """Where the backup was written: a local path, or an s3://bucket/key URI.""" + is_s3: bool + """Whether the backup was uploaded to S3 (True) or written locally (False).""" + size_bytes: int | None = Field(default=None) + """Size in bytes of the backup archive.""" diff --git a/server/lomas_server/routes/routes_admin.py b/server/lomas_server/routes/routes_admin.py index b53ecee97..f12539142 100644 --- a/server/lomas_server/routes/routes_admin.py +++ b/server/lomas_server/routes/routes_admin.py @@ -23,9 +23,10 @@ ) from lomas_server.admin_database.constants import BudgetDBKey from lomas_server.admin_database.local_database import LocalAdminDatabase -from lomas_server.models.responses import ConfigResponse +from lomas_server.models.responses import BackupResponse, ConfigResponse from lomas_server.routes.error_handler import API_ERROR_RESPONSES from lomas_server.routes.utils import get_user_id_from_authenticator +from lomas_server.utils.backup_storage import store_backup router = APIRouter() example_get_admin_db_data_body = Body(EXAMPLE_GET_ADMIN_DB_DATA) @@ -346,6 +347,21 @@ def set_dataset_metadata_admin( db.set_dataset_metadata(dataset_name, file.file) +@router.get("/backup", responses=API_ERROR_RESPONSES) +def backup_admin_database( + request: Request, + _: Annotated[UserId, Security(get_user_id_from_authenticator, scopes=[Scopes.ADMIN])], +) -> BackupResponse: + + db: LocalAdminDatabase = request.app.state.admin_database + config = request.app.state.config + + data = db.backup() + destination = store_backup(data, db.backup_filename(), config.database_directory, config.backup) + + return BackupResponse(location=destination.location, is_s3=destination.is_s3, size_bytes=len(data)) + + @router.get("/bootstrap", responses=API_ERROR_RESPONSES) def get_bootstrap( request: Request, diff --git a/server/lomas_server/utils/backup_storage.py b/server/lomas_server/utils/backup_storage.py new file mode 100644 index 000000000..86dc4cc2b --- /dev/null +++ b/server/lomas_server/utils/backup_storage.py @@ -0,0 +1,63 @@ +from pathlib import Path + +import boto3 + +from lomas_core.models.constants import get_lomas_logger +from lomas_server.models.config import BackupConfig, BackupS3Config, LocalBackupConfig +from lomas_server.models.responses import BackupResponse + +logger = get_lomas_logger(__name__) + + +def store_backup( + data: bytes, filename: str, database_directory: Path, backup_config: BackupConfig +) -> BackupResponse: + """Persists a backup archive either to S3 or to a local directory. + + If `config.s3` is set, the archive is uploaded to that S3 bucket. Otherwise, + it is written to `config.local_directory`, falling back to a 'backups' + subdirectory of the server's `database_directory` if that is not set either. + + Args: + data (bytes): The backup archive content (e.g. a zip file's bytes). + filename (str): The filename to give the backup (e.g. 'lomas-admin-backup-....zip'). + database_directory (Path): The server's admin database directory, used + as a fallback base directory for local backups. + backup_config (BackupConfig): Backup destination configuration. + + Returns: + BackupResponse: Where the backup was written. + """ + match backup_config: + case BackupS3Config(): + return _store_backup_s3(data, filename, backup_config) + case LocalBackupConfig(): + return _store_backup_local( + data, + filename, + backup_config.local_directory or (database_directory / "backups"), # type: ignore [truthy-bool] + ) + + +def _store_backup_local(data: bytes, filename: str, directory: Path) -> BackupResponse: + directory.mkdir(parents=True, exist_ok=True) + dest_path = directory / filename + dest_path.write_bytes(data) + logger.info(f"Wrote admin database backup to {dest_path}.") + return BackupResponse(location=str(dest_path), is_s3=False) + + +def _store_backup_s3(data: bytes, filename: str, s3_config: BackupS3Config) -> BackupResponse: + key = f"{s3_config.key_prefix.rstrip('/')}/{filename}" if s3_config.key_prefix else filename + + client = boto3.client( + "s3", + endpoint_url=str(s3_config.endpoint_url) if s3_config.endpoint_url else None, + aws_access_key_id=s3_config.access_key_id, + aws_secret_access_key=s3_config.secret_access_key, + ) + client.put_object(Bucket=s3_config.bucket, Key=key, Body=data) + + location = f"s3://{s3_config.bucket}/{key}" + logger.info(f"Uploaded admin database backup to {location}.") + return BackupResponse(location=location, is_s3=True)