diff --git a/.env.sample b/.env.sample index cd1d23f..577bcfb 100644 --- a/.env.sample +++ b/.env.sample @@ -198,3 +198,5 @@ REJECT_UNLISTED_RECIPIENT= # NOTE(3wc): Other values are not supported, as compose.yml does not contain # configuration for them. Pull requests welcome! DB_FLAVOR=sqlite + +ENABLE_BACKUPS=true \ No newline at end of file diff --git a/README.md b/README.md index b246f37..c120be2 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Coöp Cloud + [Mailu][Mailu] = 🥳 * **Category**: Apps -* **Status**: 1, alpha +* **Status**: 2, beta * **Image**: [`mailu/*`](https://hub.docker.com/r/mailu/), 4, upstream * **Healthcheck**: No -* **Backups**: No +* **Backups**: Yes * **Email**: N/A * **Tests**: No * **SSO**: No diff --git a/abra.sh b/abra.sh index 43a8597..ebf8add 100644 --- a/abra.sh +++ b/abra.sh @@ -1,3 +1,4 @@ export CERTDUMPER_POST_VERSION=v1 export POSTFIX_OVERRIDE_VERSION=v17 export SENDER_LOGIN_VERSIONS=v2 +export BACKUP_SQLITE_VERSION=v1 diff --git a/backup_sqlite.py b/backup_sqlite.py new file mode 100644 index 0000000..972a7df --- /dev/null +++ b/backup_sqlite.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import os +import sqlite3 +from pathlib import Path + +source_path = Path("/data/main.db") +backup_dir = Path("/data/backup") +temporary_path = backup_dir / "main.db.tmp" +backup_path = backup_dir / "main.db" + +os.umask(0o077) +backup_dir.mkdir(mode=0o700, parents=True, exist_ok=True) +temporary_path.unlink(missing_ok=True) + +try: + # Read-only mode prevents silently creating an empty source database. + source = sqlite3.connect( + f"{source_path.as_uri()}?mode=ro", + uri=True, + timeout=60, + ) + destination = sqlite3.connect(temporary_path) + try: + source.backup(destination, pages=256, sleep=0.1) + result = destination.execute("PRAGMA integrity_check").fetchall() + if result != [("ok",)]: + raise RuntimeError(f"SQLite integrity check failed: {result}") + finally: + destination.close() + source.close() + # Only publish a fully written, verified snapshot. + os.replace(temporary_path, backup_path) + print(f"SQLite backup ready: {backup_path}", flush=True) +except Exception: + temporary_path.unlink(missing_ok=True) + raise diff --git a/compose.yml b/compose.yml index 3eef13d..643f4a0 100644 --- a/compose.yml +++ b/compose.yml @@ -97,6 +97,9 @@ services: - "traefik.http.routers.${STACK_NAME}.tls.certresolver=${LETS_ENCRYPT_ENV}" - "traefik.http.routers.${STACK_NAME}.entrypoints=web-secure" - "coop-cloud.${STACK_NAME}.version=3.2.1+2024.06.66" + - "backupbot.backup=${ENABLE_BACKUPS:-true}" + - "backupbot.backup.volumes.traefik_letsencrypt=false" + - "backupbot.backup.volumes.mailu.path=backup" db: image: redis:8.10.2-alpine @@ -115,6 +118,13 @@ services: - "mailu:/data" networks: - default + configs: + - source: backup_sqlite + target: /usr/local/bin/backup_sqlite.py + mode: 0444 + deploy: + labels: + backupbot.backup.pre-hook: "python3 /usr/local/bin/backup_sqlite.py" imap: image: ghcr.io/mailu/dovecot:2024.06.66 @@ -221,6 +231,10 @@ configs: certdumper_post: name: ${STACK_NAME}_certdumper_post_${CERTDUMPER_POST_VERSION} file: certdumper_post.sh + backup_sqlite: + name: ${STACK_NAME}_backup_sqlite_${BACKUP_SQLITE_VERSION} + file: backup_sqlite.py + secrets: secret_key: