├── src ├── .python-version ├── restic_compose_backup │ ├── __init__.py │ ├── alerts │ │ ├── base.py │ │ ├── __init__.py │ │ ├── discord.py │ │ └── smtp.py │ ├── enums.py │ ├── log.py │ ├── backup_runner.py │ ├── cron.py │ ├── config.py │ ├── commands.py │ ├── utils.py │ ├── restic.py │ ├── containers_db.py │ ├── cli.py │ └── containers.py ├── tests │ ├── unit │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── fixtures.py │ │ ├── test_container_operations.py │ │ ├── test_database_configuration.py │ │ ├── test_volume_configuration.py │ │ └── test_auto_backup_all.py │ ├── integration │ │ ├── __init__.py │ │ ├── README.md │ │ ├── test_label_configuration.py │ │ ├── test_volume_backups.py │ │ ├── test_database_backups.py │ │ ├── test_all_compose_projects.py │ │ └── conftest.py │ └── README.md ├── crontab ├── .dockerignore ├── entrypoint.sh ├── Dockerfile └── pyproject.toml ├── docs ├── requirements.txt ├── Makefile ├── index.rst ├── make.bat ├── guide │ ├── advanced.rst │ ├── install.rst │ ├── rcb.rst │ └── configuration.rst └── conf.py ├── .github ├── logo.png ├── renovate.json └── workflows │ ├── pr-verify.yaml │ └── build-tag-release.yaml ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── swarm-stack.yml ├── LICENSE ├── resources └── stack-back_logo.svg ├── docker-compose.test2.yaml ├── .gitignore ├── stack-back.env.template ├── docker-compose.test.yaml └── README.md /src/.python-version: -------------------------------------------------------------------------------- 1 | 3.14 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | -------------------------------------------------------------------------------- /src/restic_compose_backup/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0" 2 | -------------------------------------------------------------------------------- /src/tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for stack-back""" 2 | -------------------------------------------------------------------------------- /src/crontab: -------------------------------------------------------------------------------- 1 | 10 2 * * * source /.env && rcb backup > /proc/1/fd/1 2 | 3 | -------------------------------------------------------------------------------- /src/tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for stack-back""" 2 | -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawndoc/stack-back/HEAD/.github/logo.png -------------------------------------------------------------------------------- /src/.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .DS_Store 3 | .pytest_cache/ 4 | .ruff_cache/ 5 | .tox/ 6 | .venv/ 7 | *.egg-info/ 8 | tests/ -------------------------------------------------------------------------------- /src/restic_compose_backup/alerts/base.py: -------------------------------------------------------------------------------- 1 | class BaseAlert: 2 | name = None 3 | 4 | def create_from_env(self): 5 | return None 6 | 7 | @property 8 | def properly_configured(self) -> bool: 9 | return False 10 | 11 | def send(self, subject: str = None, body: str = None, alert_type: str = None): 12 | pass 13 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":approveMajorUpdates", 6 | ":configMigration", 7 | ":docker", 8 | ":prHourlyLimitNone", 9 | ":timezone(America/Chicago)", 10 | "helpers:pinGitHubActionDigests" 11 | ] 12 | } -------------------------------------------------------------------------------- /src/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Dump all env vars so we can source them in cron jobs 4 | rcb dump-env > /.env 5 | 6 | # Write crontab 7 | rcb crontab > crontab 8 | 9 | # Start cron in the background and capture its PID 10 | crontab crontab 11 | crond -f & 12 | CRON_PID=$! 13 | 14 | # Trap termination signals and kill the cron process 15 | trap 'kill $CRON_PID; exit 0' TERM INT 16 | 17 | # Wait for cron and handle signals 18 | wait $CRON_PID 19 | -------------------------------------------------------------------------------- /src/restic_compose_backup/enums.py: -------------------------------------------------------------------------------- 1 | # Labels 2 | LABEL_VOLUMES_ENABLED = "stack-back.volumes" 3 | LABEL_VOLUMES_INCLUDE = "stack-back.volumes.include" 4 | LABEL_VOLUMES_EXCLUDE = "stack-back.volumes.exclude" 5 | LABEL_STOP_DURING_BACKUP = "stack-back.volumes.stop-during-backup" 6 | 7 | LABEL_MYSQL_ENABLED = "stack-back.mysql" 8 | LABEL_POSTGRES_ENABLED = "stack-back.postgres" 9 | LABEL_MARIADB_ENABLED = "stack-back.mariadb" 10 | 11 | LABEL_BACKUP_PROCESS = "stack-back.process" 12 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:0.9.13 AS uv-builder 2 | 3 | FROM restic/restic:0.18.1 4 | 5 | RUN apk update && apk add dcron 6 | 7 | COPY --from=uv-builder /uv /uvx /bin/ 8 | 9 | ADD . /restic-compose-backup 10 | WORKDIR /restic-compose-backup 11 | 12 | RUN uv python install $(cat .python-version) 13 | RUN uv sync --locked 14 | ENV PATH="/restic-compose-backup/.venv/bin:${PATH}" 15 | 16 | ENV XDG_CACHE_HOME=/cache 17 | 18 | ENTRYPOINT [] 19 | CMD ["./entrypoint.sh"] 20 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.10" 12 | 13 | # Build documentation in the "docs/" directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | # Optionally build your docs in additional formats such as PDF and ePub 18 | # formats: 19 | # - pdf 20 | # - epub 21 | 22 | python: 23 | install: 24 | - requirements: docs/requirements.txt 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | - repo: local 8 | hooks: 9 | - id: block-python-version 10 | name: Block accidental commits to .python-version 11 | entry: > 12 | bash -c ' 13 | if git diff --cached --name-only | grep -q "^\.python-version$"; then 14 | echo "✋ Do you really want to change .python-version? If so, run again with SKIP=block-python-version"; exit 1; 15 | fi 16 | ' 17 | language: system 18 | stages: [pre-commit] -------------------------------------------------------------------------------- /swarm-stack.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | mariadb: 5 | image: mariadb:10 6 | labels: 7 | stack-back.mariadb: "true" 8 | environment: 9 | - MYSQL_ROOT_PASSWORD=my-secret-pw 10 | - MYSQL_DATABASE=mydb 11 | - MYSQL_USER=myuser 12 | - MYSQL_PASSWORD=mypassword 13 | networks: 14 | - global 15 | volumes: 16 | - mariadbdata:/var/lib/mysql 17 | files: 18 | image: nginx:1.17-alpine 19 | labels: 20 | stack-back.volumes: "true" 21 | volumes: 22 | - files:/srv/files 23 | 24 | volumes: 25 | mariadbdata: 26 | files: 27 | 28 | networks: 29 | global: 30 | external: true 31 | -------------------------------------------------------------------------------- /src/tests/README.md: -------------------------------------------------------------------------------- 1 | # Test Organization 2 | 3 | This directory contains all tests for stack-back, organized into two categories: 4 | 5 | ## Running Tests 6 | 7 | ### Unit Tests Only (Fast - ~0.1s) 8 | ```bash 9 | uv run pytest -m unit 10 | ``` 11 | 12 | ### Integration Tests Only (Slow - ~3 minutes) 13 | ```bash 14 | uv run pytest -m integration 15 | ``` 16 | 17 | ### All Tests 18 | ```bash 19 | uv run pytest 20 | ``` 21 | 22 | ## Test Markers 23 | 24 | Tests are marked using pytest markers: 25 | - `@pytest.mark.unit` - Fast unit tests with mocks 26 | - `@pytest.mark.integration` - Slower integration tests requiring Docker 27 | 28 | These markers are configured in `pyproject.toml`. 29 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /src/restic_compose_backup/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | logger = logging.getLogger("restic_compose_backup") 5 | 6 | DEFAULT_LOG_LEVEL = logging.INFO 7 | LOG_LEVELS = { 8 | "debug": logging.DEBUG, 9 | "info": logging.INFO, 10 | "warning": logging.WARNING, 11 | "error": logging.ERROR, 12 | } 13 | 14 | 15 | def setup(level: str = "warning"): 16 | """Set up logging""" 17 | level = level or "" 18 | level = LOG_LEVELS.get(level.lower(), DEFAULT_LOG_LEVEL) 19 | logger.setLevel(level) 20 | 21 | ch = logging.StreamHandler(stream=sys.stdout) 22 | ch.setLevel(level) 23 | ch.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s: %(message)s")) 24 | logger.addHandler(ch) 25 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. stack-back documentation master file, created by 2 | sphinx-quickstart on Thu Dec 5 01:34:58 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to stack-back's documentation! 7 | ================================================= 8 | 9 | Simple backup with restic_ for docker-compose setups. 10 | 11 | .. toctree:: 12 | :maxdepth: 3 13 | :caption: Contents: 14 | 15 | guide/install 16 | guide/configuration 17 | guide/rcb 18 | guide/advanced 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | 27 | .. _restic: https://restic.net/ 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/guide/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced 2 | -------- 3 | 4 | Currently work in progress. These are only notes :D 5 | 6 | Temp Notes 7 | ~~~~~~~~~~ 8 | 9 | * Quick setup guide from start to end 10 | * we group snapshots by path when forgetting 11 | * explain rcb commands 12 | * examples of using restic directly 13 | * Explain what happens during backup process 14 | * Explain the backup process container 15 | * cache directory 16 | * Not displaying passwords in logs 17 | 18 | Inner workings 19 | ~~~~~~~~~~~~~~ 20 | 21 | * Each service in the compose setup is configured with a label 22 | to enable backup of volumes or databases 23 | * When backup starts a new instance of the container is created 24 | mapping in all the needed volumes. 25 | * Volumes are mounted to `/volumes//` 26 | in the backup process container. `/volumes` is pushed into restic 27 | * Databases are backed up from stdin / dumps into restic using path 28 | `/databases//dump.sql` 29 | * Cron triggers backup at 2AM every day 30 | -------------------------------------------------------------------------------- /src/tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | """Shared fixtures and test base classes for unit tests""" 2 | 3 | import os 4 | import unittest 5 | from unittest import mock 6 | 7 | os.environ["RESTIC_REPOSITORY"] = "test" 8 | os.environ["RESTIC_PASSWORD"] = "password" 9 | 10 | from . import fixtures 11 | 12 | 13 | class BaseTestCase(unittest.TestCase): 14 | """Base test case for unit tests with common setup""" 15 | 16 | @classmethod 17 | def setUpClass(cls): 18 | cls.backup_hash = fixtures.generate_sha256() 19 | 20 | cls.hostname_patcher = mock.patch( 21 | "socket.gethostname", return_value=cls.backup_hash[:8] 22 | ) 23 | cls.hostname_patcher.start() 24 | 25 | @classmethod 26 | def tearDownClass(cls): 27 | cls.hostname_patcher.stop() 28 | 29 | def createContainers(self): 30 | """Create a basic backup container for tests""" 31 | return [ 32 | { 33 | "id": self.backup_hash, 34 | "service": "backup", 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /src/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "restic_compose_backup" 3 | version = "0.0.0" 4 | description = "Backup Docker Compose volumes and databases with Restic" 5 | requires-python = ">=3.12" 6 | dependencies = [ 7 | 'docker>=7.1.0', 8 | ] 9 | 10 | [dependency-groups] 11 | dev = [ 12 | "black>=25.1.0", 13 | "pre-commit>=4.2.0", 14 | "pytest>=7.4.0", 15 | "pytest-cov>=7.0.0", 16 | "ruff>=0.12.3", 17 | ] 18 | 19 | [tool.pytest.ini_options] 20 | pythonpath = ["."] 21 | testpaths = ["tests"] 22 | python_files = ["tests.py", "test_*.py"] 23 | python_classes = ["*Tests"] 24 | python_functions = ["test_*"] 25 | markers = [ 26 | "integration: marks tests as integration tests", 27 | "unit: marks tests as unit tests", 28 | ] 29 | 30 | [project.scripts] 31 | restic-compose-backup = "restic_compose_backup.cli:main" 32 | rcb = "restic_compose_backup.cli:main" 33 | 34 | [build-system] 35 | requires = ["setuptools"] 36 | build-backend = "setuptools.build_meta" 37 | 38 | [tool.setuptools.packages.find] 39 | where = ["."] 40 | include = ["restic_compose_backup"] 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Zetta.IO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/tests/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This directory contains integration tests for stack-back that use real Docker containers and docker-compose. 4 | 5 | ## Test Coverage 6 | 7 | ### Volume Backups (`test_volume_backups.py`) 8 | - Backing up bind mounts 9 | - Backing up named Docker volumes 10 | - Restoring data from backups 11 | - Multiple backup snapshots 12 | - Excluded services 13 | 14 | ### Database Backups (`test_database_backups.py`) 15 | - MySQL backup and restore 16 | - MariaDB backup and restore 17 | - PostgreSQL backup and restore 18 | - Multiple databases in a single backup 19 | - Incremental backups after changes 20 | - Database health checks 21 | 22 | ### Label Configuration (`test_label_configuration.py`) 23 | - Volume include patterns (`stack-back.volumes.include`) 24 | - Volume exclude patterns (`stack-back.volumes.exclude`) 25 | - Service exclusion (`stack-back.volumes=false`) 26 | - Database-specific labels 27 | - Multiple mount filtering 28 | 29 | ## Requirements 30 | 31 | - Docker daemon running 32 | - Docker compose plugin installed 33 | - Sufficient disk space for test containers and volumes 34 | -------------------------------------------------------------------------------- /resources/stack-back_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | stack-back 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/guide/install.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | 5 | Install 6 | ------- 7 | 8 | stack-back is available on GitHub Container Registry `ghcr.io`_. 9 | 10 | .. code:: 11 | 12 | docker pull ghcr.io/lawndoc/stack-back 13 | 14 | Optionally it can be built from source using the github_ repository. 15 | 16 | .. code:: bash 17 | 18 | git clone https://github.com/lawndoc/stack-back.git 19 | cd stack-back 20 | # Build and tag the image locally 21 | docker build src/ --tag stack-back 22 | 23 | Bug reports and issues 24 | ---------------------- 25 | 26 | Please report bugs an issues on github_ 27 | 28 | Development setup 29 | ----------------- 30 | 31 | Getting started with local development is fairly simple. 32 | The github_ repository contains a simple ``docker-compose.yaml`` 33 | 34 | .. code:: bash 35 | 36 | docker-compose up -d 37 | # Enter the container in sh 38 | docker-compose run --rm backup sh 39 | 40 | The dev compose setup maps in the source from the host 41 | and the spawned backup container will inherit all 42 | the volumes from the backup service ensuring code changes 43 | propagates during development. 44 | 45 | Set up a local venv and install the package in development mode:: 46 | 47 | python -m venv .venv 48 | . .venv/bin/activate 49 | pip install -e ./src 50 | 51 | 52 | .. _github: https://github.com/lawndoc/stack-back 53 | -------------------------------------------------------------------------------- /src/restic_compose_backup/alerts/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from restic_compose_backup.alerts.smtp import SMTPAlert 4 | from restic_compose_backup.alerts.discord import DiscordWebhookAlert 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | ALERT_INFO = ("INFO",) 9 | ALERT_ERROR = "ERROR" 10 | ALERT_TYPES = [ALERT_INFO, ALERT_ERROR] 11 | BACKENDS = [SMTPAlert, DiscordWebhookAlert] 12 | 13 | 14 | def send(subject: str = None, body: str = None, alert_type: str = "INFO"): 15 | """Send alert to all configured backends""" 16 | alert_classes = configured_alert_types() 17 | for instance in alert_classes: 18 | logger.info("Configured: %s", instance.name) 19 | try: 20 | instance.send( 21 | subject=f"[{alert_type}] {subject}", 22 | body=body, 23 | ) 24 | except Exception as ex: 25 | logger.error( 26 | "Exception raised when sending alert [%s]: %s", instance.name, ex 27 | ) 28 | logger.exception(ex) 29 | 30 | if len(alert_classes) == 0: 31 | logger.info("No alerts configured") 32 | 33 | 34 | def configured_alert_types(): 35 | """Returns a list of configured alert class instances""" 36 | logger.debug("Getting alert backends") 37 | entires = [] 38 | 39 | for cls in BACKENDS: 40 | instance = cls.create_from_env() 41 | logger.debug( 42 | "Alert backend '%s' configured: %s", cls.name, instance is not None 43 | ) 44 | if instance: 45 | entires.append(instance) 46 | 47 | return entires 48 | -------------------------------------------------------------------------------- /src/restic_compose_backup/alerts/discord.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | import requests 5 | from restic_compose_backup.alerts.base import BaseAlert 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class DiscordWebhookAlert(BaseAlert): 11 | name = "discord_webhook" 12 | success_codes = [200] 13 | 14 | def __init__(self, webhook_url): 15 | self.url = webhook_url 16 | 17 | @classmethod 18 | def create_from_env(cls): 19 | instance = cls(os.environ.get("DISCORD_WEBHOOK")) 20 | 21 | if instance.properly_configured: 22 | return instance 23 | 24 | return None 25 | 26 | @property 27 | def properly_configured(self) -> bool: 28 | return isinstance(self.url, str) and self.url.startswith("https://") 29 | 30 | def send(self, subject: str = None, body: str = None, alert_type: str = None): 31 | """Send basic webhook request. Max embed size is 6000""" 32 | logger.info("Triggering discord webhook") 33 | # NOTE: The title size is 2048 34 | # The max description size is 2048 35 | # Total embed size limit is 6000 characters (per embed) 36 | data = { 37 | "embeds": [ 38 | { 39 | "title": subject[-256:], 40 | "description": body[-2048:] if body else "", 41 | }, 42 | ] 43 | } 44 | response = requests.post(self.url, params={"wait": True}, json=data) 45 | if response.status_code not in self.success_codes: 46 | logger.error( 47 | "Discord webhook failed: %s: %s", response.status_code, response.content 48 | ) 49 | else: 50 | logger.info("Discord webhook successful") 51 | -------------------------------------------------------------------------------- /docker-compose.test2.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | # Secondary project web service with volumes 3 | secondary_web: 4 | image: nginx:alpine 5 | labels: 6 | stack-back.volumes: true 7 | stack-back.volumes.include: "secondary_web" 8 | volumes: 9 | - secondary_web_data:/usr/share/nginx/html 10 | - ./test_data/secondary_web:/srv/content 11 | networks: 12 | - secondary-network 13 | 14 | # Secondary project MySQL database 15 | secondary_mysql: 16 | image: mysql:8 17 | labels: 18 | stack-back.mysql: true 19 | environment: 20 | - MYSQL_ROOT_PASSWORD=secondary_root_password 21 | - MYSQL_DATABASE=secondary_db 22 | - MYSQL_USER=secondary_user 23 | - MYSQL_PASSWORD=secondary_password 24 | volumes: 25 | - secondary_mysql_data:/var/lib/mysql 26 | networks: 27 | - secondary-network 28 | healthcheck: 29 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-psecondary_root_password"] 30 | interval: 5s 31 | timeout: 3s 32 | retries: 10 33 | 34 | # Secondary project PostgreSQL database 35 | secondary_postgres: 36 | image: postgres:17 37 | labels: 38 | stack-back.postgres: true 39 | environment: 40 | - POSTGRES_USER=secondary_user 41 | - POSTGRES_PASSWORD=secondary_password 42 | - POSTGRES_DB=secondary_db 43 | volumes: 44 | - secondary_postgres_data:/var/lib/postgresql/data 45 | networks: 46 | - secondary-network 47 | healthcheck: 48 | test: ["CMD-SHELL", "pg_isready -U secondary_user -d secondary_db"] 49 | interval: 5s 50 | timeout: 3s 51 | retries: 10 52 | 53 | volumes: 54 | secondary_web_data: 55 | secondary_mysql_data: 56 | secondary_postgres_data: 57 | 58 | networks: 59 | secondary-network: 60 | driver: bridge 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project specific 2 | backup.log 3 | 4 | # Environment files (potential secrets) 5 | *.env 6 | 7 | # IDEs 8 | .vscode 9 | .idea 10 | 11 | # python 12 | __pycache__ 13 | *.egg-info/ 14 | .ruff_cache/ 15 | 16 | # Virtualenvs 17 | env 18 | .venv 19 | venv 20 | 21 | # misc 22 | /private/ 23 | restic_data/ 24 | restic_cache/ 25 | alerts.env 26 | 27 | # build 28 | build/ 29 | docs/_build 30 | dist 31 | 32 | # tests 33 | /test_*/ 34 | 35 | # Python bytecode 36 | *.py[cod] 37 | *$py.class 38 | 39 | # C extensions 40 | *.so 41 | 42 | # Distribution / packaging 43 | .Python 44 | develop-eggs/ 45 | dist/ 46 | downloads/ 47 | eggs/ 48 | .eggs/ 49 | parts/ 50 | sdist/ 51 | var/ 52 | *.egg 53 | .installed.cfg 54 | MANIFEST 55 | 56 | # PyInstaller 57 | *.manifest 58 | *.spec 59 | 60 | # Installer logs 61 | pip-log.txt 62 | pip-delete-this-directory.txt 63 | 64 | # Unit test / coverage reports 65 | htmlcov/ 66 | .nox/ 67 | .tox/ 68 | .pytest_cache/ 69 | .coverage 70 | .coverage.* 71 | .cache 72 | nosetests.xml 73 | coverage.xml 74 | *.cover 75 | *.py,cover 76 | .hypothesis/ 77 | 78 | # Translations 79 | *.mo 80 | *.pot 81 | 82 | # Django stuff 83 | *.log 84 | local_settings.py 85 | db.sqlite3 86 | db.sqlite3-journal 87 | 88 | # Flask stuff 89 | instance/ 90 | .webassets-cache 91 | 92 | # Sphinx documentation 93 | docs/_build/ 94 | 95 | # Jupyter Notebook 96 | .ipynb_checkpoints 97 | 98 | # IPython 99 | profile_default/ 100 | ipython_config.py 101 | 102 | # pipenv 103 | Pipfile.lock 104 | 105 | # poetry 106 | poetry.lock 107 | 108 | # PEP 582 109 | __pypackages__/ 110 | 111 | # Celery 112 | celerybeat-schedule 113 | celerybeat.pid 114 | 115 | # IDE settings 116 | .spyproject 117 | .ropeproject 118 | 119 | # Caches 120 | .mypy_cache/ 121 | .pyre/ 122 | -------------------------------------------------------------------------------- /src/tests/unit/fixtures.py: -------------------------------------------------------------------------------- 1 | """Generate test fixtures""" 2 | 3 | from datetime import datetime 4 | import hashlib 5 | import string 6 | import random 7 | 8 | 9 | def generate_sha256(): 10 | """Generate a unique sha256""" 11 | h = hashlib.sha256() 12 | h.update(str(datetime.now().timestamp()).encode()) 13 | return h.hexdigest() 14 | 15 | 16 | def containers(project="default", containers=[]): 17 | """ 18 | Args: 19 | project (str): Name of the compose project 20 | containers (dict): 21 | { 22 | 'containers: [ 23 | 'id': 'something' 24 | 'service': 'service_name', 25 | 'image': 'image:tag', 26 | 'mounts: [{ 27 | 'Source': '/home/user/stuff', 28 | 'Destination': '/srv/stuff', 29 | 'Type': 'bind' / 'volume' 30 | }], 31 | ] 32 | } 33 | """ 34 | 35 | def wrapper(*args, **kwargs): 36 | return [ 37 | { 38 | "Id": container.get("id", generate_sha256()), 39 | "Name": container.get("service") 40 | + "_" 41 | + "".join(random.choice(string.ascii_lowercase) for i in range(16)), 42 | "Config": { 43 | "Image": container.get("image", "image:latest"), 44 | "Labels": { 45 | "com.docker.compose.oneoff": "False", 46 | "com.docker.compose.project": project, 47 | "com.docker.compose.service": container["service"], 48 | **container.get("labels", {}), 49 | }, 50 | }, 51 | "Mounts": container.get("mounts", []), 52 | "State": { 53 | "Status": "running", 54 | "Running": True, 55 | }, 56 | } 57 | for container in containers 58 | ] 59 | 60 | return wrapper 61 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'stack-back' 21 | copyright = '2019, Zetta.IO Technology AS' 22 | author = 'Zetta.IO Technology AS' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = "0.0.0" 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | master_doc = 'index' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = 'sphinx_rtd_theme' 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ['_static'] 57 | -------------------------------------------------------------------------------- /src/restic_compose_backup/backup_runner.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from restic_compose_backup import utils 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def run( 10 | image: str = None, 11 | command: str = None, 12 | volumes: dict = None, 13 | environment: dict = None, 14 | labels: dict = None, 15 | source_container_id: str = None, 16 | ): 17 | logger.info("Starting backup container") 18 | client = utils.docker_client() 19 | 20 | container = client.containers.run( 21 | image, 22 | command, 23 | labels=labels, 24 | detach=True, 25 | environment=environment + ["BACKUP_PROCESS_CONTAINER=true"], 26 | volumes=volumes, 27 | network_mode=f"container:{source_container_id}", # reuse original container network for optional access to docker proxy 28 | working_dir=os.getcwd(), 29 | tty=True, 30 | ) 31 | 32 | logger.info("Backup process container: %s", container.name) 33 | log_generator = container.logs(stdout=True, stderr=True, stream=True, follow=True) 34 | 35 | def readlines(stream): 36 | """Read stream line by line""" 37 | while True: 38 | line = "" 39 | while True: 40 | try: 41 | # Make log streaming work for docker ce 17 and 18. 42 | # For some reason strings are returned instead if bytes. 43 | data = next(stream) 44 | if isinstance(data, bytes): 45 | line += data.decode() 46 | elif isinstance(data, str): 47 | line += data 48 | if line.endswith("\n"): 49 | break 50 | except StopIteration: 51 | break 52 | if line: 53 | yield line.rstrip() 54 | else: 55 | break 56 | 57 | with open("backup.log", "w") as fd: 58 | for line in readlines(log_generator): 59 | fd.write(line) 60 | fd.write("\n") 61 | print(line) 62 | 63 | container.wait() 64 | container.reload() 65 | logger.debug("Container ExitCode %s", container.attrs["State"]["ExitCode"]) 66 | container.remove() 67 | 68 | return container.attrs["State"]["ExitCode"] 69 | -------------------------------------------------------------------------------- /.github/workflows/pr-verify.yaml: -------------------------------------------------------------------------------- 1 | name: "PR Build and Test" 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - .gitignore 7 | - .github/renovate.json 8 | - .pre-commit-config.yaml 9 | - LICENSE 10 | - README.MD 11 | - docs/ 12 | workflow_dispatch: 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | unit-tests: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 26 | 27 | - name: Run tests 28 | run: | 29 | uv sync --locked --directory src/ 30 | uv run --directory src/ pytest -m "unit" -v 31 | 32 | integration-tests: 33 | runs-on: ubuntu-latest 34 | needs: unit-tests 35 | steps: 36 | - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 37 | 38 | - name: Install uv 39 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 40 | 41 | - name: Run integration tests 42 | run: | 43 | uv sync --locked --directory src/ 44 | uv run --directory src/ pytest -m "integration" -v 45 | 46 | code-quality: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 50 | - uses: astral-sh/ruff-action@57714a7c8a2e59f32539362ba31877a1957dded1 # v3.5.1 51 | with: 52 | args: "format --check --diff" 53 | src: ./src 54 | version-file: ./src/pyproject.toml 55 | 56 | build: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Checkout repository 60 | uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 61 | 62 | - name: Set up QEMU 63 | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 64 | 65 | - name: Set up Docker Buildx 66 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 67 | 68 | - name: Test build Docker image 69 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 70 | with: 71 | context: src/ 72 | platforms: linux/amd64,linux/arm64 73 | push: false 74 | tags: | 75 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} 76 | -------------------------------------------------------------------------------- /src/restic_compose_backup/alerts/smtp.py: -------------------------------------------------------------------------------- 1 | import os 2 | import smtplib 3 | import logging 4 | from email.mime.text import MIMEText 5 | 6 | from restic_compose_backup.alerts.base import BaseAlert 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class SMTPAlert(BaseAlert): 12 | name = "smtp" 13 | 14 | def __init__(self, host, port, user, password, to): 15 | self.host = host 16 | self.port = port 17 | self.user = user 18 | self.password = password or "" 19 | self.to = to 20 | 21 | @classmethod 22 | def create_from_env(cls): 23 | instance = cls( 24 | os.environ.get("EMAIL_HOST"), 25 | os.environ.get("EMAIL_PORT"), 26 | os.environ.get("EMAIL_HOST_USER"), 27 | os.environ.get("EMAIL_HOST_PASSWORD"), 28 | (os.environ.get("EMAIL_SEND_TO") or "").split(","), 29 | ) 30 | if instance.properly_configured: 31 | return instance 32 | 33 | return None 34 | 35 | @property 36 | def properly_configured(self) -> bool: 37 | return self.host and self.port and self.user and len(self.to) > 0 38 | 39 | def send(self, subject: str = None, body: str = None, alert_type: str = "INFO"): 40 | msg = MIMEText(body) 41 | msg["Subject"] = f"[{alert_type}] {subject}" 42 | msg["From"] = self.user 43 | msg["To"] = ", ".join(self.to) 44 | 45 | try: 46 | logger.info("Connecting to %s port %s", self.host, self.port) 47 | if self.port == "465": 48 | server = smtplib.SMTP_SSL(self.host, self.port) 49 | else: 50 | server = smtplib.SMTP(self.host, self.port) 51 | if self.port == "587": 52 | try: 53 | server.starttls() 54 | except smtplib.SMTPHeloError: 55 | logger.error( 56 | "The server didn't reply properly to the HELO greeting. Email not sent." 57 | ) 58 | return 59 | except smtplib.SMTPNotSupportedError: 60 | logger.error("STARTTLS not supported on server. Email not sent.") 61 | return 62 | server.ehlo() 63 | server.login(self.user, self.password) 64 | server.sendmail(self.user, self.to, msg.as_string()) 65 | logger.info("Email sent") 66 | except Exception as ex: 67 | logger.exception(ex) 68 | finally: 69 | server.close() 70 | -------------------------------------------------------------------------------- /src/restic_compose_backup/cron.py: -------------------------------------------------------------------------------- 1 | """ 2 | # ┌───────────── minute (0 - 59) 3 | # │ ┌───────────── hour (0 - 23) 4 | # │ │ ┌───────────── day of the month (1 - 31) 5 | # │ │ │ ┌───────────── month (1 - 12) 6 | # │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday; 7 | # │ │ │ │ │ 7 is also Sunday on some systems) 8 | # │ │ │ │ │ 9 | # │ │ │ │ │ 10 | # * * * * * command to execute 11 | """ 12 | 13 | QUOTE_CHARS = ['"', "'"] 14 | 15 | 16 | def generate_crontab(config): 17 | """Generate a crontab entry for running backup job""" 18 | backup_command = config.cron_command.strip() 19 | backup_schedule = config.cron_schedule 20 | 21 | if backup_schedule: 22 | backup_schedule = backup_schedule.strip() 23 | backup_schedule = strip_quotes(backup_schedule) 24 | if not validate_schedule(backup_schedule): 25 | backup_schedule = config.default_crontab_schedule 26 | else: 27 | backup_schedule = config.default_crontab_schedule 28 | 29 | crontab = f"{backup_schedule} {backup_command}\n" 30 | 31 | maintenance_command = config.maintenance_command.strip() 32 | maintenance_schedule = config.maintenance_schedule 33 | 34 | if maintenance_schedule: 35 | maintenance_schedule = maintenance_schedule.strip() 36 | maintenance_schedule = strip_quotes(maintenance_schedule) 37 | if validate_schedule(maintenance_schedule): 38 | crontab += f"{maintenance_schedule} {maintenance_command}\n" 39 | 40 | return crontab 41 | 42 | 43 | def validate_schedule(schedule: str): 44 | """Validate crontab format""" 45 | parts = schedule.split() 46 | if len(parts) != 5: 47 | return False 48 | 49 | for p in parts: 50 | if p != "*" and not p.isdigit(): 51 | return False 52 | 53 | minute, hour, day, month, weekday = parts 54 | try: 55 | validate_field(minute, 0, 59) 56 | validate_field(hour, 0, 23) 57 | validate_field(day, 1, 31) 58 | validate_field(month, 1, 12) 59 | validate_field(weekday, 0, 6) 60 | except ValueError: 61 | return False 62 | 63 | return True 64 | 65 | 66 | def validate_field(value, min, max): 67 | if value == "*": 68 | return 69 | 70 | i = int(value) 71 | return min <= i <= max 72 | 73 | 74 | def strip_quotes(value: str): 75 | """Strip enclosing single or double quotes if present""" 76 | if value[0] in QUOTE_CHARS: 77 | value = value[1:] 78 | if value[-1] in QUOTE_CHARS: 79 | value = value[:-1] 80 | 81 | return value 82 | -------------------------------------------------------------------------------- /stack-back.env.template: -------------------------------------------------------------------------------- 1 | # DOCKER_HOST=unix://tmp/docker.sock 2 | # DOCKER_TLS_VERIFY=1 3 | # DOCKER_CERT_PATH='' 4 | 5 | # SWARM_MODE= 6 | INCLUDE_PROJECT_NAME=false 7 | EXCLUDE_BIND_MOUNTS=false 8 | INCLUDE_ALL_COMPOSE_PROJECTS=false 9 | AUTO_BACKUP_ALL=true 10 | 11 | RESTIC_REPOSITORY=/restic_backups 12 | RESTIC_PASSWORD=thisdecryptsyourbackupsdontloseit 13 | 14 | RESTIC_KEEP_DAILY=7 15 | RESTIC_KEEP_WEEKLY=4 16 | RESTIC_KEEP_MONTHLY=12 17 | RESTIC_KEEP_YEARLY=3 18 | 19 | LOG_LEVEL=info 20 | CRON_SCHEDULE=0 2 * * * 21 | 22 | # EMAIL_HOST= 23 | # EMAIL_PORT= 24 | # EMAIL_HOST_USER= 25 | # EMAIL_HOST_PASSWORD= 26 | # EMAIL_SEND_TO= 27 | 28 | # DISCORD_WEBHOOK= 29 | 30 | # Various env vars for restic : https://restic.readthedocs.io/en/stable/040_backup.html#environment-variables 31 | # RESTIC_REPOSITORY Location of repository (replaces -r) 32 | # RESTIC_PASSWORD_FILE Location of password file (replaces --password-file) 33 | # RESTIC_PASSWORD The actual password for the repository 34 | # 35 | # AWS_ACCESS_KEY_ID Amazon S3 access key ID 36 | # AWS_SECRET_ACCESS_KEY Amazon S3 secret access key 37 | # 38 | # ST_AUTH Auth URL for keystone v1 authentication 39 | # ST_USER Username for keystone v1 authentication 40 | # ST_KEY Password for keystone v1 authentication 41 | # 42 | # OS_AUTH_URL Auth URL for keystone authentication 43 | # OS_REGION_NAME Region name for keystone authentication 44 | # OS_USERNAME Username for keystone authentication 45 | # OS_PASSWORD Password for keystone authentication 46 | # OS_TENANT_ID Tenant ID for keystone v2 authentication 47 | # OS_TENANT_NAME Tenant name for keystone v2 authentication 48 | # 49 | # OS_USER_DOMAIN_NAME User domain name for keystone authentication 50 | # OS_PROJECT_NAME Project name for keystone authentication 51 | # OS_PROJECT_DOMAIN_NAME PRoject domain name for keystone authentication 52 | # 53 | # OS_STORAGE_URL Storage URL for token authentication 54 | # OS_AUTH_TOKEN Auth token for token authentication 55 | # 56 | # B2_ACCOUNT_ID Account ID or applicationKeyId for Backblaze B2 57 | # B2_ACCOUNT_KEY Account Key or applicationKey for Backblaze B2 58 | # 59 | # AZURE_ACCOUNT_NAME Account name for Azure 60 | # AZURE_ACCOUNT_KEY Account key for Azure 61 | # 62 | # GOOGLE_PROJECT_ID Project ID for Google Cloud Storage 63 | # GOOGLE_APPLICATION_CREDENTIALS Application Credentials for Google Cloud Storage (e.g. $HOME/.config/gs-secret-restic-key.json) 64 | # 65 | # RCLONE_BWLIMIT rclone bandwidth limit 66 | 67 | -------------------------------------------------------------------------------- /src/restic_compose_backup/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class Config: 8 | default_backup_command = "source /.env && rcb backup > /proc/1/fd/1" 9 | default_crontab_schedule = "0 2 * * *" 10 | default_maintenance_command = "source /.env && rcb maintenance > /proc/1/fd/1" 11 | 12 | """Bag for config values""" 13 | 14 | def __init__(self, check=True): 15 | # Mandatory values 16 | self.repository = os.environ.get("RESTIC_REPOSITORY") 17 | self.password = os.environ.get("RESTIC_REPOSITORY") 18 | self.check_with_cache = os.environ.get("CHECK_WITH_CACHE") or False 19 | self.cron_schedule = ( 20 | os.environ.get("CRON_SCHEDULE") or self.default_crontab_schedule 21 | ) 22 | self.cron_command = ( 23 | os.environ.get("CRON_COMMAND") or self.default_backup_command 24 | ) 25 | self.maintenance_schedule = os.environ.get("MAINTENANCE_SCHEDULE") or "" 26 | self.maintenance_command = ( 27 | os.environ.get("MAINTENANCE_COMMAND") or self.default_maintenance_command 28 | ) 29 | self.swarm_mode = os.environ.get("SWARM_MODE") or False 30 | self.exclude_bind_mounts = os.environ.get("EXCLUDE_BIND_MOUNTS") or False 31 | self.include_all_compose_projects = ( 32 | os.environ.get("INCLUDE_ALL_COMPOSE_PROJECTS") or False 33 | ) 34 | self.include_project_name = ( 35 | os.environ.get("INCLUDE_ALL_COMPOSE_PROJECTS") 36 | or os.environ.get("INCLUDE_PROJECT_NAME") 37 | or False 38 | ) 39 | self.include_all_volumes = os.environ.get("INCLUDE_ALL_VOLUMES") or False 40 | if self.include_all_volumes: 41 | logger.warning( 42 | "INCLUDE_ALL_VOLUMES will be deprecated in the future in favor of AUTO_BACKUP_ALL. Please update your environment variables." 43 | ) 44 | self.auto_backup_all = ( 45 | os.environ.get("AUTO_BACKUP_ALL") or self.include_all_volumes 46 | ) 47 | 48 | # Log 49 | self.log_level = os.environ.get("LOG_LEVEL") 50 | 51 | # forget / keep 52 | self.keep_daily = ( 53 | os.environ.get("RESTIC_KEEP_DAILY") or os.environ.get("KEEP_DAILY") or "7" 54 | ) 55 | self.keep_weekly = ( 56 | os.environ.get("RESTIC_KEEP_WEEKLY") or os.environ.get("KEEP_WEEKLY") or "4" 57 | ) 58 | self.keep_monthly = ( 59 | os.environ.get("RESTIC_KEEP_MONTHLY") 60 | or os.environ.get("KEEP_MONTHLY") 61 | or "12" 62 | ) 63 | self.keep_yearly = ( 64 | os.environ.get("RESTIC_KEEP_YEARLY") or os.environ.get("KEEP_YEARLY") or "3" 65 | ) 66 | 67 | if check: 68 | self.check() 69 | 70 | def check(self): 71 | if not self.repository: 72 | raise ValueError("RESTIC_REPOSITORY env var not set") 73 | 74 | if not self.password: 75 | raise ValueError("RESTIC_REPOSITORY env var not set") 76 | 77 | 78 | config = Config() 79 | -------------------------------------------------------------------------------- /src/tests/unit/test_container_operations.py: -------------------------------------------------------------------------------- 1 | """Unit tests for container operations""" 2 | 3 | import unittest 4 | from unittest import mock 5 | import pytest 6 | 7 | from restic_compose_backup import utils 8 | from restic_compose_backup.containers import RunningContainers 9 | from . import fixtures 10 | from .conftest import BaseTestCase 11 | 12 | pytestmark = pytest.mark.unit 13 | 14 | list_containers_func = "restic_compose_backup.utils.list_containers" 15 | 16 | 17 | class ContainerOperationTests(BaseTestCase): 18 | """Tests for basic container operations and detection""" 19 | 20 | def test_list_containers(self): 21 | """Test a basic container list""" 22 | containers = [ 23 | { 24 | "service": "web", 25 | "labels": { 26 | "moo": 1, 27 | }, 28 | "mounts": [ 29 | { 30 | "Source": "moo", 31 | "Destination": "moo", 32 | "Type": "bind", 33 | } 34 | ], 35 | }, 36 | { 37 | "service": "mysql", 38 | }, 39 | { 40 | "service": "postgres", 41 | }, 42 | ] 43 | 44 | with mock.patch( 45 | list_containers_func, fixtures.containers(containers=containers) 46 | ): 47 | test = utils.list_containers() 48 | 49 | def test_running_containers(self): 50 | """Test detection and parsing of running containers""" 51 | containers = self.createContainers() 52 | containers += [ 53 | { 54 | "service": "web", 55 | "labels": { 56 | "stack-back.volumes": True, 57 | "test": "test", 58 | }, 59 | "mounts": [ 60 | { 61 | "Source": "test", 62 | "Destination": "test", 63 | "Type": "bind", 64 | } 65 | ], 66 | }, 67 | { 68 | "service": "mysql", 69 | }, 70 | { 71 | "service": "postgres", 72 | }, 73 | ] 74 | with mock.patch( 75 | list_containers_func, fixtures.containers(containers=containers) 76 | ): 77 | result = RunningContainers() 78 | self.assertEqual(len(result.containers), 4, msg="Three containers expected") 79 | self.assertNotEqual( 80 | result.this_container, None, msg="No backup container found" 81 | ) 82 | web_service = result.get_service("web") 83 | self.assertNotEqual(web_service, None) 84 | self.assertEqual(len(web_service.filter_mounts()), 1) 85 | 86 | def test_find_running_backup_container(self): 87 | """Test detection of running backup process container""" 88 | containers = self.createContainers() 89 | with mock.patch( 90 | list_containers_func, fixtures.containers(containers=containers) 91 | ): 92 | cnt = RunningContainers() 93 | self.assertFalse(cnt.backup_process_running) 94 | 95 | containers += [ 96 | { 97 | "service": "backup_runner", 98 | "labels": { 99 | "stack-back.process-default": "True", 100 | }, 101 | }, 102 | ] 103 | with mock.patch( 104 | list_containers_func, fixtures.containers(containers=containers) 105 | ): 106 | cnt = RunningContainers() 107 | self.assertTrue(cnt.backup_process_running) 108 | -------------------------------------------------------------------------------- /docker-compose.test.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | socket-proxy: 3 | image: linuxserver/socket-proxy:3.2.9 4 | environment: 5 | ALLOW_START: 1 6 | ALLOW_STOP: 1 7 | CONTAINERS: 1 8 | EXEC: 1 9 | POST: 1 10 | VERSION: 1 11 | read_only: true 12 | tmpfs: 13 | - /run 14 | volumes: 15 | - /var/run/docker.sock:/var/run/docker.sock:ro 16 | restart: unless-stopped 17 | networks: 18 | - backup 19 | backup: 20 | build: ./src 21 | environment: 22 | - DOCKER_HOST=tcp://socket-proxy:2375 23 | - RESTIC_REPOSITORY=/restic_data 24 | - RESTIC_PASSWORD=test_password_for_integration_tests 25 | - AUTO_BACKUP_ALL=false 26 | - LOG_LEVEL=debug 27 | - INCLUDE_PROJECT_NAME=false 28 | - EXCLUDE_BIND_MOUNTS=false 29 | - RESTIC_KEEP_DAILY=7 30 | - RESTIC_KEEP_WEEKLY=4 31 | - RESTIC_KEEP_MONTHLY=12 32 | - RESTIC_KEEP_YEARLY=3 33 | volumes: 34 | - ./test_restic_data:/restic_data 35 | - ./test_restic_cache:/cache 36 | - ./src:/stack-back 37 | networks: 38 | - backup 39 | depends_on: 40 | socket-proxy: 41 | condition: service_started 42 | mysql: 43 | condition: service_healthy 44 | mariadb: 45 | condition: service_healthy 46 | postgres: 47 | condition: service_healthy 48 | 49 | web: 50 | image: nginx:alpine 51 | labels: 52 | stack-back.volumes: true 53 | stack-back.volumes.include: "data" 54 | volumes: 55 | - web_data:/usr/share/nginx/html 56 | - ./test_data/web:/srv/data 57 | networks: 58 | - test-network 59 | 60 | mysql: 61 | image: mysql:8 62 | labels: 63 | stack-back.mysql: true 64 | environment: 65 | - MYSQL_ROOT_PASSWORD=test_root_password 66 | - MYSQL_DATABASE=testdb 67 | - MYSQL_USER=testuser 68 | - MYSQL_PASSWORD=testpassword 69 | volumes: 70 | - mysql_data:/var/lib/mysql 71 | networks: 72 | - test-network 73 | healthcheck: 74 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ptest_root_password"] 75 | interval: 5s 76 | timeout: 3s 77 | retries: 10 78 | 79 | mariadb: 80 | image: mariadb:11 81 | labels: 82 | stack-back.mariadb: true 83 | environment: 84 | - MARIADB_ROOT_PASSWORD=test_root_password 85 | - MARIADB_DATABASE=testdb 86 | - MARIADB_USER=testuser 87 | - MARIADB_PASSWORD=testpassword 88 | volumes: 89 | - mariadb_data:/var/lib/mysql 90 | networks: 91 | - test-network 92 | healthcheck: 93 | test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] 94 | interval: 5s 95 | timeout: 3s 96 | retries: 10 97 | 98 | postgres: 99 | image: postgres:17 100 | labels: 101 | stack-back.postgres: true 102 | environment: 103 | - POSTGRES_USER=testuser 104 | - POSTGRES_PASSWORD=testpassword 105 | - POSTGRES_DB=testdb 106 | volumes: 107 | - postgres_data:/var/lib/postgresql/data 108 | networks: 109 | - test-network 110 | healthcheck: 111 | test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"] 112 | interval: 5s 113 | timeout: 3s 114 | retries: 10 115 | 116 | # Service to test exclude patterns 117 | excluded_service: 118 | image: alpine:latest 119 | command: tail -f /dev/null 120 | labels: 121 | stack-back.volumes: false 122 | volumes: 123 | - excluded_data:/data 124 | networks: 125 | - test-network 126 | 127 | volumes: 128 | web_data: 129 | mysql_data: 130 | mariadb_data: 131 | postgres_data: 132 | excluded_data: 133 | 134 | networks: 135 | backup: 136 | driver: bridge 137 | test-network: 138 | driver: bridge 139 | -------------------------------------------------------------------------------- /src/restic_compose_backup/commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Tuple, Union 3 | from restic_compose_backup import utils 4 | from subprocess import Popen, PIPE 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def test(): 10 | return run(["ls", "/volumes"]) 11 | 12 | 13 | def ping_mysql(container_id, host, port, username, password) -> int: 14 | """Check if the mysql is up and can be reached""" 15 | return docker_exec( 16 | container_id, 17 | [ 18 | "mysqladmin", 19 | "ping", 20 | "--host", 21 | host, 22 | "--port", 23 | port, 24 | "--user", 25 | username, 26 | ], 27 | environment={"MYSQL_PWD": password}, 28 | ) 29 | 30 | 31 | def ping_mariadb(container_id, host, port, username, password) -> int: 32 | """Check if the mariadb is up and can be reached""" 33 | return docker_exec( 34 | container_id, 35 | [ 36 | "mariadb-admin", 37 | "ping", 38 | "--host", 39 | host, 40 | "--port", 41 | port, 42 | "--user", 43 | username, 44 | ], 45 | environment={"MYSQL_PWD": password}, 46 | ) 47 | 48 | 49 | def ping_postgres(container_id, host, port, username, password) -> int: 50 | """Check if postgres can be reached""" 51 | return docker_exec( 52 | container_id, 53 | [ 54 | "pg_isready", 55 | f"--host={host}", 56 | f"--port={port}", 57 | f"--username={username}", 58 | ], 59 | ) 60 | 61 | 62 | def docker_exec( 63 | container_id: str, cmd: List[str], environment: Union[dict, list] = [] 64 | ) -> int: 65 | """Execute a command within the given container""" 66 | client = utils.docker_client() 67 | logger.debug("docker exec inside %s: %s", container_id, " ".join(cmd)) 68 | exit_code, (stdout, stderr) = client.containers.get(container_id).exec_run( 69 | cmd, demux=True, environment=environment 70 | ) 71 | 72 | if stdout: 73 | log_std( 74 | "stdout", 75 | stdout.decode(), 76 | logging.DEBUG if exit_code == 0 else logging.ERROR, 77 | ) 78 | 79 | if stderr: 80 | log_std("stderr", stderr.decode(), logging.ERROR) 81 | 82 | return exit_code 83 | 84 | 85 | def run(cmd: List[str]) -> int: 86 | """Run a command with parameters""" 87 | logger.debug("cmd: %s", " ".join(cmd)) 88 | child = Popen(cmd, stdout=PIPE, stderr=PIPE) 89 | stdoutdata, stderrdata = child.communicate() 90 | 91 | if stdoutdata.strip(): 92 | log_std( 93 | "stdout", 94 | stdoutdata.decode(), 95 | logging.DEBUG if child.returncode == 0 else logging.ERROR, 96 | ) 97 | 98 | if stderrdata.strip(): 99 | log_std("stderr", stderrdata.decode(), logging.ERROR) 100 | 101 | logger.debug("returncode %s", child.returncode) 102 | return child.returncode 103 | 104 | 105 | def run_capture_std(cmd: List[str]) -> Tuple[str, str]: 106 | """Run a command with parameters and return stdout, stderr""" 107 | logger.debug("cmd: %s", " ".join(cmd)) 108 | child = Popen(cmd, stdout=PIPE, stderr=PIPE) 109 | return child.communicate() 110 | 111 | 112 | def log_std(source: str, data: str, level: int): 113 | if isinstance(data, bytes): 114 | data = data.decode() 115 | 116 | if not data.strip(): 117 | return 118 | 119 | log_func = logger.debug if level == logging.DEBUG else logger.error 120 | log_func("%s %s %s", "-" * 10, source, "-" * 10) 121 | 122 | lines = data.split("\n") 123 | if lines[-1] == "": 124 | lines.pop() 125 | 126 | for line in lines: 127 | log_func(line) 128 | 129 | log_func("-" * 28) 130 | -------------------------------------------------------------------------------- /src/restic_compose_backup/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from typing import List, TYPE_CHECKING 4 | from contextlib import contextmanager 5 | import docker 6 | from docker import DockerClient 7 | 8 | if TYPE_CHECKING: 9 | from restic_compose_backup.containers import Container 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | TRUE_VALUES = ["1", "true", "True", "TRUE", True, 1] 14 | FALSE_VALUES = ["0", "false", "False", "FALSE", False, 0] 15 | 16 | 17 | def docker_client() -> DockerClient: 18 | """ 19 | Create a docker client from the following environment variables:: 20 | 21 | DOCKER_HOST=unix://tmp/docker.sock 22 | DOCKER_TLS_VERIFY=1 23 | DOCKER_CERT_PATH='' 24 | """ 25 | # NOTE: Remove this fallback in 1.0 26 | if not os.environ.get("DOCKER_HOST"): 27 | os.environ["DOCKER_HOST"] = "unix://tmp/docker.sock" 28 | 29 | return docker.from_env() 30 | 31 | 32 | def list_containers() -> List[dict]: 33 | """ 34 | List all containers. 35 | 36 | Returns: 37 | List of raw container json data from the api 38 | """ 39 | client = docker_client() 40 | all_containers = client.containers.list(all=True) 41 | client.close() 42 | return [c.attrs for c in all_containers] 43 | 44 | 45 | def get_swarm_nodes(): 46 | client = docker_client() 47 | # NOTE: If not a swarm node docker.errors.APIError is raised 48 | # 503 Server Error: Service Unavailable 49 | # ("This node is not a swarm manager. Use "docker swarm init" or 50 | # "docker swarm join" to connect this node to swarm and try again.") 51 | try: 52 | return client.nodes.list() 53 | except docker.errors.APIError: 54 | return [] 55 | 56 | 57 | def remove_containers(containers: List["Container"]): 58 | client = docker_client() 59 | logger.info("Attempting to delete stale backup process containers") 60 | for container in containers: 61 | logger.info(" -> deleting %s", container.name) 62 | try: 63 | c = client.containers.get(container.name) 64 | c.remove() 65 | except Exception as ex: 66 | logger.exception(ex) 67 | 68 | 69 | def stop_containers(containers: List["Container"]): 70 | client = docker_client() 71 | logger.info("Attempting to stop containers labeled to stop during backup") 72 | for container in containers: 73 | logger.info(" -> stopping %s", container.name) 74 | try: 75 | c = client.containers.get(container.name) 76 | c.stop() 77 | except Exception as ex: 78 | logger.exception(ex) 79 | 80 | 81 | def start_containers(containers: List["Container"]): 82 | client = docker_client() 83 | logger.info("Attempting to restart containers that were stopped during backup") 84 | for container in containers: 85 | logger.info(" -> starting %s", container.name) 86 | try: 87 | c = client.containers.get(container.name) 88 | c.start() 89 | except Exception as ex: 90 | logger.exception(ex) 91 | 92 | 93 | def is_true(value): 94 | """ 95 | Evaluates the truthfullness of a bool value in container labels 96 | """ 97 | return value in TRUE_VALUES 98 | 99 | 100 | def is_false(value): 101 | """ 102 | Evaluates the falseness of a bool value in container labels 103 | """ 104 | return value in FALSE_VALUES 105 | 106 | 107 | def strip_root(path): 108 | """ 109 | Removes the root slash in a path. 110 | Example: /srv/data becomes srv/data 111 | """ 112 | path = path.strip() 113 | if path.startswith("/"): 114 | return path[1:] 115 | 116 | return path 117 | 118 | 119 | @contextmanager 120 | def environment(name, value): 121 | """Tempset env var""" 122 | old_val = os.environ.get(name) 123 | os.environ[name] = value 124 | try: 125 | yield 126 | finally: 127 | if old_val is None: 128 | del os.environ[name] 129 | else: 130 | os.environ[name] = old_val 131 | -------------------------------------------------------------------------------- /src/tests/unit/test_database_configuration.py: -------------------------------------------------------------------------------- 1 | """Unit tests for database backup configuration""" 2 | 3 | import unittest 4 | from unittest import mock 5 | import pytest 6 | 7 | from restic_compose_backup.containers import RunningContainers 8 | from . import fixtures 9 | from .conftest import BaseTestCase 10 | 11 | pytestmark = pytest.mark.unit 12 | 13 | list_containers_func = "restic_compose_backup.utils.list_containers" 14 | 15 | 16 | class DatabaseConfigurationTests(BaseTestCase): 17 | """Tests for database backup configuration""" 18 | 19 | def test_databases_for_backup(self): 20 | """Test identifying databases marked for backup""" 21 | containers = self.createContainers() 22 | containers += [ 23 | { 24 | "service": "mysql", 25 | "labels": { 26 | "stack-back.mysql": True, 27 | }, 28 | "mounts": [ 29 | { 30 | "Source": "/srv/mysql/data", 31 | "Destination": "/var/lib/mysql", 32 | "Type": "bind", 33 | } 34 | ], 35 | }, 36 | { 37 | "service": "mariadb", 38 | "labels": { 39 | "stack-back.mariadb": True, 40 | }, 41 | "mounts": [ 42 | { 43 | "Source": "/srv/mariadb/data", 44 | "Destination": "/var/lib/mysql", 45 | "Type": "bind", 46 | }, 47 | ], 48 | }, 49 | { 50 | "service": "postgres", 51 | "labels": { 52 | "stack-back.postgres": True, 53 | }, 54 | "mounts": [ 55 | { 56 | "Source": "/srv/postgres/data", 57 | "Destination": "/var/lib/postgresql/data", 58 | "Type": "bind", 59 | }, 60 | ], 61 | }, 62 | ] 63 | with mock.patch( 64 | list_containers_func, fixtures.containers(containers=containers) 65 | ): 66 | cnt = RunningContainers() 67 | mysql_service = cnt.get_service("mysql") 68 | self.assertNotEqual(mysql_service, None, msg="MySQL service not found") 69 | self.assertTrue(mysql_service.mysql_backup_enabled) 70 | mariadb_service = cnt.get_service("mariadb") 71 | self.assertNotEqual(mariadb_service, None, msg="MariaDB service not found") 72 | self.assertTrue(mariadb_service.mariadb_backup_enabled) 73 | postgres_service = cnt.get_service("postgres") 74 | self.assertNotEqual(postgres_service, None, msg="Posgres service not found") 75 | self.assertTrue(postgres_service.postgresql_backup_enabled) 76 | 77 | def test_stop_container_during_backup_database(self): 78 | """Test that stop-during-backup label doesn't apply to databases""" 79 | containers = self.createContainers() 80 | containers += [ 81 | { 82 | "service": "mysql", 83 | "labels": { 84 | "stack-back.mysql": True, 85 | "stack-back.volumes.stop-during-backup": True, 86 | }, 87 | "mounts": [ 88 | { 89 | "Source": "/srv/mysql/data", 90 | "Destination": "/var/lib/mysql", 91 | "Type": "bind", 92 | } 93 | ], 94 | }, 95 | ] 96 | with mock.patch( 97 | list_containers_func, fixtures.containers(containers=containers) 98 | ): 99 | cnt = RunningContainers() 100 | mysql_service = cnt.get_service("mysql") 101 | self.assertNotEqual(mysql_service, None, msg="MySQL service not found") 102 | self.assertTrue(mysql_service.mysql_backup_enabled) 103 | self.assertFalse(mysql_service.stop_during_backup) 104 | -------------------------------------------------------------------------------- /src/tests/integration/test_label_configuration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for label-based configuration""" 2 | 3 | import subprocess 4 | import time 5 | import pytest 6 | 7 | pytestmark = pytest.mark.integration 8 | 9 | 10 | def test_volume_include_label(run_rcb_command): 11 | """Test that stack-back.volumes.include label filters volumes correctly""" 12 | exit_code, output = run_rcb_command("status") 13 | assert exit_code == 0 14 | 15 | # Web service has include label for "data" 16 | # It should only back up the /srv/data mount, not the nginx html volume 17 | assert "service: web" in output 18 | # Should see the data volume 19 | assert "/srv/data" in output 20 | 21 | 22 | def test_volume_exclude_label(run_rcb_command): 23 | """Test that stack-back.volumes=false excludes a service from backups""" 24 | exit_code, output = run_rcb_command("status") 25 | assert exit_code == 0 26 | 27 | # excluded_service has stack-back.volumes=false 28 | assert "service: excluded_service" not in output 29 | 30 | 31 | def test_database_labels_detection(run_rcb_command): 32 | """Test that database backup labels are detected correctly""" 33 | exit_code, output = run_rcb_command("status") 34 | assert exit_code == 0 35 | 36 | # Check that all three database types are detected 37 | assert "mysql" in output.lower() 38 | assert "mariadb" in output.lower() 39 | assert "postgres" in output.lower() 40 | 41 | 42 | def test_backup_respects_labels( 43 | run_rcb_command, create_test_data, project_root, compose_project_name 44 | ): 45 | """Test that backup only processes labeled services""" 46 | # Create data in both included and excluded services 47 | create_test_data("test_data/web/included.txt", "Should be backed up") 48 | 49 | time.sleep(2) 50 | 51 | # Run backup 52 | exit_code, output = run_rcb_command("backup") 53 | assert exit_code == 0 54 | 55 | # The output should mention processing web service but not excluded_service 56 | # (excluded_service has stack-back.volumes=false) 57 | exit_code, status_output = run_rcb_command("status") 58 | assert "service: web" in status_output 59 | assert "service: excluded_service" not in status_output 60 | 61 | 62 | def test_database_label_enables_backup(run_rcb_command, mysql_container): 63 | """Test that database-specific labels enable database backups""" 64 | # MySQL container has stack-back.mysql=true label 65 | exit_code, output = run_rcb_command("status") 66 | assert exit_code == 0 67 | 68 | # Should show mysql service with database backup enabled 69 | assert "service: mysql" in output 70 | assert "mysql" in output.lower() 71 | 72 | 73 | def test_multiple_mounts_with_include(run_rcb_command): 74 | """Test that include pattern works when service has multiple mounts""" 75 | # Web service has two mounts but include filter for "data" 76 | exit_code, output = run_rcb_command("status") 77 | assert exit_code == 0 78 | 79 | # Should only show the included volume 80 | lines = output.split("\n") 81 | web_section = [] 82 | in_web_section = False 83 | 84 | for line in lines: 85 | if "service: web" in line: 86 | in_web_section = True 87 | elif in_web_section: 88 | if line.strip().startswith("service:"): 89 | break 90 | if "volume:" in line: 91 | web_section.append(line) 92 | 93 | # Should have the data mount but web service has include filter 94 | # so we should see limited volumes 95 | assert len(web_section) > 0, "Web service should have at least one volume listed" 96 | 97 | 98 | def test_services_without_labels_not_backed_up(run_rcb_command): 99 | """Test that services without stack-back labels are not backed up when AUTO_BACKUP_ALL=false""" 100 | exit_code, output = run_rcb_command("status") 101 | assert exit_code == 0 102 | 103 | # With AUTO_BACKUP_ALL=false in test environment, 104 | # only explicitly labeled services should appear 105 | lines = [line for line in output.split("\n") if line.strip().startswith("service:")] 106 | 107 | # We should see our explicitly labeled services 108 | service_names = [line.split("service:")[1].strip() for line in lines] 109 | 110 | # All services in the list should be ones we explicitly labeled 111 | expected_services = ["web", "mysql", "mariadb", "postgres", "backup"] 112 | for service in service_names: 113 | assert service in expected_services, ( 114 | f"Unexpected service '{service}' in backup list" 115 | ) 116 | -------------------------------------------------------------------------------- /src/tests/integration/test_volume_backups.py: -------------------------------------------------------------------------------- 1 | """Integration tests for volume backups""" 2 | 3 | import time 4 | import pytest 5 | 6 | pytestmark = pytest.mark.integration 7 | 8 | 9 | def test_backup_status(run_rcb_command): 10 | """Test that the status command works""" 11 | exit_code, output = run_rcb_command("status") 12 | assert exit_code == 0, f"Status command failed: {output}" 13 | assert "Detected Config" in output 14 | assert "service: web" in output 15 | assert "service: mysql" in output 16 | 17 | 18 | def test_backup_bind_mount(run_rcb_command, create_test_data, backup_container): 19 | """Test backing up a bind mount""" 20 | # Create test data in the bind mount 21 | test_file = create_test_data("test_data/web/test.txt", "Hello from bind mount!") 22 | 23 | # Wait a moment for the file to be visible 24 | time.sleep(2) 25 | 26 | # Run backup 27 | exit_code, output = run_rcb_command("backup") 28 | assert exit_code == 0, f"Backup command failed: {output}" 29 | 30 | # Check that snapshots were created 31 | exit_code, output = run_rcb_command("snapshots") 32 | assert exit_code == 0, f"Snapshots command failed: {output}" 33 | assert len(output.strip().split("\n")) > 1, "No snapshots found" 34 | 35 | 36 | def test_restore_bind_mount( 37 | run_rcb_command, create_test_data, backup_container, project_root 38 | ): 39 | """Test restoring data from a bind mount backup""" 40 | # Create and backup test data 41 | test_content = "This is test data for restore" 42 | test_file = create_test_data("test_data/web/restore_test.txt", test_content) 43 | 44 | time.sleep(2) 45 | 46 | # Run backup 47 | exit_code, output = run_rcb_command("backup") 48 | assert exit_code == 0, f"Backup command failed: {output}" 49 | 50 | # Remove the test file 51 | test_file.unlink() 52 | 53 | # Restore from backup 54 | exit_code, output = backup_container.exec_run( 55 | "restic restore latest --target /restore --path /volumes" 56 | ) 57 | assert exit_code == 0, f"Restore command failed: {output.decode()}" 58 | 59 | # Verify the restored file exists 60 | exit_code, output = backup_container.exec_run( 61 | "cat /restore/volumes/web/srv/data/restore_test.txt" 62 | ) 63 | assert exit_code == 0, f"Could not read restored file: {output.decode()}" 64 | assert test_content in output.decode(), "Restored content doesn't match original" 65 | 66 | 67 | def test_named_volume_backup(run_rcb_command, web_container): 68 | """Test backing up a named Docker volume""" 69 | # Create test data in the named volume 70 | test_content = "Named volume test data" 71 | exit_code, output = web_container.exec_run( 72 | f"sh -c 'echo \"{test_content}\" > /usr/share/nginx/html/index.html'" 73 | ) 74 | assert exit_code == 0, f"Failed to create test data: {output.decode()}" 75 | 76 | time.sleep(2) 77 | 78 | # Run backup 79 | exit_code, output = run_rcb_command("backup") 80 | assert exit_code == 0, f"Backup command failed: {output}" 81 | 82 | # Verify snapshot exists 83 | exit_code, output = run_rcb_command("snapshots") 84 | assert exit_code == 0, f"Snapshots command failed: {output}" 85 | 86 | 87 | def test_multiple_backups_creates_snapshots(run_rcb_command, create_test_data): 88 | """Test that running multiple backups creates multiple snapshots""" 89 | # First backup 90 | create_test_data("test_data/web/file1.txt", "First backup") 91 | time.sleep(2) 92 | exit_code, _ = run_rcb_command("backup") 93 | assert exit_code == 0 94 | 95 | # Second backup with new data 96 | time.sleep(2) 97 | create_test_data("test_data/web/file2.txt", "Second backup") 98 | time.sleep(2) 99 | exit_code, _ = run_rcb_command("backup") 100 | assert exit_code == 0 101 | 102 | # Check that we have multiple snapshots 103 | exit_code, output = run_rcb_command("snapshots") 104 | assert exit_code == 0 105 | # Should have at least 2 snapshots (may have more from previous tests) 106 | snapshot_lines = [ 107 | line for line in output.split("\n") if line.strip() and not line.startswith("-") 108 | ] 109 | # Filter out header lines 110 | snapshot_count = len( 111 | [ 112 | line 113 | for line in snapshot_lines 114 | if "latest" not in line.lower() and len(line) > 20 115 | ] 116 | ) 117 | assert snapshot_count >= 2, f"Expected at least 2 snapshots, found {snapshot_count}" 118 | 119 | 120 | def test_excluded_service_not_backed_up(run_rcb_command): 121 | """Test that services with stack-back.volumes=false are not backed up""" 122 | exit_code, output = run_rcb_command("status") 123 | assert exit_code == 0 124 | # The excluded_service should not appear in the backup list 125 | assert "service: excluded_service" not in output, ( 126 | "Excluded service should not be in backup list" 127 | ) 128 | -------------------------------------------------------------------------------- /src/restic_compose_backup/restic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Restic commands 3 | """ 4 | 5 | import logging 6 | from typing import List, Tuple, Union 7 | from subprocess import Popen, PIPE 8 | from restic_compose_backup import commands, utils 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def init_repo(repository: str): 14 | """ 15 | Attempt to initialize the repository. 16 | Doing this after the repository is initialized 17 | """ 18 | return commands.run( 19 | restic( 20 | repository, 21 | [ 22 | "init", 23 | ], 24 | ) 25 | ) 26 | 27 | 28 | def backup_files(repository: str, source="/volumes"): 29 | return commands.run( 30 | restic( 31 | repository, 32 | [ 33 | "--verbose", 34 | "backup", 35 | source, 36 | ], 37 | ) 38 | ) 39 | 40 | 41 | def backup_from_stdin( 42 | repository: str, 43 | filename: str, 44 | container_id: str, 45 | source_command: List[str], 46 | environment: Union[dict, list] = None, 47 | ): 48 | """ 49 | Backs up from stdin running the source_command passed in within the given container. 50 | It will appear in restic with the filename (including path) passed in. 51 | """ 52 | dest_command = restic( 53 | repository, 54 | [ 55 | "backup", 56 | "--stdin", 57 | "--stdin-filename", 58 | filename, 59 | ], 60 | ) 61 | 62 | client = utils.docker_client() 63 | 64 | logger.debug( 65 | f"docker exec inside container {container_id} command: {' '.join(source_command)}" 66 | ) 67 | 68 | # Create and start source command inside the given container 69 | handle = client.api.exec_create( 70 | container_id, source_command, environment=environment 71 | ) 72 | exec_id = handle.get("Id") 73 | stream = client.api.exec_start(exec_id, stream=True, demux=True) 74 | source_stderr = "" 75 | 76 | # Create the restic process to receive the output of the source command 77 | dest_process = Popen( 78 | dest_command, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=65536 79 | ) 80 | 81 | # Send the output of the source command over to restic in the chunks received 82 | for stdout_chunk, stderr_chunk in stream: 83 | if stdout_chunk: 84 | dest_process.stdin.write(stdout_chunk) 85 | if stderr_chunk: 86 | source_stderr += stderr_chunk.decode() 87 | 88 | # Wait for restic to finish 89 | stdout, stderr = dest_process.communicate() 90 | 91 | # Ensure both processes exited with code 0 92 | source_exit = client.api.exec_inspect(exec_id).get("ExitCode") 93 | dest_exit = dest_process.poll() 94 | exit_code = source_exit or dest_exit 95 | 96 | if stdout: 97 | commands.log_std( 98 | "stdout", stdout, logging.DEBUG if exit_code == 0 else logging.ERROR 99 | ) 100 | 101 | if source_stderr: 102 | commands.log_std(f"stderr ({source_command[0]})", source_stderr, logging.ERROR) 103 | 104 | if stderr: 105 | commands.log_std("stderr (restic)", stderr, logging.ERROR) 106 | 107 | return exit_code 108 | 109 | 110 | def snapshots(repository: str, last=True) -> Tuple[str, str]: 111 | """Returns the stdout and stderr info""" 112 | args = ["snapshots"] 113 | if last: 114 | args.append("--latest") 115 | args.append("1") 116 | return commands.run_capture_std(restic(repository, args)) 117 | 118 | 119 | def is_initialized(repository: str) -> bool: 120 | """ 121 | Checks if a repository is initialized with restic cat config. 122 | https://restic.readthedocs.io/en/latest/075_scripting.html#check-if-a-repository-is-already-initialized 123 | """ 124 | response = commands.run(restic(repository, ["cat", "config"])) 125 | if response == 0: 126 | return True 127 | elif response == 10: 128 | return False 129 | else: 130 | logger.error("Error while checking if repository is initialized") 131 | exit(1) 132 | 133 | 134 | def forget(repository: str, daily: str, weekly: str, monthly: str, yearly: str): 135 | return commands.run( 136 | restic( 137 | repository, 138 | [ 139 | "forget", 140 | "--group-by", 141 | "paths", 142 | "--keep-daily", 143 | daily, 144 | "--keep-weekly", 145 | weekly, 146 | "--keep-monthly", 147 | monthly, 148 | "--keep-yearly", 149 | yearly, 150 | ], 151 | ) 152 | ) 153 | 154 | 155 | def prune(repository: str): 156 | return commands.run( 157 | restic( 158 | repository, 159 | [ 160 | "prune", 161 | ], 162 | ) 163 | ) 164 | 165 | 166 | def check(repository: str, with_cache: bool = False): 167 | check_args = ["check"] 168 | if with_cache: 169 | check_args.append("--with-cache") 170 | return commands.run(restic(repository, check_args)) 171 | 172 | 173 | def restic(repository: str, args: List[str]): 174 | """Generate restic command""" 175 | return [ 176 | "restic", 177 | "-r", 178 | repository, 179 | ] + args 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![stack-back logo](./resources/stack-back_logo.svg) 3 | 4 | [![docs](https://readthedocs.org/projects/stack-back/badge/?version=latest)](https://stack-back.readthedocs.io) 5 | 6 | Automated incremental backups using [restic] for any docker-compose setup. 7 | 8 | * Backup docker volumes or host binds 9 | * Backup postgres, mariadb, and mysql databases 10 | * Notifications over SMTP or Discord webhooks 11 | 12 | # Usage 13 | 14 | ### Just add it to your services 15 | 16 | ```yaml 17 | services: 18 | backup: 19 | image: ghcr.io/lawndoc/stack-back: 20 | env_file: 21 | - stack-back.env 22 | environment: 23 | - AUTO_BACKUP_ALL: True 24 | volumes: 25 | - /var/run/docker.sock:/tmp/docker.sock:ro 26 | - cache:/cache # Persistent restic cache (greatly speeds up all restic operations) 27 | ``` 28 | 29 | ### and it will back up all your volumes and databases 30 | 31 | ```yaml 32 | web: 33 | image: some_image # Backs up the volumes below 34 | volumes: 35 | - media:/srv/media 36 | - /srv/files:/srv/files 37 | mysql: 38 | image: mysql:9 # Performs stateful backup using mysqldump 39 | volumes: 40 | - mysql:/var/lib/mysql # Only SQL dump is backed up 41 | ``` 42 | 43 | [Documentation](https://stack-back.readthedocs.io) 44 | 45 | Please report issus on [github](https://github.com/lawndoc/stack-back/issues). 46 | 47 | ## Configuration (environment variables) 48 | 49 | Minimum configuration 50 | 51 | ```bash 52 | RESTIC_REPOSITORY 53 | RESTIC_PASSWORD 54 | ``` 55 | 56 | All config options can be found in the [template env file](./stack-back.env.template) 57 | 58 | Restic-specific environment variables can be found in the [restic documentation](https://restic.readthedocs.io/en/stable/040_backup.html#environment-variables) 59 | 60 | ### Example config: S3 bucket 61 | 62 | stack-back.env 63 | 64 | ```bash 65 | AUTO_BACKUP_ALL=True 66 | RESTIC_REPOSITORY=s3:s3.us-east-1.amazonaws.com/bucket_name 67 | RESTIC_PASSWORD=thisdecryptsyourbackupsdontloseit 68 | AWS_ACCESS_KEY_ID= 69 | AWS_SECRET_ACCESS_KEY= 70 | CHECK_WITH_CACHE=true 71 | # snapshot prune rules 72 | RESTIC_KEEP_DAILY=7 73 | RESTIC_KEEP_WEEKLY=4 74 | RESTIC_KEEP_MONTHLY=12 75 | RESTIC_KEEP_YEARLY=3 76 | # Cron schedule. Run every day at 1am 77 | CRON_SCHEDULE="0 1 * * *" 78 | ``` 79 | 80 | ## Advanced configuration (container labels) 81 | 82 | You can also use `stack-back` container labels for granular control over which volumes get backed up. 83 | 84 | ```yaml 85 | web: 86 | image: some_image 87 | labels: 88 | - stack-back.volumes.exclude: files # host mount substring matching 89 | volumes: 90 | - media:/srv/media # will be backed up 91 | - /srv/files:/srv/files # will NOT be backed up 92 | mysql: 93 | image: mysql:9 94 | labels: 95 | - stack-back.mysql: False # don't perform database dump backup 96 | - stack-back.volumes: False # don't back up any volumes for this container either 97 | volumes: 98 | - mysql:/var/lib/mysql 99 | ``` 100 | 101 | Detailed documentation on compose labels can be found in the [stack-back documentation](https://stack-back.readthedocs.io/en/latest/guide/configuration.html#compose-labels) 102 | 103 | ## The `rcb` command 104 | 105 | Everything is controlled using the [`rcb` command line tool](./src/) from this repo. 106 | 107 | You can use the `rcb status` command to verify the configuration. 108 | 109 | ```bash 110 | $ docker-compose exec -it backup rcb status 111 | INFO: Status for compose project 'myproject' 112 | INFO: Repository: '' 113 | INFO: Backup currently running?: False 114 | INFO: --------------- Detected Config --------------- 115 | INFO: service: mysql 116 | INFO: - mysql (is_ready=True) 117 | INFO: service: mariadb 118 | INFO: - mariadb (is_ready=True) 119 | INFO: service: postgres 120 | INFO: - postgres (is_ready=True) 121 | INFO: service: web 122 | INFO: - volume: media 123 | INFO: - volume: /srv/files 124 | ``` 125 | 126 | The `status` subcommand lists what will be backed up and even pings the database services checking their availability. 127 | 128 | More `rcb` commands can be found in the [documentation]. 129 | 130 | # Contributing 131 | 132 | Contributions are welcome regardless of experience level. 133 | 134 | ## Python environment 135 | 136 | Use [`uv`](https://docs.astral.sh/uv/) within the repo root directory to manage your development environment. 137 | 138 | ```bash 139 | git clone https://github.com/lawndoc/stack-back.git 140 | cd stack-back 141 | uv sync --directory src/ 142 | ``` 143 | 144 | ## Running unit tests 145 | 146 | Make sure `uv` is already set up as shown above. 147 | 148 | ```bash 149 | # Run only unit tests (fast) 150 | uv run --directory src/ pytest -m unit 151 | 152 | # Run only integration tests 153 | uv run --directory src/ pytest -m integration 154 | 155 | # Run all tests (unit + integration) 156 | uv run --directory src/ pytest 157 | ``` 158 | 159 | **Note:** Integration tests require Docker and docker compose plugin, and will spin up real database containers. They take significantly longer than unit tests. 160 | 161 | ## Building Docs 162 | 163 | ```bash 164 | pip install -r docs/requirements.txt 165 | python src/setup.py build_sphinx 166 | ``` 167 | 168 | [restic]: https://restic.net/ 169 | [documentation]: https://stack-back.readthedocs.io 170 | 171 | --- 172 | This project is an actively maintained fork of [restic-compose-backup](https://github.com/ZettaIO/restic-compose-backup) by [Zetta.IO](https://www.zetta.io). 173 | 174 | Huge thanks to them for creating this project. 175 | 176 | [![Zetta.IO](https://raw.githubusercontent.com/lawndoc/stack-back/main/.github/logo.png)](https://www.zetta.io) 177 | -------------------------------------------------------------------------------- /src/tests/integration/test_database_backups.py: -------------------------------------------------------------------------------- 1 | """Integration tests for database backups""" 2 | 3 | import time 4 | import pytest 5 | 6 | pytestmark = pytest.mark.integration 7 | 8 | 9 | def test_mysql_backup(run_rcb_command, mysql_container): 10 | """Test backing up MySQL database""" 11 | # Create test data in MySQL 12 | exit_code, output = mysql_container.exec_run( 13 | "mysql -u root -ptest_root_password -e " 14 | '"CREATE TABLE IF NOT EXISTS testdb.users (id INT, name VARCHAR(50)); ' 15 | "INSERT INTO testdb.users VALUES (1, 'Alice'), (2, 'Bob');\"" 16 | ) 17 | assert exit_code == 0, f"Failed to create MySQL test data: {output.decode()}" 18 | 19 | time.sleep(2) 20 | 21 | # Run backup 22 | exit_code, output = run_rcb_command("backup") 23 | assert exit_code == 0, f"Backup command failed: {output}" 24 | 25 | # Verify the backup includes database dumps 26 | assert "Backing up databases" in output or "mysql" in output.lower() 27 | 28 | 29 | def test_mysql_data_integrity(run_rcb_command, mysql_container, backup_container): 30 | """Test MySQL backup and restore data integrity""" 31 | # Insert unique test data 32 | test_data = "IntegrationTestUser" 33 | exit_code, output = mysql_container.exec_run( 34 | f"mysql -u root -ptest_root_password -e " 35 | f"\"INSERT INTO testdb.users VALUES (999, '{test_data}');\"" 36 | ) 37 | assert exit_code == 0, f"Failed to insert test data: {output.decode()}" 38 | 39 | time.sleep(2) 40 | 41 | # Backup 42 | exit_code, output = run_rcb_command("backup") 43 | assert exit_code == 0, f"Backup failed: {output}" 44 | 45 | # Verify data can be dumped from backup 46 | # The backup creates SQL dumps in the backup process 47 | # We can verify the snapshot exists and contains database data 48 | exit_code, output = run_rcb_command("snapshots") 49 | assert exit_code == 0, f"Failed to list snapshots: {output}" 50 | 51 | 52 | def test_mariadb_backup(run_rcb_command, mariadb_container): 53 | """Test backing up MariaDB database""" 54 | # Create test data in MariaDB 55 | exit_code, output = mariadb_container.exec_run( 56 | "mariadb -u root -ptest_root_password -e " 57 | '"CREATE TABLE IF NOT EXISTS testdb.products (id INT, name VARCHAR(50)); ' 58 | "INSERT INTO testdb.products VALUES (1, 'Widget'), (2, 'Gadget');\"" 59 | ) 60 | assert exit_code == 0, f"Failed to create MariaDB test data: {output.decode()}" 61 | 62 | time.sleep(2) 63 | 64 | # Run backup 65 | exit_code, output = run_rcb_command("backup") 66 | assert exit_code == 0, f"Backup command failed: {output}" 67 | 68 | # Verify the backup includes database dumps 69 | assert "Backing up databases" in output or "mariadb" in output.lower() 70 | 71 | 72 | def test_postgres_backup(run_rcb_command, postgres_container): 73 | """Test backing up PostgreSQL database""" 74 | # Create test data in PostgreSQL 75 | exit_code, output = postgres_container.exec_run( 76 | "psql -U testuser -d testdb -c " 77 | '"CREATE TABLE IF NOT EXISTS orders (id INT, item VARCHAR(50)); ' 78 | "INSERT INTO orders VALUES (1, 'Book'), (2, 'Pen');\"" 79 | ) 80 | assert exit_code == 0, f"Failed to create PostgreSQL test data: {output.decode()}" 81 | 82 | time.sleep(2) 83 | 84 | # Run backup 85 | exit_code, output = run_rcb_command("backup") 86 | assert exit_code == 0, f"Backup command failed: {output}" 87 | 88 | # Verify the backup includes database dumps 89 | assert "Backing up databases" in output or "postgres" in output.lower() 90 | 91 | 92 | def test_all_databases_backed_up( 93 | run_rcb_command, mysql_container, mariadb_container, postgres_container 94 | ): 95 | """Test that all three database types are backed up in a single backup operation""" 96 | # Insert data in all databases 97 | mysql_container.exec_run( 98 | "mysql -u root -ptest_root_password -e " 99 | "\"INSERT INTO testdb.users VALUES (100, 'MultiDBTest');\"" 100 | ) 101 | 102 | mariadb_container.exec_run( 103 | "mariadb -u root -ptest_root_password -e " 104 | "\"INSERT INTO testdb.products VALUES (100, 'MultiDBProduct');\"" 105 | ) 106 | 107 | postgres_container.exec_run( 108 | "psql -U testuser -d testdb -c " 109 | "\"INSERT INTO orders VALUES (100, 'MultiDBItem');\"" 110 | ) 111 | 112 | time.sleep(2) 113 | 114 | # Single backup operation 115 | exit_code, output = run_rcb_command("backup") 116 | assert exit_code == 0, f"Backup command failed: {output}" 117 | 118 | # Check that all databases were processed 119 | # The status command should show all three database services 120 | exit_code, status_output = run_rcb_command("status") 121 | assert exit_code == 0 122 | assert "service: mysql" in status_output 123 | assert "service: mariadb" in status_output 124 | assert "service: postgres" in status_output 125 | 126 | 127 | def test_database_health_check(run_rcb_command): 128 | """Test that database health checks work in status command""" 129 | exit_code, output = run_rcb_command("status") 130 | assert exit_code == 0 131 | 132 | # All databases should be reported as ready 133 | assert "is_ready=True" in output, ( 134 | "Database health checks should show databases as ready" 135 | ) 136 | 137 | 138 | def test_backup_after_database_changes(run_rcb_command, mysql_container): 139 | """Test incremental backup after database modifications""" 140 | # Initial backup 141 | exit_code, _ = run_rcb_command("backup") 142 | assert exit_code == 0 143 | 144 | # Modify data 145 | time.sleep(2) 146 | mysql_container.exec_run( 147 | "mysql -u root -ptest_root_password -e " 148 | "\"UPDATE testdb.users SET name='UpdatedName' WHERE id=1;\"" 149 | ) 150 | 151 | time.sleep(2) 152 | 153 | # Second backup 154 | exit_code, output = run_rcb_command("backup") 155 | assert exit_code == 0, f"Incremental backup failed: {output}" 156 | 157 | # Verify we have snapshots 158 | exit_code, snapshot_output = run_rcb_command("snapshots") 159 | assert exit_code == 0 160 | -------------------------------------------------------------------------------- /.github/workflows/build-tag-release.yaml: -------------------------------------------------------------------------------- 1 | name: "Build, Tag, and Release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - .gitignore 9 | - .github/renovate.json 10 | - .github/workflows/ 11 | - .pre-commit-config.yaml 12 | - LICENSE 13 | - README.MD 14 | - docs/ 15 | workflow_dispatch: 16 | 17 | env: 18 | REGISTRY: ghcr.io 19 | IMAGE_NAME: ${{ github.repository }} 20 | 21 | jobs: 22 | build-tag-release: 23 | runs-on: ubuntu-latest 24 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} 25 | permissions: 26 | contents: write 27 | packages: write 28 | attestations: write 29 | id-token: write 30 | steps: 31 | - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Get next version 36 | id: version 37 | uses: anothrNick/github-tag-action@4ed44965e0db8dab2b466a16da04aec3cc312fd8 # 1.75.0 38 | env: 39 | WITH_V: true 40 | DEFAULT_BUMP: patch 41 | DRY_RUN: true 42 | 43 | - name: Check if version changed 44 | id: changed 45 | continue-on-error: true 46 | run: | 47 | if [[ "${{ steps.version.outputs.new_tag }}" == "${{ steps.version.outputs.old_tag }}" ]]; then 48 | echo "Version not changed" 49 | exit 1 50 | else 51 | echo "Version changed" 52 | fi 53 | 54 | - name: Install uv 55 | if: ${{ steps.changed.outcome == 'success' }} 56 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 57 | 58 | - name: Bump version in files 59 | if: ${{ steps.changed.outcome == 'success' }} 60 | run: | 61 | version="${{ steps.version.outputs.new_tag }}" 62 | clean_version="${version#v}" 63 | sed -i "s/__version__ = .*/__version__ = \"${clean_version}\"/" src/restic_compose_backup/__init__.py 64 | sed -i "s/version = .*/version = \"${clean_version}\"/" src/pyproject.toml 65 | sed -i "s/release = .*/release = \"${clean_version}\"/" docs/conf.py 66 | uv lock --directory src --upgrade-package restic-compose-backup 67 | 68 | - name: Push version tag 69 | if: ${{ steps.changed.outcome == 'success' }} 70 | uses: anothrNick/github-tag-action@4ed44965e0db8dab2b466a16da04aec3cc312fd8 # 1.75.0 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | CUSTOM_TAG: ${{ steps.version.outputs.new_tag }} 74 | 75 | - name: Log in to the Container registry 76 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 77 | with: 78 | registry: ${{ env.REGISTRY }} 79 | username: ${{ github.actor }} 80 | password: ${{ secrets.GITHUB_TOKEN }} 81 | 82 | - name: Set up QEMU 83 | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 84 | 85 | - name: Set up Docker Buildx 86 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 87 | 88 | - name: Generate metadata for published image 89 | id: meta 90 | run: | 91 | TAG=${{ steps.version.outputs.new_tag }} 92 | echo "version=${TAG#v}" >> $GITHUB_OUTPUT 93 | echo "timestamp=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')" >> $GITHUB_OUTPUT 94 | 95 | - name: Build and push Docker image 96 | id: push 97 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 98 | with: 99 | context: src/ 100 | platforms: linux/amd64,linux/arm64 101 | push: true 102 | tags: | 103 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} 104 | ${{ steps.changed.outcome == 'success' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || null }} 105 | ${{ steps.changed.outcome == 'success' && format('{0}/{1}:{2}', env.REGISTRY, env.IMAGE_NAME, steps.version.outputs.new_tag) || null }} 106 | labels: | 107 | org.opencontainers.image.title=${{ github.event.repository.name }} 108 | org.opencontainers.image.url=${{ github.repositoryUrl }} 109 | org.opencontainers.image.source=${{ github.repositoryUrl }} 110 | org.opencontainers.image.version=${{ steps.meta.outputs.version }} 111 | org.opencontainers.image.revision=${{ github.sha }} 112 | org.opencontainers.image.created=${{ steps.meta.outputs.timestamp }} 113 | 114 | - name: Attest container image build provenance 115 | uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 116 | with: 117 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 118 | subject-digest: ${{ steps.push.outputs.digest }} 119 | push-to-registry: true 120 | 121 | - name: Generate SPDX SBOM 122 | uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10 123 | with: 124 | image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.changed.outcome == 'success' && steps.version.outputs.new_tag || github.ref_name }} 125 | format: spdx-json 126 | output-file: ./sbom.spdx.json 127 | 128 | - name: Generate CycloneDX SBOM 129 | uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10 130 | with: 131 | image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.changed.outcome == 'success' && steps.version.outputs.new_tag || github.ref_name }} 132 | format: cyclonedx-json 133 | output-file: ./sbom.cyclonedx.json 134 | 135 | - name: Attest SBOM 136 | uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0 137 | with: 138 | sbom-path: ./sbom.cyclonedx.json 139 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 140 | subject-digest: ${{ steps.push.outputs.digest }} 141 | push-to-registry: true 142 | 143 | - name: Update release 144 | if: ${{ steps.changed.outcome == 'success' }} 145 | uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 146 | with: 147 | allowUpdates: true 148 | artifacts: "sbom.spdx.json,sbom.cyclonedx.json" 149 | updateOnlyUnreleased: true 150 | generateReleaseNotes: true 151 | tag: ${{ steps.version.outputs.new_tag }} 152 | -------------------------------------------------------------------------------- /src/restic_compose_backup/containers_db.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from restic_compose_backup.containers import Container 4 | from restic_compose_backup.config import config, Config 5 | from restic_compose_backup import ( 6 | commands, 7 | restic, 8 | ) 9 | from restic_compose_backup import utils 10 | 11 | 12 | class MariadbContainer(Container): 13 | container_type = "mariadb" 14 | 15 | def get_credentials(self) -> dict: 16 | """dict: get credentials for the service""" 17 | password = self.get_config_env("MARIADB_ROOT_PASSWORD") 18 | if password is not None: 19 | username = "root" 20 | else: 21 | username = self.get_config_env("MARIADB_USER") 22 | password = self.get_config_env("MARIADB_PASSWORD") 23 | return { 24 | "host": "127.0.0.1", 25 | "username": username, 26 | "password": password, 27 | "port": "3306", 28 | } 29 | 30 | def ping(self) -> bool: 31 | """Check the availability of the service""" 32 | creds = self.get_credentials() 33 | 34 | return ( 35 | commands.ping_mariadb( 36 | self.id, 37 | creds["host"], 38 | creds["port"], 39 | creds["username"], 40 | creds["password"], 41 | ) 42 | == 0 43 | ) 44 | 45 | def dump_command(self) -> list: 46 | """list: create a dump command restic and use to send data through stdin""" 47 | creds = self.get_credentials() 48 | return [ 49 | "mariadb-dump", 50 | f"--user={creds['username']}", 51 | "--all-databases", 52 | "--no-tablespaces", 53 | "--single-transaction", 54 | "--order-by-primary", 55 | "--compact", 56 | "--force", 57 | ] 58 | 59 | def backup(self): 60 | config = Config() 61 | creds = self.get_credentials() 62 | 63 | return restic.backup_from_stdin( 64 | config.repository, 65 | self.backup_destination_path(), 66 | self.id, 67 | self.dump_command(), 68 | environment={"MYSQL_PWD": creds["password"]}, 69 | ) 70 | 71 | def backup_destination_path(self) -> str: 72 | destination = Path("/databases") 73 | 74 | if utils.is_true(config.include_project_name): 75 | project_name = self.project_name 76 | if project_name != "": 77 | destination /= project_name 78 | 79 | destination /= self.service_name 80 | destination /= "all_databases.sql" 81 | 82 | return destination 83 | 84 | 85 | class MysqlContainer(Container): 86 | container_type = "mysql" 87 | 88 | def get_credentials(self) -> dict: 89 | """dict: get credentials for the service""" 90 | password = self.get_config_env("MYSQL_ROOT_PASSWORD") 91 | if password is not None: 92 | username = "root" 93 | else: 94 | username = self.get_config_env("MYSQL_USER") 95 | password = self.get_config_env("MYSQL_PASSWORD") 96 | return { 97 | "host": "127.0.0.1", 98 | "username": username, 99 | "password": password, 100 | "port": "3306", 101 | } 102 | 103 | def ping(self) -> bool: 104 | """Check the availability of the service""" 105 | creds = self.get_credentials() 106 | 107 | return ( 108 | commands.ping_mysql( 109 | self.id, 110 | creds["host"], 111 | creds["port"], 112 | creds["username"], 113 | creds["password"], 114 | ) 115 | == 0 116 | ) 117 | 118 | def dump_command(self) -> list: 119 | """list: create a dump command restic and use to send data through stdin""" 120 | creds = self.get_credentials() 121 | return [ 122 | "mysqldump", 123 | f"--user={creds['username']}", 124 | "--all-databases", 125 | "--no-tablespaces", 126 | "--single-transaction", 127 | "--order-by-primary", 128 | "--compact", 129 | "--force", 130 | ] 131 | 132 | def backup(self): 133 | config = Config() 134 | creds = self.get_credentials() 135 | 136 | return restic.backup_from_stdin( 137 | config.repository, 138 | self.backup_destination_path(), 139 | self.id, 140 | self.dump_command(), 141 | environment={"MYSQL_PWD": creds["password"]}, 142 | ) 143 | 144 | def backup_destination_path(self) -> str: 145 | destination = Path("/databases") 146 | 147 | if utils.is_true(config.include_project_name): 148 | project_name = self.project_name 149 | if project_name != "": 150 | destination /= project_name 151 | 152 | destination /= self.service_name 153 | destination /= "all_databases.sql" 154 | 155 | return destination 156 | 157 | 158 | class PostgresContainer(Container): 159 | container_type = "postgres" 160 | 161 | def get_credentials(self) -> dict: 162 | """dict: get credentials for the service""" 163 | return { 164 | "host": "127.0.0.1", 165 | "username": self.get_config_env("POSTGRES_USER"), 166 | "password": self.get_config_env("POSTGRES_PASSWORD"), 167 | "port": "5432", 168 | "database": self.get_config_env("POSTGRES_DB"), 169 | } 170 | 171 | def ping(self) -> bool: 172 | """Check the availability of the service""" 173 | creds = self.get_credentials() 174 | return ( 175 | commands.ping_postgres( 176 | self.id, 177 | creds["host"], 178 | creds["port"], 179 | creds["username"], 180 | creds["password"], 181 | ) 182 | == 0 183 | ) 184 | 185 | def dump_command(self) -> list: 186 | """list: create a dump command restic and use to send data through stdin""" 187 | # NOTE: Backs up a single database from POSTGRES_DB env var 188 | creds = self.get_credentials() 189 | return [ 190 | "pg_dump", 191 | f"--username={creds['username']}", 192 | creds["database"], 193 | ] 194 | 195 | def backup(self): 196 | config = Config() 197 | creds = self.get_credentials() 198 | 199 | return restic.backup_from_stdin( 200 | config.repository, 201 | self.backup_destination_path(), 202 | self.id, 203 | self.dump_command(), 204 | ) 205 | 206 | def backup_destination_path(self) -> str: 207 | destination = Path("/databases") 208 | 209 | if utils.is_true(config.include_project_name): 210 | project_name = self.project_name 211 | if project_name != "": 212 | destination /= project_name 213 | 214 | destination /= self.service_name 215 | destination /= f"{self.get_credentials()['database']}.sql" 216 | 217 | return destination 218 | -------------------------------------------------------------------------------- /src/tests/unit/test_volume_configuration.py: -------------------------------------------------------------------------------- 1 | """Unit tests for volume backup configuration""" 2 | 3 | import unittest 4 | from unittest import mock 5 | import pytest 6 | 7 | from restic_compose_backup.containers import RunningContainers 8 | from . import fixtures 9 | from .conftest import BaseTestCase 10 | 11 | pytestmark = pytest.mark.unit 12 | 13 | list_containers_func = "restic_compose_backup.utils.list_containers" 14 | 15 | 16 | class VolumeConfigurationTests(BaseTestCase): 17 | """Tests for volume backup configuration and filtering""" 18 | 19 | def test_volumes_for_backup(self): 20 | """Test identifying volumes marked for backup""" 21 | containers = self.createContainers() 22 | containers += [ 23 | { 24 | "service": "web", 25 | "labels": { 26 | "stack-back.volumes": True, 27 | }, 28 | "mounts": [ 29 | { 30 | "Source": "test", 31 | "Destination": "test", 32 | "Type": "bind", 33 | } 34 | ], 35 | }, 36 | { 37 | "service": "mysql", 38 | "labels": { 39 | "stack-back.mysql": True, 40 | }, 41 | "mounts": [ 42 | { 43 | "Source": "data", 44 | "Destination": "data", 45 | "Type": "bind", 46 | } 47 | ], 48 | }, 49 | ] 50 | with mock.patch( 51 | list_containers_func, fixtures.containers(containers=containers) 52 | ): 53 | cnt = RunningContainers() 54 | self.assertTrue(len(cnt.containers_for_backup()) == 2) 55 | self.assertEqual( 56 | cnt.generate_backup_mounts(), 57 | {"test": {"bind": "/volumes/web/test", "mode": "ro"}}, 58 | ) 59 | mysql_service = cnt.get_service("mysql") 60 | self.assertNotEqual(mysql_service, None, msg="MySQL service not found") 61 | mounts = mysql_service.filter_mounts() 62 | print(mounts) 63 | self.assertTrue(mysql_service.mysql_backup_enabled) 64 | self.assertEqual(len(mounts), 0) 65 | 66 | def test_no_volumes_for_backup(self): 67 | """Test containers with no volumes to backup""" 68 | containers = self.createContainers() 69 | containers += [ 70 | { 71 | "service": "web", 72 | "labels": { 73 | "stack-back.volumes": True, 74 | }, 75 | }, 76 | ] 77 | with mock.patch( 78 | list_containers_func, fixtures.containers(containers=containers) 79 | ): 80 | cnt = RunningContainers() 81 | self.assertTrue(len(cnt.containers_for_backup()) == 1) 82 | web_service = cnt.get_service("web") 83 | self.assertNotEqual(web_service, None, msg="Web service not found") 84 | mounts = web_service.filter_mounts() 85 | print(mounts) 86 | self.assertEqual(len(mounts), 0) 87 | 88 | def test_include(self): 89 | """Test including specific volumes by name""" 90 | containers = self.createContainers() 91 | containers += [ 92 | { 93 | "service": "web", 94 | "labels": { 95 | "stack-back.volumes": True, 96 | "stack-back.volumes.include": "media", 97 | }, 98 | "mounts": [ 99 | { 100 | "Source": "/srv/files/media", 101 | "Destination": "/srv/media", 102 | "Type": "bind", 103 | }, 104 | { 105 | "Source": "/srv/files/stuff", 106 | "Destination": "/srv/stuff", 107 | "Type": "bind", 108 | }, 109 | ], 110 | }, 111 | ] 112 | with mock.patch( 113 | list_containers_func, fixtures.containers(containers=containers) 114 | ): 115 | cnt = RunningContainers() 116 | 117 | web_service = cnt.get_service("web") 118 | self.assertNotEqual(web_service, None, msg="Web service not found") 119 | 120 | mounts = web_service.filter_mounts() 121 | print(mounts) 122 | self.assertEqual(len(mounts), 1) 123 | self.assertEqual(mounts[0].source, "/srv/files/media") 124 | 125 | def test_exclude(self): 126 | """Test excluding specific volumes by name""" 127 | containers = self.createContainers() 128 | containers += [ 129 | { 130 | "service": "web", 131 | "labels": { 132 | "stack-back.volumes": True, 133 | "stack-back.volumes.exclude": "stuff", 134 | }, 135 | "mounts": [ 136 | { 137 | "Source": "/srv/files/media", 138 | "Destination": "/srv/media", 139 | "Type": "bind", 140 | }, 141 | { 142 | "Source": "/srv/files/stuff", 143 | "Destination": "/srv/stuff", 144 | "Type": "bind", 145 | }, 146 | ], 147 | }, 148 | ] 149 | with mock.patch( 150 | list_containers_func, fixtures.containers(containers=containers) 151 | ): 152 | cnt = RunningContainers() 153 | 154 | web_service = cnt.get_service("web") 155 | self.assertNotEqual(web_service, None, msg="Web service not found") 156 | 157 | mounts = web_service.filter_mounts() 158 | self.assertEqual(len(mounts), 1) 159 | self.assertEqual(mounts[0].source, "/srv/files/media") 160 | 161 | def test_stop_container_during_backup_volume(self): 162 | """Test stopping containers during volume backup""" 163 | containers = self.createContainers() 164 | containers += [ 165 | { 166 | "service": "web", 167 | "labels": { 168 | "stack-back.volumes": True, 169 | "stack-back.volumes.include": "sqlite", 170 | "stack-back.volumes.stop-during-backup": True, 171 | }, 172 | "mounts": [ 173 | { 174 | "Source": "/srv/files/media", 175 | "Destination": "/srv/media", 176 | "Type": "bind", 177 | }, 178 | { 179 | "Source": "/srv/files/sqlite", 180 | "Destination": "/srv/sqlite", 181 | "Type": "bind", 182 | }, 183 | ], 184 | } 185 | ] 186 | with mock.patch( 187 | list_containers_func, fixtures.containers(containers=containers) 188 | ): 189 | cnt = RunningContainers() 190 | web_service = cnt.get_service("web") 191 | self.assertNotEqual(web_service, None, msg="Web service not found") 192 | self.assertTrue(web_service.stop_during_backup) 193 | -------------------------------------------------------------------------------- /docs/guide/rcb.rst: -------------------------------------------------------------------------------- 1 | 2 | The `rcb` command 3 | ----------------- 4 | 5 | The ``rcb`` command is is basically what this entire project is. 6 | It provides useful commands interacting with the compose setup 7 | and restic. 8 | 9 | The command can be executed inside the container or through ``run``. 10 | 11 | .. code:: bash 12 | 13 | # Get the current status using run 14 | $ docker-compose run --rm backup rcb status 15 | 16 | # by entering the container 17 | $ docker-compose exec backup sh 18 | /stack-back # rcb status 19 | 20 | Log level can be overridden by using the ``--log-level`` 21 | flag. This can help you better understand what is going on 22 | for example by using ``--log-level debug``. 23 | 24 | version 25 | ~~~~~~~ 26 | 27 | Displays the version. 28 | 29 | Example output:: 30 | 31 | /stack-back # rcb version 32 | 1.0.0 33 | 34 | status 35 | ~~~~~~ 36 | 37 | Shows the general status of our setup. The command is doing 38 | the following operations 39 | 40 | - Displays the name of the compose setup 41 | - Displays the repository path 42 | - Tells us if a backup is currently running 43 | - Removes stale backup process containers if the exist 44 | - Checks is the repository is initialized 45 | - Initializes the repository if this is not already done 46 | - Displays what volumes and databases are flagged for backup 47 | 48 | Example output:: 49 | 50 | INFO: Status for compose project 'myproject' 51 | INFO: Repository: '' 52 | INFO: Backup currently running?: False 53 | INFO: --------------- Detected Config --------------- 54 | INFO: service: mysql 55 | INFO: - mysql (is_ready=True) 56 | INFO: service: mariadb 57 | INFO: - mariadb (is_ready=True) 58 | INFO: service: postgres 59 | INFO: - postgres (is_ready=True) 60 | INFO: service: web 61 | INFO: - volume: media 62 | INFO: - volume: /srv/files 63 | 64 | alert 65 | ~~~~~ 66 | 67 | Sends a test message to all configured alert backends 68 | and is there for you to verify that alerts are in 69 | fact working and configured correctly. 70 | 71 | The format of this message:: 72 | 73 | subject: myproject: Test Alert 74 | body: Test message 75 | 76 | snapshots 77 | ~~~~~~~~~ 78 | 79 | Displays the latest snapshots in restic. This can also 80 | be done with ``restic snapshots``. 81 | 82 | Example output:: 83 | 84 | /stack-back # rcb snapshots 85 | repository f325264e opened successfully, password is correct 86 | ID Time Host Tags Paths 87 | --------------------------------------------------------------------------------------------- 88 | 19928e1c 2019-12-09 02:07:44 b3038db04ec1 /volumes 89 | 7a642f37 2019-12-09 02:07:45 b3038db04ec1 /databases/mysql/all_databases.sql 90 | 883dada4 2019-12-09 02:07:46 b3038db04ec1 /databases/mariadb/all_databases.sql 91 | 76ef2457 2019-12-09 02:07:47 b3038db04ec1 /databases/postgres/test.sql 92 | --------------------------------------------------------------------------------------------- 93 | 4 snapshots 94 | 95 | backup 96 | ~~~~~~ 97 | 98 | Starts a backup process by spawning a new docker container. 99 | The mounted volumes, env vars etc. from the 100 | backup service are copied to this container. 101 | 102 | We attach to this container and stream the logs and delete 103 | the container with the backup process is completed. If the 104 | container for any reason should not be deleted, it will 105 | be in next backup run as these containers are tagged with 106 | a unique label and detected. 107 | 108 | If anything goes wrong the exist status of the container 109 | is non-zero and the logs from this backup run will be sent 110 | to the user through the configure alerts. 111 | 112 | This command is by default called by cron every 113 | day at 02:00 unless configured otherwise. We can also run this 114 | manually is needed. 115 | 116 | Running this command will do the following: 117 | 118 | * Checks if a backup process is already running. 119 | If so, we alert the user and abort 120 | * Gathers all the volumes configured for backup and starts 121 | the backup process with these volumes mounted into ``/volumes`` 122 | * Checks the status of the process and reports to the user 123 | if anything failed 124 | 125 | The backup process does the following: 126 | 127 | * ``status`` is first called to ensure everything is ok 128 | * Backs up ``/volumes`` if any volumes were mounted 129 | * Backs up each configured database 130 | * Runs ``cleanup`` purging snapshots based on the configured policy 131 | * Checks the health of the repository 132 | 133 | Example:: 134 | 135 | $ docker-compose exec backup sh 136 | /stack-back # rcb backup 137 | INFO: Starting backup container 138 | INFO: Backup process container: loving_jepsen 139 | INFO: 2019-12-09 04:50:22,817 - INFO: Status for compose project 'stack-back' 140 | INFO: 2019-12-09 04:50:22,817 - INFO: Repository: '/restic_data' 141 | INFO: 2019-12-09 04:50:22,817 - INFO: Backup currently running?: True 142 | INFO: 2019-12-09 04:50:23,701 - INFO: ------------------------- Detected Config ------------------------- 143 | INFO: 2019-12-09 04:50:23,701 - INFO: service: mysql 144 | INFO: 2019-12-09 04:50:23,718 - INFO: - mysql (is_ready=True) 145 | INFO: 2019-12-09 04:50:23,718 - INFO: service: mariadb 146 | INFO: 2019-12-09 04:50:23,726 - INFO: - mariadb (is_ready=True) 147 | INFO: 2019-12-09 04:50:23,727 - INFO: service: postgres 148 | INFO: 2019-12-09 04:50:23,734 - INFO: - postgres (is_ready=True) 149 | INFO: 2019-12-09 04:50:23,735 - INFO: service: web 150 | INFO: 2019-12-09 04:50:23,736 - INFO: - volume: /some/volume 151 | INFO: 2019-12-09 04:50:23,736 - INFO: ------------------------------------------------------------------- 152 | INFO: 2019-12-09 04:50:23,736 - INFO: Backing up volumes 153 | INFO: 2019-12-09 04:50:24,661 - INFO: Backing up databases 154 | INFO: 2019-12-09 04:50:24,661 - INFO: Backing up mysql in service mysql 155 | INFO: 2019-12-09 04:50:25,643 - INFO: Backing up mariadb in service mariadb 156 | INFO: 2019-12-09 04:50:26,580 - INFO: Backing up postgres in service postgres 157 | INFO: 2019-12-09 04:50:27,555 - INFO: Forget outdated snapshots 158 | INFO: 2019-12-09 04:50:28,457 - INFO: Prune stale data freeing storage space 159 | INFO: 2019-12-09 04:50:31,547 - INFO: Checking the repository for errors 160 | INFO: 2019-12-09 04:50:32,869 - INFO: Backup completed 161 | INFO: Backup container exit code: 0 162 | 163 | crontab 164 | ~~~~~~~ 165 | 166 | Generates and verifies the crontab. This is done automatically when 167 | the container starts. It can be user to verify the configuration. 168 | 169 | Example output:: 170 | 171 | /stack-back # rcb crontab 172 | 10 2 * * * source /env.sh && rcb backup > /proc/1/fd/1 173 | 174 | cleanup 175 | ~~~~~~~ 176 | 177 | Purges all snapshots based on the configured policy. (``RESTIC_KEEP_*`` 178 | env variables). It runs ``restic forget`` and ``restic purge``. 179 | 180 | Example output:: 181 | 182 | /stack-back # rcb cleanup 183 | 2019-12-09 05:09:52,892 - INFO: Forget outdated snapshots 184 | 2019-12-09 05:09:53,776 - INFO: Prune stale data freeing storage space 185 | 186 | start-backup-process 187 | ~~~~~~~~~~~~~~~~~~~~ 188 | 189 | This can only be executed by the backup process container. 190 | Attempting to run this command in the backup service 191 | will simply tell you it's not possible. 192 | 193 | The backup process is doing the following: 194 | 195 | * ``status`` is first called to ensure everything is ok 196 | * Backs up ``/volumes`` if any volumes were mounted 197 | * Backs up each configured database 198 | * Runs ``cleanup`` purging snapshots based on the configured policy 199 | * Checks the health of the repository 200 | -------------------------------------------------------------------------------- /src/tests/unit/test_auto_backup_all.py: -------------------------------------------------------------------------------- 1 | """Unit tests for AUTO_BACKUP_ALL configuration""" 2 | 3 | import unittest 4 | from unittest import mock 5 | import pytest 6 | 7 | from restic_compose_backup import config 8 | from restic_compose_backup.containers import RunningContainers 9 | from . import fixtures 10 | from .conftest import BaseTestCase 11 | 12 | pytestmark = pytest.mark.unit 13 | 14 | list_containers_func = "restic_compose_backup.utils.list_containers" 15 | 16 | 17 | class AutoBackupAllTests(BaseTestCase): 18 | """Tests for AUTO_BACKUP_ALL configuration option""" 19 | 20 | def setUp(self): 21 | super().setUp() 22 | # Enable AUTO_BACKUP_ALL for each test 23 | self._original_auto_backup_all = config.config.auto_backup_all 24 | config.config.auto_backup_all = "true" 25 | 26 | def tearDown(self): 27 | # Restore original setting after each test 28 | config.config.auto_backup_all = self._original_auto_backup_all 29 | super().tearDown() 30 | 31 | def test_all_volumes(self): 32 | """Test that the AUTO_BACKUP_ALL flag works""" 33 | containers = self.createContainers() 34 | containers += [ 35 | { 36 | "service": "web", 37 | "mounts": [ 38 | { 39 | "Source": "/srv/files/media", 40 | "Destination": "/srv/media", 41 | "Type": "bind", 42 | }, 43 | { 44 | "Source": "/srv/files/stuff", 45 | "Destination": "/srv/stuff", 46 | "Type": "bind", 47 | }, 48 | ], 49 | }, 50 | ] 51 | with mock.patch( 52 | list_containers_func, fixtures.containers(containers=containers) 53 | ): 54 | cnt = RunningContainers() 55 | 56 | web_service = cnt.get_service("web") 57 | self.assertNotEqual(web_service, None, msg="Web service not found") 58 | 59 | mounts = web_service.filter_mounts() 60 | print(mounts) 61 | self.assertEqual(len(mounts), 2) 62 | self.assertEqual(mounts[0].source, "/srv/files/media") 63 | self.assertEqual(mounts[1].source, "/srv/files/stuff") 64 | 65 | def test_all_databases(self): 66 | """Test that the AUTO_BACKUP_ALL flag intelligently handles databases based on image""" 67 | containers = self.createContainers() 68 | containers += [ 69 | { 70 | "service": "mysql", 71 | "image": "mysql:8", 72 | "mounts": [ 73 | { 74 | "Source": "/srv/mysql/data", 75 | "Destination": "/var/lib/mysql", 76 | "Type": "bind", 77 | }, 78 | ], 79 | }, 80 | { 81 | "service": "mariadb", 82 | "image": "mariadb:11", 83 | "mounts": [ 84 | { 85 | "Source": "/srv/mariadb/data", 86 | "Destination": "/var/lib/mysql", 87 | "Type": "bind", 88 | }, 89 | ], 90 | }, 91 | { 92 | "service": "postgres", 93 | "image": "postgres:17", 94 | "mounts": [ 95 | { 96 | "Source": "/srv/postgres/data", 97 | "Destination": "/var/lib/postgresql/data", 98 | "Type": "bind", 99 | }, 100 | ], 101 | }, 102 | ] 103 | with mock.patch( 104 | list_containers_func, fixtures.containers(containers=containers) 105 | ): 106 | cnt = RunningContainers() 107 | 108 | mysql_service = cnt.get_service("mysql") 109 | self.assertNotEqual(mysql_service, None, msg="MySQL service not found") 110 | self.assertTrue(mysql_service.mysql_backup_enabled) 111 | mounts = mysql_service.filter_mounts() 112 | print(mounts) 113 | self.assertEqual(len(mounts), 0) 114 | 115 | mariadb_service = cnt.get_service("mariadb") 116 | self.assertNotEqual(mariadb_service, None, msg="MariaDB service not found") 117 | self.assertTrue(mariadb_service.mariadb_backup_enabled) 118 | mounts = mariadb_service.filter_mounts() 119 | print(mounts) 120 | self.assertEqual(len(mounts), 0) 121 | 122 | postgres_service = cnt.get_service("postgres") 123 | self.assertNotEqual(postgres_service, None, msg="Postgres service not found") 124 | self.assertTrue(postgres_service.postgresql_backup_enabled) 125 | mounts = postgres_service.filter_mounts() 126 | print(mounts) 127 | self.assertEqual(len(mounts), 0) 128 | 129 | def test_redundant_volume_label(self): 130 | """Test that a container has a redundant volume label should be backed up""" 131 | 132 | containers = self.createContainers() 133 | containers += [ 134 | { 135 | "service": "web", 136 | "labels": { 137 | "stack-back.volumes": True, 138 | }, 139 | "mounts": [ 140 | { 141 | "Source": "/srv/files/media", 142 | "Destination": "/srv/media", 143 | "Type": "bind", 144 | }, 145 | { 146 | "Source": "/srv/files/stuff", 147 | "Destination": "/srv/stuff", 148 | "Type": "bind", 149 | }, 150 | ], 151 | }, 152 | ] 153 | with mock.patch( 154 | list_containers_func, fixtures.containers(containers=containers) 155 | ): 156 | cnt = RunningContainers() 157 | 158 | web_service = cnt.get_service("web") 159 | self.assertNotEqual(web_service, None, msg="Web service not found") 160 | 161 | mounts = web_service.filter_mounts() 162 | print(mounts) 163 | self.assertEqual(len(mounts), 2) 164 | self.assertEqual(mounts[0].source, "/srv/files/media") 165 | self.assertEqual(mounts[1].source, "/srv/files/stuff") 166 | 167 | def test_redundant_database_label(self): 168 | """Test that a container has a redundant database label should be backed up""" 169 | containers = self.createContainers() 170 | containers += [ 171 | { 172 | "service": "mysql", 173 | "image": "mysql:8", 174 | "labels": { 175 | "stack-back.mysql": True, 176 | }, 177 | "mounts": [ 178 | { 179 | "Source": "/srv/mysql/data", 180 | "Destination": "/var/lib/mysql", 181 | "Type": "bind", 182 | }, 183 | ], 184 | }, 185 | ] 186 | with mock.patch( 187 | list_containers_func, fixtures.containers(containers=containers) 188 | ): 189 | cnt = RunningContainers() 190 | 191 | mysql_service = cnt.get_service("mysql") 192 | self.assertNotEqual(mysql_service, None, msg="MySQL service not found") 193 | self.assertTrue(mysql_service.mysql_backup_enabled) 194 | mounts = mysql_service.filter_mounts() 195 | print(mounts) 196 | self.assertEqual(len(mounts), 0) 197 | 198 | def test_explicit_volumes_exclude(self): 199 | """Test that a container's volumes can be excluded from the backup""" 200 | 201 | containers = self.createContainers() 202 | containers += [ 203 | { 204 | "service": "web", 205 | "labels": { 206 | "stack-back.volumes": False, 207 | }, 208 | "mounts": [ 209 | { 210 | "Source": "/srv/files/media", 211 | "Destination": "/srv/media", 212 | "Type": "bind", 213 | }, 214 | { 215 | "Source": "/srv/files/stuff", 216 | "Destination": "/srv/stuff", 217 | "Type": "bind", 218 | }, 219 | ], 220 | }, 221 | ] 222 | with mock.patch( 223 | list_containers_func, fixtures.containers(containers=containers) 224 | ): 225 | cnt = RunningContainers() 226 | 227 | web_service = cnt.get_service("web") 228 | self.assertNotEqual(web_service, None, msg="Web service not found") 229 | 230 | mounts = web_service.filter_mounts() 231 | print(mounts) 232 | self.assertEqual(len(mounts), 0) 233 | 234 | def test_specific_volume_exclude(self): 235 | """Test that a specific volume can be excluded from the backup""" 236 | 237 | containers = self.createContainers() 238 | containers += [ 239 | { 240 | "service": "web", 241 | "labels": { 242 | "stack-back.volumes.exclude": "stuff", 243 | }, 244 | "mounts": [ 245 | { 246 | "Source": "/srv/files/media", 247 | "Destination": "/srv/media", 248 | "Type": "bind", 249 | }, 250 | { 251 | "Source": "/srv/files/stuff", 252 | "Destination": "/srv/stuff", 253 | "Type": "bind", 254 | }, 255 | ], 256 | }, 257 | ] 258 | with mock.patch( 259 | list_containers_func, fixtures.containers(containers=containers) 260 | ): 261 | cnt = RunningContainers() 262 | 263 | web_service = cnt.get_service("web") 264 | self.assertNotEqual(web_service, None, msg="Web service not found") 265 | 266 | mounts = web_service.filter_mounts() 267 | print(mounts) 268 | self.assertEqual(len(mounts), 1) 269 | self.assertEqual(mounts[0].source, "/srv/files/media") 270 | 271 | def test_specific_database_exclude(self): 272 | """Test that a database container can be excluded from the backup""" 273 | containers = self.createContainers() 274 | containers += [ 275 | { 276 | "service": "mysql", 277 | "labels": { 278 | "stack-back.mysql": False, 279 | "stack-back.volumes": False, 280 | }, 281 | "mounts": [ 282 | { 283 | "Source": "/srv/mysql/data", 284 | "Destination": "/var/lib/mysql", 285 | "Type": "bind", 286 | }, 287 | ], 288 | }, 289 | ] 290 | with mock.patch( 291 | list_containers_func, fixtures.containers(containers=containers) 292 | ): 293 | cnt = RunningContainers() 294 | 295 | mysql_service = cnt.get_service("mysql") 296 | self.assertNotEqual(mysql_service, None, msg="MySQL service not found") 297 | self.assertFalse(mysql_service.mysql_backup_enabled) 298 | mounts = mysql_service.filter_mounts() 299 | print(mounts) 300 | self.assertEqual(len(mounts), 0) 301 | -------------------------------------------------------------------------------- /src/tests/integration/test_all_compose_projects.py: -------------------------------------------------------------------------------- 1 | """Integration tests for INCLUDE_ALL_COMPOSE_PROJECTS configuration""" 2 | 3 | import time 4 | import pytest 5 | 6 | pytestmark = pytest.mark.integration 7 | 8 | 9 | def test_status_shows_only_same_project_by_default( 10 | run_rcb_command, secondary_compose_up 11 | ): 12 | """Test that status command only shows containers from the same compose project by default""" 13 | exit_code, output = run_rcb_command("status") 14 | assert exit_code == 0, f"Status command failed: {output}" 15 | 16 | # Should show services from the main project 17 | assert "service: web" in output 18 | assert "service: mysql" in output 19 | assert "service: mariadb" in output 20 | assert "service: postgres" in output 21 | 22 | # Should NOT show services from the secondary project (different compose project) 23 | assert "service: secondary_web" not in output 24 | assert "service: secondary_mysql" not in output 25 | assert "service: secondary_postgres" not in output 26 | 27 | 28 | def test_backup_only_same_project_by_default( 29 | run_rcb_command, secondary_compose_up, secondary_web_container, create_test_data 30 | ): 31 | """Test that backup only includes containers from the same compose project by default""" 32 | # Create test data in the secondary project 33 | create_test_data("test_data/secondary_web/test.txt", "Secondary project data") 34 | time.sleep(2) 35 | 36 | # Also create data in secondary container's named volume 37 | exit_code, output = secondary_web_container.exec_run( 38 | "sh -c 'echo \"Secondary volume data\" > /usr/share/nginx/html/secondary.html'" 39 | ) 40 | assert exit_code == 0, ( 41 | f"Failed to create test data in secondary container: {output.decode()}" 42 | ) 43 | 44 | time.sleep(2) 45 | 46 | # Run backup with default settings (INCLUDE_ALL_COMPOSE_PROJECTS=false) 47 | exit_code, output = run_rcb_command("backup") 48 | assert exit_code == 0, f"Backup command failed: {output}" 49 | 50 | # The backup should only mention the main project's services 51 | # It should not backup the secondary project 52 | # We verify this indirectly by checking the status output doesn't mention secondary services 53 | exit_code, status_output = run_rcb_command("status") 54 | assert exit_code == 0 55 | assert "service: secondary_web" not in status_output 56 | 57 | 58 | def test_status_with_include_all_compose_projects(backup_container_with_multi_project): 59 | """Test that status command shows containers from all compose projects when enabled""" 60 | # Use the backup container with INCLUDE_ALL_COMPOSE_PROJECTS=true 61 | exit_code, output = backup_container_with_multi_project.exec_run("rcb status") 62 | output_text = output.decode() 63 | assert exit_code == 0, f"Status command failed: {output_text}" 64 | 65 | # Should show services from the main project 66 | assert "service: web" in output_text 67 | assert "service: mysql" in output_text 68 | assert "service: mariadb" in output_text 69 | assert "service: postgres" in output_text 70 | 71 | # Should ALSO show services from the secondary project 72 | assert "service: secondary_web" in output_text 73 | assert "service: secondary_mysql" in output_text 74 | assert "service: secondary_postgres" in output_text 75 | 76 | 77 | def test_backup_all_compose_projects_volumes( 78 | backup_container_with_multi_project, 79 | secondary_web_container, 80 | create_test_data, 81 | project_root, 82 | ): 83 | """Test that volumes from all compose projects are backed up when enabled""" 84 | # Create test data in secondary project bind mount 85 | test_content_bind = "Secondary project bind mount data" 86 | create_test_data( 87 | "test_data/secondary_web/multi_project_test.txt", test_content_bind 88 | ) 89 | time.sleep(2) 90 | 91 | # Create test data in secondary project named volume 92 | test_content_volume = "Secondary project named volume data" 93 | exit_code, output = secondary_web_container.exec_run( 94 | f"sh -c 'echo \"{test_content_volume}\" > /usr/share/nginx/html/multi_project.html'" 95 | ) 96 | assert exit_code == 0, f"Failed to create test data: {output.decode()}" 97 | 98 | time.sleep(2) 99 | 100 | # Run backup with INCLUDE_ALL_COMPOSE_PROJECTS=true 101 | exit_code, output = backup_container_with_multi_project.exec_run("rcb backup") 102 | backup_output = output.decode() 103 | assert exit_code == 0, f"Backup command failed: {backup_output}" 104 | 105 | # Verify snapshots were created 106 | exit_code, output = backup_container_with_multi_project.exec_run("rcb snapshots") 107 | snapshots_output = output.decode() 108 | assert exit_code == 0, f"Snapshots command failed: {snapshots_output}" 109 | assert len(snapshots_output.strip().split("\n")) > 1, "No snapshots found" 110 | 111 | # Restore from backup and verify secondary project data exists 112 | exit_code, output = backup_container_with_multi_project.exec_run( 113 | "restic restore latest --target /restore --path /volumes" 114 | ) 115 | assert exit_code == 0, f"Restore command failed: {output.decode()}" 116 | 117 | # Check for secondary project bind mount data 118 | exit_code, output = backup_container_with_multi_project.exec_run( 119 | "find /restore/volumes -name multi_project_test.txt" 120 | ) 121 | find_output = output.decode() 122 | assert exit_code == 0, f"Failed to find restored file: {find_output}" 123 | assert "multi_project_test.txt" in find_output, ( 124 | "Secondary project bind mount file not found in backup" 125 | ) 126 | 127 | # Check for secondary project named volume data 128 | exit_code, output = backup_container_with_multi_project.exec_run( 129 | "find /restore/volumes -name multi_project.html" 130 | ) 131 | find_output = output.decode() 132 | assert exit_code == 0, f"Failed to find restored file: {find_output}" 133 | assert "multi_project.html" in find_output, ( 134 | "Secondary project named volume file not found in backup" 135 | ) 136 | 137 | 138 | def test_backup_all_compose_projects_databases( 139 | backup_container_with_multi_project, 140 | secondary_mysql_container, 141 | secondary_postgres_container, 142 | ): 143 | """Test that databases from all compose projects are backed up when enabled""" 144 | # Create test data in secondary MySQL 145 | exit_code, output = secondary_mysql_container.exec_run( 146 | "mysql -u root -psecondary_root_password -e " 147 | '"CREATE TABLE IF NOT EXISTS secondary_db.items (id INT, name VARCHAR(50)); ' 148 | "INSERT INTO secondary_db.items VALUES (1, 'SecondaryItem1'), (2, 'SecondaryItem2');\"" 149 | ) 150 | assert exit_code == 0, f"Failed to create MySQL test data: {output.decode()}" 151 | 152 | # Create test data in secondary PostgreSQL 153 | exit_code, output = secondary_postgres_container.exec_run( 154 | "psql -U secondary_user -d secondary_db -c " 155 | '"CREATE TABLE IF NOT EXISTS records (id INT, value VARCHAR(50)); ' 156 | "INSERT INTO records VALUES (1, 'SecondaryRecord1'), (2, 'SecondaryRecord2');\"" 157 | ) 158 | assert exit_code == 0, f"Failed to create PostgreSQL test data: {output.decode()}" 159 | 160 | time.sleep(2) 161 | 162 | # Run backup with INCLUDE_ALL_COMPOSE_PROJECTS=true 163 | exit_code, output = backup_container_with_multi_project.exec_run("rcb backup") 164 | backup_output = output.decode() 165 | assert exit_code == 0, f"Backup command failed: {backup_output}" 166 | 167 | # Verify the backup mentions database backups 168 | assert ( 169 | "Backing up databases" in backup_output or "database" in backup_output.lower() 170 | ) 171 | 172 | # Verify snapshots exist 173 | exit_code, output = backup_container_with_multi_project.exec_run("rcb snapshots") 174 | assert exit_code == 0, f"Snapshots command failed: {output.decode()}" 175 | 176 | 177 | def test_all_projects_after_multiple_backups( 178 | backup_container_with_multi_project, secondary_web_container, create_test_data 179 | ): 180 | """Test multiple backups with data changes across different compose projects""" 181 | # First backup 182 | create_test_data("test_data/secondary_web/backup1.txt", "First backup data") 183 | time.sleep(2) 184 | exit_code, _ = backup_container_with_multi_project.exec_run("rcb backup") 185 | assert exit_code == 0 186 | 187 | # Second backup with new data 188 | time.sleep(2) 189 | create_test_data("test_data/secondary_web/backup2.txt", "Second backup data") 190 | time.sleep(2) 191 | exit_code, _ = backup_container_with_multi_project.exec_run("rcb backup") 192 | assert exit_code == 0 193 | 194 | # Verify multiple snapshots 195 | exit_code, output = backup_container_with_multi_project.exec_run("rcb snapshots") 196 | snapshots_output = output.decode() 197 | assert exit_code == 0 198 | 199 | # Should have at least 2 snapshots 200 | snapshot_lines = [ 201 | line 202 | for line in snapshots_output.split("\n") 203 | if line.strip() and not line.startswith("-") 204 | ] 205 | snapshot_count = len( 206 | [ 207 | line 208 | for line in snapshot_lines 209 | if "latest" not in line.lower() and len(line) > 20 210 | ] 211 | ) 212 | assert snapshot_count >= 2, f"Expected at least 2 snapshots, found {snapshot_count}" 213 | 214 | 215 | def test_include_all_projects_with_excluded_services( 216 | backup_container_with_multi_project, 217 | ): 218 | """Test that excluded services are still excluded even with INCLUDE_ALL_COMPOSE_PROJECTS=true""" 219 | exit_code, output = backup_container_with_multi_project.exec_run("rcb status") 220 | status_output = output.decode() 221 | assert exit_code == 0 222 | 223 | # The excluded_service from main project should not appear 224 | assert "service: excluded_service" not in status_output, ( 225 | "Excluded service should not be in backup list even with INCLUDE_ALL_COMPOSE_PROJECTS" 226 | ) 227 | 228 | # But other services should appear 229 | assert "service: web" in status_output 230 | assert "service: secondary_web" in status_output 231 | 232 | 233 | def test_project_name_in_backup_path_with_all_projects( 234 | backup_container_with_multi_project, create_test_data 235 | ): 236 | """Test that when INCLUDE_ALL_COMPOSE_PROJECTS is enabled, project names are included in paths""" 237 | # Create test data 238 | create_test_data( 239 | "test_data/secondary_web/project_name_test.txt", "Testing project names" 240 | ) 241 | time.sleep(2) 242 | 243 | # Run backup 244 | exit_code, _ = backup_container_with_multi_project.exec_run("rcb backup") 245 | assert exit_code == 0 246 | 247 | # When INCLUDE_ALL_COMPOSE_PROJECTS is true, INCLUDE_PROJECT_NAME should also be enabled 248 | # Check the status to verify 249 | exit_code, output = backup_container_with_multi_project.exec_run("rcb status") 250 | assert exit_code == 0 251 | 252 | # The backup should have project-specific paths 253 | # We can verify this by restoring and checking the path structure 254 | exit_code, output = backup_container_with_multi_project.exec_run( 255 | "restic restore latest --target /restore" 256 | ) 257 | assert exit_code == 0, f"Restore failed: {output.decode()}" 258 | 259 | # List the restored structure 260 | exit_code, output = backup_container_with_multi_project.exec_run( 261 | "find /restore -type d -name '*secondary*' -o -type d -name '*integration*'" 262 | ) 263 | # Should find directories with project names in the path 264 | # Note: The exact path structure depends on INCLUDE_PROJECT_NAME implementation 265 | assert exit_code == 0 266 | -------------------------------------------------------------------------------- /src/restic_compose_backup/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import logging 4 | 5 | from restic_compose_backup import ( 6 | alerts, 7 | backup_runner, 8 | log, 9 | restic, 10 | ) 11 | from restic_compose_backup.config import Config 12 | from restic_compose_backup.containers import RunningContainers 13 | from restic_compose_backup import cron, utils 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def main(): 19 | """CLI entrypoint""" 20 | args = parse_args() 21 | config = Config() 22 | log.setup(level=args.log_level or config.log_level) 23 | containers = RunningContainers() 24 | 25 | # Ensure log level is propagated to parent container if overridden 26 | if args.log_level: 27 | containers.this_container.set_config_env("LOG_LEVEL", args.log_level) 28 | 29 | if args.action == "status": 30 | status(config, containers) 31 | 32 | elif args.action == "snapshots": 33 | snapshots(config, containers) 34 | 35 | elif args.action == "backup": 36 | backup(config, containers) 37 | 38 | elif args.action == "start-backup-process": 39 | start_backup_process(config, containers) 40 | 41 | elif args.action == "maintenance": 42 | maintenance(config, containers) 43 | 44 | elif args.action == "cleanup": 45 | cleanup(config, containers) 46 | 47 | elif args.action == "alert": 48 | alert(config, containers) 49 | 50 | elif args.action == "version": 51 | import restic_compose_backup 52 | 53 | print(restic_compose_backup.__version__) 54 | 55 | elif args.action == "crontab": 56 | crontab(config) 57 | 58 | elif args.action == "dump-env": 59 | dump_env() 60 | 61 | # Random test stuff here 62 | elif args.action == "test": 63 | nodes = utils.get_swarm_nodes() 64 | print("Swarm nodes:") 65 | for node in nodes: 66 | addr = node.attrs["Status"]["Addr"] 67 | state = node.attrs["Status"]["State"] 68 | print(" - {} {} {}".format(node.id, addr, state)) 69 | 70 | 71 | def status(config, containers): 72 | """Outputs the backup config for the compose setup""" 73 | logger.info("Status for compose project '%s'", containers.project_name) 74 | logger.info("Repository: '%s'", config.repository) 75 | logger.info("Backup currently running?: %s", containers.backup_process_running) 76 | logger.info( 77 | "Include project name in backup path?: %s", 78 | utils.is_true(config.include_project_name), 79 | ) 80 | logger.debug( 81 | "Exclude bind mounts from backups?: %s", 82 | utils.is_true(config.exclude_bind_mounts), 83 | ) 84 | logger.debug( 85 | "Include all compose projects?: %s", 86 | utils.is_true(config.include_all_compose_projects), 87 | ) 88 | logger.debug( 89 | f"Use cache for integrity check?: {utils.is_true(config.check_with_cache)}" 90 | ) 91 | logger.info("Checking docker availability") 92 | 93 | utils.list_containers() 94 | 95 | if containers.stale_backup_process_containers: 96 | utils.remove_containers(containers.stale_backup_process_containers) 97 | 98 | logger.info("Contacting repository") 99 | if not restic.is_initialized(config.repository): 100 | logger.info("Repository is not initialized. Attempting to initialize it.") 101 | result = restic.init_repo(config.repository) 102 | if result == 0: 103 | logger.info("Successfully initialized repository: %s", config.repository) 104 | else: 105 | logger.error("Failed to initialize repository") 106 | 107 | logger.info("%s Detected Config %s", "-" * 25, "-" * 25) 108 | 109 | # Start making snapshots 110 | backup_containers = containers.containers_for_backup() 111 | for container in backup_containers: 112 | logger.info("service: %s", container.service_name) 113 | 114 | if container.volume_backup_enabled: 115 | logger.info(f" - stop during backup: {container.stop_during_backup}") 116 | for mount in container.filter_mounts(): 117 | logger.info( 118 | " - volume: %s -> %s", 119 | mount.source, 120 | container.get_volume_backup_destination(mount, "/volumes"), 121 | ) 122 | 123 | if container.database_backup_enabled: 124 | instance = container.instance 125 | ping = instance.ping() 126 | logger.info( 127 | " - %s (is_ready=%s) -> %s", 128 | instance.container_type, 129 | ping, 130 | instance.backup_destination_path(), 131 | ) 132 | if not ping: 133 | logger.error( 134 | "Database '%s' in service %s cannot be reached", 135 | instance.container_type, 136 | container.service_name, 137 | ) 138 | 139 | if len(backup_containers) == 0: 140 | logger.info("No containers in the project has 'stack-back.*' label") 141 | 142 | logger.info("-" * 67) 143 | 144 | 145 | def backup(config, containers: RunningContainers): 146 | """Request a backup to start""" 147 | # Make sure we don't spawn multiple backup processes 148 | if containers.backup_process_running: 149 | alerts.send( 150 | subject="Backup process container already running", 151 | body=( 152 | "A backup process container is already running. \n" 153 | f"Id: {containers.backup_process_container.id}\n" 154 | f"Name: {containers.backup_process_container.name}\n" 155 | ), 156 | alert_type="ERROR", 157 | ) 158 | raise RuntimeError("Backup process already running") 159 | 160 | # Map all volumes from the backup container into the backup process container 161 | volumes = containers.this_container.volumes 162 | 163 | # Map volumes from other containers we are backing up 164 | mounts = containers.generate_backup_mounts("/volumes") 165 | volumes.update(mounts) 166 | 167 | logger.debug( 168 | "Starting backup container with image %s", containers.this_container.image 169 | ) 170 | try: 171 | result = backup_runner.run( 172 | image=containers.this_container.image, 173 | command="rcb start-backup-process", 174 | volumes=volumes, 175 | environment=containers.this_container.environment, 176 | source_container_id=containers.this_container.id, 177 | labels={ 178 | containers.backup_process_label: "True", 179 | "com.docker.compose.project": containers.project_name, 180 | }, 181 | ) 182 | except Exception as ex: 183 | logger.exception(ex) 184 | alerts.send( 185 | subject="Exception during backup", 186 | body=str(ex), 187 | alert_type="ERROR", 188 | ) 189 | return 190 | 191 | logger.info("Backup container exit code: %s", result) 192 | 193 | # Alert the user if something went wrong 194 | if result != 0: 195 | alerts.send( 196 | subject="Backup process exited with non-zero code", 197 | body=open("backup.log").read(), 198 | alert_type="ERROR", 199 | ) 200 | 201 | 202 | def start_backup_process(config, containers): 203 | """The actual backup process running inside the spawned container""" 204 | if not utils.is_true(os.environ.get("BACKUP_PROCESS_CONTAINER")): 205 | logger.error( 206 | "Cannot run backup process in this container. Use backup command instead. " 207 | "This will spawn a new container with the necessary mounts." 208 | ) 209 | alerts.send( 210 | subject="Cannot run backup process in this container", 211 | body=( 212 | "Cannot run backup process in this container. Use backup command instead. " 213 | "This will spawn a new container with the necessary mounts." 214 | ), 215 | ) 216 | exit(1) 217 | 218 | status(config, containers) 219 | errors = False 220 | 221 | # Did we actually get any volumes mounted? 222 | try: 223 | has_volumes = os.stat("/volumes") is not None 224 | except FileNotFoundError: 225 | logger.warning("Found no volumes to back up") 226 | has_volumes = False 227 | 228 | # Warn if there is nothing to do 229 | if len(containers.containers_for_backup()) == 0 and not has_volumes: 230 | logger.error("No containers for backup found") 231 | exit(1) 232 | 233 | # stop containers labeled to stop during backup 234 | if len(containers.stop_during_backup_containers) > 0: 235 | utils.stop_containers(containers.stop_during_backup_containers) 236 | 237 | # back up volumes 238 | if has_volumes: 239 | try: 240 | logger.info("Backing up volumes") 241 | vol_result = restic.backup_files(config.repository, source="/volumes") 242 | logger.debug("Volume backup exit code: %s", vol_result) 243 | if vol_result != 0: 244 | logger.error("Volume backup exited with non-zero code: %s", vol_result) 245 | errors = True 246 | except Exception as ex: 247 | logger.error("Exception raised during volume backup") 248 | logger.exception(ex) 249 | errors = True 250 | 251 | # back up databases 252 | logger.info("Backing up databases") 253 | for container in containers.containers_for_backup(): 254 | if container.database_backup_enabled: 255 | try: 256 | instance = container.instance 257 | logger.debug( 258 | "Backing up %s in service %s from project %s", 259 | instance.container_type, 260 | instance.service_name, 261 | instance.project_name, 262 | ) 263 | result = instance.backup() 264 | logger.debug("Exit code: %s", result) 265 | if result != 0: 266 | logger.error("Backup command exited with non-zero code: %s", result) 267 | errors = True 268 | except Exception as ex: 269 | logger.exception(ex) 270 | errors = True 271 | 272 | # restart stopped containers after backup 273 | if len(containers.stop_during_backup_containers) > 0: 274 | utils.start_containers(containers.stop_during_backup_containers) 275 | 276 | if errors: 277 | logger.error("Exit code: %s", errors) 278 | exit(1) 279 | 280 | # Only run maintenance tasks if maintenance is not scheduled 281 | if not config.maintenance_schedule: 282 | maintenance(config, containers) 283 | 284 | logger.info("Backup completed") 285 | 286 | 287 | def maintenance(config, containers): 288 | """Run maintenance tasks""" 289 | logger.info("Running maintenance tasks") 290 | result = cleanup(config, containers) 291 | if result != 0: 292 | logger.error("Cleanup exit code: %s", result) 293 | exit(1) 294 | 295 | logger.info("Checking the repository for errors") 296 | check_with_cache = utils.is_true(config.check_with_cache) 297 | result = restic.check(config.repository, with_cache=check_with_cache) 298 | if result != 0: 299 | logger.error("Check exit code: %s", result) 300 | exit(1) 301 | 302 | 303 | def cleanup(config, containers): 304 | """Run forget / prune to minimize storage space""" 305 | logger.info("Forget outdated snapshots") 306 | forget_result = restic.forget( 307 | config.repository, 308 | config.keep_daily, 309 | config.keep_weekly, 310 | config.keep_monthly, 311 | config.keep_yearly, 312 | ) 313 | logger.info("Prune stale data freeing storage space") 314 | prune_result = restic.prune(config.repository) 315 | return forget_result and prune_result 316 | 317 | 318 | def snapshots(config, containers): 319 | """Display restic snapshots""" 320 | stdout, stderr = restic.snapshots(config.repository, last=True) 321 | for line in stdout.decode().split("\n"): 322 | print(line) 323 | 324 | 325 | def alert(config, containers): 326 | """Test alerts""" 327 | logger.info("Testing alerts") 328 | alerts.send( 329 | subject="{}: Test Alert".format(containers.project_name), 330 | body="Test message", 331 | ) 332 | 333 | 334 | def crontab(config): 335 | """Generate the crontab""" 336 | print(cron.generate_crontab(config)) 337 | 338 | 339 | def dump_env(): 340 | """Dump all environment variables to a file that can be sourced from cron""" 341 | print("# This file was generated by stack-back") 342 | for key, value in os.environ.items(): 343 | print("export {}='{}'".format(key, value)) 344 | 345 | 346 | def parse_args(): 347 | parser = argparse.ArgumentParser(prog="restic_compose_backup") 348 | parser.add_argument( 349 | "action", 350 | choices=[ 351 | "status", 352 | "snapshots", 353 | "backup", 354 | "start-backup-process", 355 | "maintenance", 356 | "alert", 357 | "cleanup", 358 | "version", 359 | "crontab", 360 | "dump-env", 361 | "test", 362 | ], 363 | ) 364 | parser.add_argument( 365 | "--log-level", 366 | default=None, 367 | choices=list(log.LOG_LEVELS.keys()), 368 | help="Log level", 369 | ) 370 | return parser.parse_args() 371 | 372 | 373 | if __name__ == "__main__": 374 | main() 375 | -------------------------------------------------------------------------------- /docs/guide/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | Environment Variables 5 | --------------------- 6 | 7 | RESTIC_REPOSITORY 8 | ~~~~~~~~~~~~~~~~~ 9 | 10 | Sets the restic repository path. 11 | 12 | This is a standard environment variable 13 | the ``restic`` command will read making it simple for 14 | us to enter the container and use the restic command directly. 15 | 16 | More about this value and supported backends: 17 | https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html 18 | 19 | RESTIC_PASSWORD 20 | ~~~~~~~~~~~~~~~ 21 | 22 | Sets the password is used to encrypt/decrypt data. 23 | Losing this password will make recovery impossible. 24 | 25 | This is a standard environment variable the ``restic`` 26 | command will read making it simple for us to enter the 27 | container running the command directly. 28 | 29 | RESTIC_KEEP_DAILY 30 | ~~~~~~~~~~~~~~~~~ 31 | 32 | **Default value**: ``7`` 33 | 34 | How many daily snapshots (grouped by path) back in time we 35 | want to keep. This is passed to restic in the 36 | ``forget --keep-daily`` option. 37 | 38 | RESTIC_KEEP_WEEKLY 39 | ~~~~~~~~~~~~~~~~~~ 40 | 41 | **Default value**: ``4`` 42 | 43 | How many weeks back we should keep at least one snapshot 44 | (grouped by path). This is passed to restic in the 45 | ``forget --keep-weekly`` option. 46 | 47 | RESTIC_KEEP_MONTHLY 48 | ~~~~~~~~~~~~~~~~~~~ 49 | 50 | **Default value**: ``12`` 51 | 52 | How many months back we should keep at least on snapshot 53 | (grouped by path). This is passed to restic in the 54 | ``forget --keep-monthly`` option. 55 | 56 | The schedule parameters only accepts numeric values 57 | and is validated when the container starts. Providing 58 | values cron does not understand will stall all backup. 59 | 60 | RESTIC_KEEP_YEARLY 61 | ~~~~~~~~~~~~~~~~~~ 62 | 63 | **Default value**: ``3`` 64 | 65 | How many years back we should keep at least one snapshot 66 | (grouped by path). This is passed to restic in the 67 | ``forget --keep-yearly`` option. 68 | 69 | CRON_SCHEDULE 70 | ~~~~~~~~~~~~~ 71 | 72 | **Default value**: ``0 2 * * *`` (daily at 02:00) 73 | 74 | The cron schedule parameters. The crontab is generated when the 75 | container starts from the ``CRON_SCHEDULE`` and ``CRON_COMMAND`` 76 | env variables. 77 | 78 | .. code:: 79 | 80 | ┌───────────── minute (0 - 59) 81 | │ ┌───────────── hour (0 - 23) 82 | │ │ ┌───────────── day of the month (1 - 31) 83 | │ │ │ ┌───────────── month (1 - 12) 84 | │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday) 85 | │ │ │ │ │ 86 | │ │ │ │ │ 87 | │ │ │ │ │ 88 | * * * * * command to execute 89 | 90 | CRON_COMMAND 91 | ~~~~~~~~~~~~ 92 | 93 | **Default value**: ``source /env.sh && rcb backup > /proc/1/fd/1`` 94 | 95 | The command executed in the crontab. A single line is generated when 96 | the container starts from the ``CRON_SCHEDULE`` and ``CRON_COMMAND`` 97 | environment variables. 98 | 99 | The default command sources a dump of all env vars, runs the 100 | backup command and directs output to pid 1 so it appears in 101 | docker logs. 102 | 103 | By default the crontab will look like this:: 104 | 105 | 0 2 * * * source /env.sh && rcb backup > /proc/1/fd/1 106 | 107 | MAINTENANCE_SCHEDULE 108 | ~~~~~~~~~~~~~~~~~~~~~ 109 | 110 | **Default value**: null` 111 | 112 | By default, maintenance (restic forget + prune + check) happens after 113 | every successful backup. This can be changed by setting this 114 | environment variable to a cron schedule. The format is the same as 115 | the ``CRON_SCHEDULE`` variable. 116 | 117 | This is useful if maintenance tasks are using a lot of read operations 118 | and you want to limit them to reduce cloud costs. The tradeoff is that 119 | the repository will be larger for a longer time as forget and prune 120 | will not be run after every backup. 121 | 122 | CHECK_WITH_CACHE 123 | ~~~~~~~~~~~~~~~~~~ 124 | 125 | **Default value**: ``false`` 126 | 127 | Whether restic should use the local cache when checking for integrity 128 | of the repository. This is useful for reducing remote read operations, 129 | which may be charged by your cloud provider. 130 | 131 | The tradeoff is that there is a small risk of not detecting corrupted 132 | data in the repository if the remote is corrupted but the local cache is not. 133 | 134 | LOG_LEVEL 135 | ~~~~~~~~~ 136 | 137 | **Default value**: ``info`` 138 | 139 | Log level for the ``rcb`` command. Valid values are 140 | ``debug``, ``info``, ``warning``, ``error``. 141 | 142 | EMAIL_HOST 143 | ~~~~~~~~~~ 144 | 145 | The email host to use. 146 | 147 | Alerts can be tested using the ``rcb alerts`` command. 148 | This will send a test message to all configured alert 149 | backends. 150 | 151 | EMAIL_PORT 152 | ~~~~~~~~~~ 153 | 154 | The port to connect to 155 | 156 | Alerts can be tested using the ``rcb alerts`` command. 157 | This will send a test message to all configured alert 158 | backends. 159 | 160 | EMAIL_HOST_USER 161 | ~~~~~~~~~~~~~~~ 162 | 163 | The user of the sender account 164 | 165 | Alerts can be tested using the ``rcb alerts`` command. 166 | This will send a test message to all configured alert 167 | backends. 168 | 169 | EMAIL_HOST_PASSWORD 170 | ~~~~~~~~~~~~~~~~~~~ 171 | 172 | The password for the sender account 173 | 174 | Alerts can be tested using the ``rcb alerts`` command. 175 | This will send a test message to all configured alert 176 | backends. 177 | 178 | EMAIL_SEND_TO 179 | ~~~~~~~~~~~~~ 180 | 181 | The email address to send alerts 182 | 183 | Alerts can be tested using the ``rcb alerts`` command. 184 | This will send a test message to all configured alert 185 | backends. 186 | 187 | DISCORD_WEBHOOK 188 | ~~~~~~~~~~~~~~~ 189 | 190 | The discord webhook url. And administrator can quickly set this up 191 | by going to server settings in the discord client and create 192 | a webhook that will post embedded messages to a specific channel. 193 | 194 | The url usually looks like this: ``https://discordapp.com/api/webhooks/...``` 195 | 196 | DOCKER_HOST 197 | ~~~~~~~~~~~ 198 | 199 | **Default value**: ``unix://tmp/docker.sock`` 200 | 201 | The socket or host of the docker service. 202 | 203 | DOCKER_TLS_VERIFY 204 | ~~~~~~~~~~~~~~~~~ 205 | 206 | If defined verify the host against a CA certificate. 207 | Path to certs is defined in ``DOCKER_CERT_PATH`` 208 | and can be copied or mapped into this backup container. 209 | 210 | DOCKER_CERT_PATH 211 | ~~~~~~~~~~~~~~~~ 212 | 213 | A path to a directory containing TLS certificates to use when 214 | connecting to the Docker host. Combined with ``DOCKER_TLS_VERIFY`` 215 | this can be used to talk to docker through TLS in cases 216 | were we cannot map in the docker socket. 217 | 218 | AUTO_BACKUP_ALL 219 | ~~~~~~~~~~~~~~~~~~~ 220 | 221 | If defined, all volumes and databases in the project will be 222 | included in the backup. This removes the need to add labels to 223 | each service when you want to back up everything. 224 | 225 | Database detection is based on the default images for mariadb, mysql 226 | and postgres. When a database is detected, the volume associated with 227 | the database data is automatically excluded from the backup. 228 | 229 | Volumes can be excluded by adding the ``stack-back.volumes.exclude: `` 230 | or ``stack-back.volumes: False`` label to the service. 231 | 232 | Databases can be excluded by adding the 233 | ``stack-back.: False`` label to the service along with 234 | ``stack-back.volumes: False``. Forgetting to also exclude the 235 | volumes may result in a backup of the database files volume instead 236 | of the database itself. 237 | 238 | INCLUDE_PROJECT_NAME 239 | ~~~~~~~~~~~~~~~~~~~~ 240 | 241 | Define this environment variable if your backup destination 242 | paths needs project name as a prefix. This is useful 243 | when running multiple projects. 244 | 245 | EXCLUDE_BIND_MOUNTS 246 | ~~~~~~~~~~~~~~~~~~~ 247 | 248 | Docker has to volumes types. Binds and volumes. 249 | Volumes are docker volumes (``docker`volume list``). 250 | Binds are paths mapped into the container from 251 | the host for example in the ``volumes`` section 252 | of a service. 253 | 254 | If defined all host binds will be ignored globally. 255 | This is useful when you only care about actual 256 | docker volumes. Often host binds are only used 257 | for mapping in configuration. This saves the user 258 | from manually excluding these bind volumes. 259 | 260 | INCLUDE_ALL_COMPOSE_PROJECTS 261 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 262 | 263 | If defined all compose projects found will be available for backup. 264 | By default only the compose project the backup container is 265 | running in is available for backup. 266 | 267 | This is useful when not wanting to run a separate backup container 268 | for each compose project. 269 | 270 | SWARM_MODE 271 | ~~~~~~~~~~ 272 | 273 | If defined containers in swarm stacks are also evaluated. 274 | 275 | Compose Labels 276 | -------------- 277 | 278 | What is backed up is controlled by simple labels in the compose 279 | yaml file. At any point we can verify this configuration 280 | by running the ``rcb status`` command. 281 | 282 | .. code: 283 | 284 | $ docker-compose run --rm backup rcb status 285 | INFO: Status for compose project 'myproject' 286 | INFO: Repository: '' 287 | INFO: Backup currently running?: False 288 | INFO: --------------- Detected Config --------------- 289 | INFO: service: mysql 290 | INFO: - mysql (is_ready=True) 291 | INFO: service: mariadb 292 | INFO: - mariadb (is_ready=True) 293 | INFO: service: postgres 294 | INFO: - postgres (is_ready=True) 295 | INFO: service: web 296 | INFO: - volume: media 297 | INFO: - volume: /srv/files 298 | 299 | Here we can see what volumes and databases are detected for backup. 300 | 301 | Volumes 302 | ~~~~~~~ 303 | 304 | To enable volume backup for a service we simply add the 305 | `stack-back.volumes: true` label. The value 306 | must be ``true``. 307 | 308 | Example: 309 | 310 | .. code:: yaml 311 | 312 | myservice: 313 | image: some_image 314 | labels: 315 | stack-back.volumes: true 316 | volumes: 317 | - uploaded_media:/srv/media 318 | - uploaded_files:/srv/files 319 | - /srv/data:/srv/data 320 | 321 | volumes: 322 | media: 323 | files: 324 | 325 | This will back up the three volumes mounted to this service. 326 | Their path in restic will be: 327 | 328 | - /volumes/myservice/srv/media 329 | - /volumes/myservice/srv/files 330 | - /volumes/myservice/srv/data 331 | 332 | In situations where the files in the volume are at risk of being 333 | corrupted during the backup process (such as SQLite databases), 334 | the `stack-back.volumes.stop-during-backup` label can be added to 335 | the service. This will stop the service during the backup process 336 | and start it again when the backup is done. 337 | 338 | Example: 339 | 340 | .. code:: yaml 341 | 342 | myservice: 343 | image: some_image 344 | labels: 345 | stack-back.volumes: true 346 | stack-back.volumes.stop-during-backup: true 347 | volumes: 348 | - uploaded_media:/srv/media 349 | - uploaded_files:/srv/files 350 | - /srv/data:/srv/data 351 | 352 | volumes: 353 | media: 354 | files: 355 | 356 | A simple `include` and `exclude` filter for what volumes 357 | should be backed up is also available. Note that this 358 | includes or excludes entire volumes and are not include/exclude 359 | patterns for files in the volumes. 360 | 361 | .. note:: The ``exclude`` and ``include`` filtering is applied on 362 | the source path, not the destination. 363 | 364 | Include example including two volumes only: 365 | 366 | .. code:: yaml 367 | 368 | myservice: 369 | image: some_image 370 | labels: 371 | stack-back.volumes: true 372 | stack-back.volumes.include: "uploaded_media,uploaded_files" 373 | volumes: 374 | - uploaded_media:/srv/media 375 | - uploaded_files:/srv/files 376 | - /srv/data:/srv/data 377 | 378 | volumes: 379 | media: 380 | files: 381 | 382 | Exclude example achieving the same result as the example above. 383 | 384 | .. code:: yaml 385 | 386 | example: 387 | image: some_image 388 | labels: 389 | stack-back.volumes: true 390 | stack-back.volumes.exclude: "data" 391 | volumes: 392 | # Excluded by filter 393 | - media:/srv/media 394 | # Backed up 395 | - files:/srv/files 396 | - /srv/data:/srv/data 397 | 398 | volumes: 399 | media: 400 | files: 401 | 402 | The ``exclude`` and ``include`` tag can be used together 403 | in more complex situations. 404 | 405 | mariadb 406 | ~~~~~~~ 407 | 408 | To enable backup of mariadb simply add the 409 | ``stack-back.mariadb: true`` label. 410 | 411 | Credentials are fetched from the following environment 412 | variables in the mariadb service. This is the standard 413 | when using the official mariadb_ image. 414 | 415 | .. code:: 416 | 417 | MARIADB_USER 418 | MARIADB_PASSWORD 419 | 420 | Backups are done by dumping all databases directly into 421 | restic through stdin using ``mysqldump``. It will appear 422 | in restic as a separate snapshot with path 423 | ``/databases//all_databases.sql``. 424 | 425 | .. warning: This will only back up the databases the 426 | ``MARIADB_USER` has access to. If you have multiple 427 | databases this must be taken into consideration. 428 | 429 | Example: 430 | 431 | .. code:: yaml 432 | 433 | mariadb: 434 | image: mariadb:10 435 | labels: 436 | stack-back.mariadb: true 437 | env_file: 438 | mariadb-credentials.env 439 | volumes: 440 | - mariadb:/var/lib/mysql 441 | 442 | volumes: 443 | mariadb: 444 | 445 | mysql 446 | ~~~~~ 447 | 448 | To enable backup of mysql simply add the 449 | ``stack-back.mysql: true`` label. 450 | 451 | Credentials are fetched from the following environment 452 | variables in the mysql service. This is the standard 453 | when using the official mysql_ image. 454 | 455 | .. code:: 456 | 457 | MYSQL_USER 458 | MYSQL_PASSWORD 459 | 460 | Backups are done by dumping all databases directly into 461 | restic through stdin using ``mysqldump``. It will appear 462 | in restic as a separate snapshot with path 463 | ``/databases//all_databases.sql``. 464 | 465 | .. warning: This will only back up the databases the 466 | ``MYSQL_USER` has access to. If you have multiple 467 | databases this must be taken into consideration. 468 | 469 | Example: 470 | 471 | .. code:: yaml 472 | 473 | mysql: 474 | image: mysql:5 475 | labels: 476 | stack-back.mysql: true 477 | env_file: 478 | mysql-credentials.env 479 | volumes: 480 | - mysql:/var/lib/mysql 481 | 482 | volumes: 483 | mysql: 484 | 485 | postgres 486 | ~~~~~~~~ 487 | 488 | To enable backup of mysql simply add the 489 | ``stack-back.postgres: true`` label. 490 | 491 | Credentials are fetched from the following environment 492 | variables in the postgres service. This is the standard 493 | when using the official postgres_ image. 494 | 495 | .. code:: 496 | 497 | POSTGRES_USER 498 | POSTGRES_PASSWORD 499 | POSTGRES_DB 500 | 501 | Backups are done by dumping the ``POSTGRES_DB`` directly into 502 | restic through stdin using ``pg_dump``. It will appear 503 | in restic as a separate snapshot with path 504 | ``/databases//.sql``. 505 | 506 | .. warning:: Currently only the ``POSTGRES_DB`` database 507 | is dumped. 508 | 509 | Example: 510 | 511 | .. code:: yaml 512 | 513 | postgres: 514 | image: postgres:11 515 | labels: 516 | # Enables backup of this database 517 | stack-back.postgres: true 518 | env_file: 519 | postgres-credentials.env 520 | volumes: 521 | - pgdata:/var/lib/postgresql/data 522 | 523 | volumes: 524 | pgdata: 525 | 526 | .. _mariadb: https://hub.docker.com/_/mariadb 527 | .. _mysql: https://hub.docker.com/_/mysql 528 | .. _postgres: https://hub.docker.com/_/postgres 529 | -------------------------------------------------------------------------------- /src/restic_compose_backup/containers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | import socket 4 | from typing import List 5 | 6 | from restic_compose_backup import enums, utils 7 | from restic_compose_backup.config import config 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | VOLUME_TYPE_BIND = "bind" 12 | VOLUME_TYPE_VOLUME = "volume" 13 | 14 | 15 | class Container: 16 | """Represents a docker container""" 17 | 18 | container_type = None 19 | 20 | def __init__(self, data: dict): 21 | self._data = data 22 | self._state = data.get("State") 23 | self._config = data.get("Config") 24 | self._mounts = [Mount(mnt, container=self) for mnt in data.get("Mounts")] 25 | 26 | if not self._state: 27 | raise ValueError("Container meta missing State") 28 | if self._config is None: 29 | raise ValueError("Container meta missing Config") 30 | 31 | self._labels = self._config.get("Labels") 32 | if self._labels is None: 33 | raise ValueError("Container meta missing Config->Labels") 34 | 35 | self._include = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_INCLUDE)) 36 | self._exclude = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_EXCLUDE)) 37 | 38 | @property 39 | def instance(self) -> "Container": 40 | """Container: Get a service specific subclass instance""" 41 | # TODO: Do this smarter in the future (simple registry) 42 | if self.database_backup_enabled: 43 | from restic_compose_backup import containers_db 44 | 45 | if self.mariadb_backup_enabled: 46 | return containers_db.MariadbContainer(self._data) 47 | if self.mysql_backup_enabled: 48 | return containers_db.MysqlContainer(self._data) 49 | if self.postgresql_backup_enabled: 50 | return containers_db.PostgresContainer(self._data) 51 | else: 52 | return self 53 | 54 | @property 55 | def id(self) -> str: 56 | """str: The id of the container""" 57 | return self._data.get("Id") 58 | 59 | @property 60 | def image(self) -> str: 61 | """Image name""" 62 | return self.get_config("Image") 63 | 64 | @property 65 | def name(self) -> str: 66 | """Container name""" 67 | return self._data["Name"].replace("/", "") 68 | 69 | @property 70 | def service_name(self) -> str: 71 | """Name of the container/service""" 72 | return self.get_label( 73 | "com.docker.compose.service", default="" 74 | ) or self.get_label("com.docker.swarm.service.name", default="") 75 | 76 | @property 77 | def backup_process_label(self) -> str: 78 | """str: The unique backup process label for this project""" 79 | return f"{enums.LABEL_BACKUP_PROCESS}-{self.project_name}" 80 | 81 | @property 82 | def project_name(self) -> str: 83 | """str: Name of the compose setup""" 84 | return self.get_label("com.docker.compose.project", default="") 85 | 86 | @property 87 | def stack_name(self) -> str: 88 | """str: Name of the stack is present""" 89 | return self.get_label("com.docker.stack.namespace") 90 | 91 | @property 92 | def is_oneoff(self) -> bool: 93 | """Was this container started with run command?""" 94 | return self.get_label("com.docker.compose.oneoff", default="False") == "True" 95 | 96 | @property 97 | def environment(self) -> list: 98 | """All configured env vars for the container as a list""" 99 | return self.get_config("Env") 100 | 101 | def remove(self): 102 | self._data.remove() 103 | 104 | def get_config_env(self, name) -> str: 105 | """Get a config environment variable by name""" 106 | # convert to dict and fetch env var by name 107 | data = {i[0 : i.find("=")]: i[i.find("=") + 1 :] for i in self.environment} 108 | return data.get(name) 109 | 110 | def set_config_env(self, name, value): 111 | """Set an environment variable""" 112 | env = self.environment 113 | new_value = f"{name}={value}" 114 | for i, entry in enumerate(env): 115 | if f"{name}=" in entry: 116 | env[i] = new_value 117 | break 118 | else: 119 | env.append(new_value) 120 | 121 | @property 122 | def volumes(self) -> dict: 123 | """ 124 | Return volumes for the container in the following format: 125 | {'/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'},} 126 | """ 127 | volumes = {} 128 | for mount in self._mounts: 129 | volumes[mount.source] = { 130 | "bind": mount.destination, 131 | "mode": "rw", 132 | } 133 | 134 | return volumes 135 | 136 | @property 137 | def backup_enabled(self) -> bool: 138 | """Is backup enabled for this container?""" 139 | return any( 140 | [ 141 | self.volume_backup_enabled, 142 | self.database_backup_enabled, 143 | ] 144 | ) 145 | 146 | @property 147 | def volume_backup_enabled(self) -> bool: 148 | """bool: If the ``stack-back.volumes`` label is set""" 149 | explicitly_enabled = utils.is_true(self.get_label(enums.LABEL_VOLUMES_ENABLED)) 150 | explicitly_disabled = utils.is_false( 151 | self.get_label(enums.LABEL_VOLUMES_ENABLED) 152 | ) 153 | automatically_enabled = utils.is_true(config.auto_backup_all) 154 | return explicitly_enabled or (automatically_enabled and not explicitly_disabled) 155 | 156 | @property 157 | def database_backup_enabled(self) -> bool: 158 | """bool: Is database backup enabled in any shape or form?""" 159 | return any( 160 | [ 161 | self.mysql_backup_enabled, 162 | self.mariadb_backup_enabled, 163 | self.postgresql_backup_enabled, 164 | ] 165 | ) 166 | 167 | @property 168 | def mysql_backup_enabled(self) -> bool: 169 | """bool: If the ``stack-back.mysql`` label is set""" 170 | explicity_enabled = utils.is_true(self.get_label(enums.LABEL_MYSQL_ENABLED)) 171 | explicity_disabled = utils.is_false(self.get_label(enums.LABEL_MYSQL_ENABLED)) 172 | automatically_enabled = utils.is_true( 173 | config.auto_backup_all 174 | ) and self.image.startswith("mysql") 175 | return explicity_enabled or (automatically_enabled and not explicity_disabled) 176 | 177 | @property 178 | def mariadb_backup_enabled(self) -> bool: 179 | """bool: If the ``stack-back.mariadb`` label is set""" 180 | explicity_enabled = utils.is_true(self.get_label(enums.LABEL_MARIADB_ENABLED)) 181 | explicity_disabled = utils.is_false(self.get_label(enums.LABEL_MARIADB_ENABLED)) 182 | automatically_enabled = utils.is_true( 183 | config.auto_backup_all 184 | ) and self.image.startswith("mariadb") 185 | return explicity_enabled or (automatically_enabled and not explicity_disabled) 186 | 187 | @property 188 | def postgresql_backup_enabled(self) -> bool: 189 | """bool: If the ``stack-back.postgres`` label is set""" 190 | explicity_enabled = utils.is_true(self.get_label(enums.LABEL_POSTGRES_ENABLED)) 191 | explicity_disabled = utils.is_false( 192 | self.get_label(enums.LABEL_POSTGRES_ENABLED) 193 | ) 194 | automatically_enabled = utils.is_true( 195 | config.auto_backup_all 196 | ) and self.image.startswith("postgres") 197 | return explicity_enabled or (automatically_enabled and not explicity_disabled) 198 | 199 | @property 200 | def stop_during_backup(self) -> bool: 201 | """bool: If the ``stack-back.volumes.stop-during-backup`` label is set""" 202 | return ( 203 | utils.is_true(self.get_label(enums.LABEL_STOP_DURING_BACKUP)) 204 | and not self.database_backup_enabled 205 | ) 206 | 207 | @property 208 | def is_backup_process_container(self) -> bool: 209 | """Is this container the running backup process?""" 210 | return self.get_label(self.backup_process_label) == "True" 211 | 212 | @property 213 | def is_running(self) -> bool: 214 | """bool: Is the container running?""" 215 | return self._state.get("Running", False) 216 | 217 | def get_config(self, name, default=None): 218 | """Get value from config dict""" 219 | return self._config.get(name, default) 220 | 221 | def get_label(self, name, default=None): 222 | """Get a label by name""" 223 | return self._labels.get(name, None) 224 | 225 | def filter_mounts(self): 226 | """Get all mounts for this container matching include/exclude filters""" 227 | filtered = [] 228 | database_mounts = [ 229 | "/var/lib/mysql", 230 | "/var/lib/mariadb", 231 | "/var/lib/postgresql/data", 232 | ] 233 | 234 | # If exclude_bind_mounts is true, only volume mounts are kept in the list of mounts 235 | exclude_bind_mounts = utils.is_true(config.exclude_bind_mounts) 236 | mounts = list( 237 | filter( 238 | lambda m: not exclude_bind_mounts or m.type == "volume", self._mounts 239 | ) 240 | ) 241 | 242 | if not self.volume_backup_enabled: 243 | return filtered 244 | 245 | if self._include: 246 | for mount in mounts: 247 | for pattern in self._include: 248 | if pattern in mount.source: 249 | break 250 | else: 251 | continue 252 | 253 | filtered.append(mount) 254 | 255 | elif self._exclude: 256 | for mount in mounts: 257 | for pattern in self._exclude: 258 | if pattern in mount.source: 259 | break 260 | else: 261 | filtered.append(mount) 262 | else: 263 | for mount in mounts: 264 | if ( 265 | self.database_backup_enabled 266 | and mount.destination in database_mounts 267 | ): 268 | continue 269 | filtered.append(mount) 270 | 271 | return filtered 272 | 273 | def volumes_for_backup(self, source_prefix="/volumes", mode="ro"): 274 | """Get volumes configured for backup""" 275 | mounts = self.filter_mounts() 276 | volumes = {} 277 | for mount in mounts: 278 | volumes[mount.source] = { 279 | "bind": self.get_volume_backup_destination(mount, source_prefix), 280 | "mode": mode, 281 | } 282 | 283 | return volumes 284 | 285 | def get_volume_backup_destination(self, mount, source_prefix) -> str: 286 | """Get the destination path for backups of the given mount""" 287 | destination = Path(source_prefix) 288 | 289 | if utils.is_true(config.include_project_name): 290 | project_name = self.project_name 291 | if project_name != "": 292 | destination /= project_name 293 | 294 | destination /= self.service_name 295 | destination /= Path(utils.strip_root(mount.destination)) 296 | 297 | return str(destination) 298 | 299 | def get_credentials(self) -> dict: 300 | """dict: get credentials for the service""" 301 | raise NotImplementedError("Base container class don't implement this") 302 | 303 | def ping(self) -> bool: 304 | """Check the availability of the service""" 305 | raise NotImplementedError("Base container class don't implement this") 306 | 307 | def backup(self): 308 | """Back up this service""" 309 | raise NotImplementedError("Base container class don't implement this") 310 | 311 | def backup_destination_path(self) -> str: 312 | """Return the path backups will be saved at""" 313 | raise NotImplementedError("Base container class don't implement this") 314 | 315 | def dump_command(self) -> list: 316 | """list: create a dump command restic and use to send data through stdin""" 317 | raise NotImplementedError("Base container class don't implement this") 318 | 319 | def _parse_pattern(self, value: str) -> List[str]: 320 | """list: Safely parse include/exclude pattern from user""" 321 | if not value: 322 | return None 323 | 324 | if type(value) is not str: 325 | return None 326 | 327 | value = value.strip() 328 | if len(value) == 0: 329 | return None 330 | 331 | return value.split(",") 332 | 333 | def __eq__(self, other): 334 | """Compare container by id""" 335 | if other is None: 336 | return False 337 | 338 | if not isinstance(other, Container): 339 | return False 340 | 341 | return self.id == other.id 342 | 343 | def __repr__(self): 344 | return str(self) 345 | 346 | def __str__(self): 347 | return "".format(self.name) 348 | 349 | 350 | class Mount: 351 | """Represents a volume mount (volume or bind)""" 352 | 353 | def __init__(self, data, container=None): 354 | self._data = data 355 | self._container = container 356 | 357 | @property 358 | def container(self) -> Container: 359 | """The container this mount belongs to""" 360 | return self._container 361 | 362 | @property 363 | def type(self) -> str: 364 | """bind/volume""" 365 | return self._data.get("Type") 366 | 367 | @property 368 | def name(self) -> str: 369 | """Name of the mount""" 370 | return self._data.get("Name") 371 | 372 | @property 373 | def source(self) -> str: 374 | """Source of the mount. Volume name or path""" 375 | return self._data.get("Source") 376 | 377 | @property 378 | def destination(self) -> str: 379 | """Destination path for the volume mount in the container""" 380 | return self._data.get("Destination") 381 | 382 | def __repr__(self) -> str: 383 | return str(self) 384 | 385 | def __str__(self) -> str: 386 | return str(self._data) 387 | 388 | def __hash__(self): 389 | """Uniqueness for a volume""" 390 | if self.type == VOLUME_TYPE_VOLUME: 391 | return hash(self.name) 392 | elif self.type == VOLUME_TYPE_BIND: 393 | return hash(self.source) 394 | else: 395 | raise ValueError("Unknown volume type: {}".format(self.type)) 396 | 397 | 398 | class RunningContainers: 399 | def __init__(self): 400 | all_containers = utils.list_containers() 401 | self.containers = [] 402 | self.this_container = None 403 | self.backup_process_container = None 404 | self.stale_backup_process_containers = [] 405 | self.stop_during_backup_containers = [] 406 | 407 | # Find the container we are running in. 408 | # If we don't have this information we cannot continue 409 | for container_data in all_containers: 410 | if container_data.get("Id").startswith(socket.gethostname()): 411 | self.this_container = Container(container_data) 412 | 413 | if not self.this_container: 414 | raise ValueError("Cannot find metadata for backup container") 415 | 416 | # Gather relevant containers 417 | for container_data in all_containers: 418 | container = Container(container_data) 419 | 420 | # Gather stale backup process containers 421 | if ( 422 | self.this_container.image == container.image 423 | and not container.is_running 424 | and container.is_backup_process_container 425 | ): 426 | self.stale_backup_process_containers.append(container) 427 | 428 | # We only care about running containers after this point 429 | if not container.is_running: 430 | continue 431 | 432 | # If not swarm mode we need to filter in compose project 433 | if ( 434 | not config.swarm_mode 435 | and not config.include_all_compose_projects 436 | and container.project_name != self.this_container.project_name 437 | ): 438 | continue 439 | 440 | # Gather stop during backup containers 441 | if container.stop_during_backup: 442 | self.stop_during_backup_containers.append(container) 443 | 444 | # Detect running backup process container 445 | if container.is_backup_process_container: 446 | self.backup_process_container = container 447 | 448 | # Containers started manually are not included 449 | if container.is_oneoff: 450 | continue 451 | 452 | # Do not include the stack-back and backup process containers 453 | if "stack-back" in container.image: 454 | continue 455 | 456 | self.containers.append(container) 457 | 458 | @property 459 | def project_name(self) -> str: 460 | """str: Name of the compose project""" 461 | return self.this_container.project_name 462 | 463 | @property 464 | def backup_process_label(self) -> str: 465 | """str: The backup process label for this project""" 466 | return self.this_container.backup_process_label 467 | 468 | @property 469 | def backup_process_running(self) -> bool: 470 | """Is the backup process container running?""" 471 | return self.backup_process_container is not None 472 | 473 | def containers_for_backup(self) -> list[Container]: 474 | """Obtain all containers with backup enabled""" 475 | return [container for container in self.containers if container.backup_enabled] 476 | 477 | def generate_backup_mounts(self, dest_prefix="/volumes") -> dict: 478 | """Generate mounts for backup for the entire compose setup""" 479 | mounts = {} 480 | for container in self.containers_for_backup(): 481 | if container.volume_backup_enabled: 482 | mounts.update( 483 | container.volumes_for_backup(source_prefix=dest_prefix, mode="ro") 484 | ) 485 | 486 | return mounts 487 | 488 | def get_service(self, name) -> Container: 489 | """Container: Get a service by name""" 490 | for container in self.containers: 491 | if container.service_name == name: 492 | return container 493 | 494 | return None 495 | -------------------------------------------------------------------------------- /src/tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest fixtures for integration tests""" 2 | 3 | import subprocess 4 | import time 5 | import pytest 6 | import docker 7 | from pathlib import Path 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def docker_client(): 12 | """Provide a Docker client for tests""" 13 | return docker.from_env() 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def project_root(): 18 | """Return the project root directory""" 19 | return Path(__file__).parent.parent.parent.parent 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def compose_project_name(): 24 | """Return a unique name for the test compose project""" 25 | return "stack_back_integration_test" 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def docker_compose_file(project_root): 30 | """Return the path to the test docker-compose file""" 31 | return project_root / "docker-compose.test.yaml" 32 | 33 | 34 | @pytest.fixture(scope="session") 35 | def compose_up(docker_compose_file, compose_project_name, project_root): 36 | """Start docker-compose services before tests and tear down after""" 37 | # Clean up any existing test data 38 | test_data_dir = project_root / "test_data" 39 | test_restic_data = project_root / "test_restic_data" 40 | test_restic_cache = project_root / "test_restic_cache" 41 | 42 | def cleanup_directories(): 43 | """Helper to clean up test directories""" 44 | for directory in [test_data_dir, test_restic_data, test_restic_cache]: 45 | if directory.exists(): 46 | # Use docker to remove (files owned by docker user) 47 | subprocess.run( 48 | [ 49 | "docker", 50 | "run", 51 | "--rm", 52 | "-v", 53 | f"{directory}:/data", 54 | "alpine:latest", 55 | "rm", 56 | "-rf", 57 | "/data", 58 | ], 59 | check=False, 60 | ) 61 | # Remove empty directory if still exists 62 | subprocess.run( 63 | ["rm", "-rf", str(directory)], 64 | check=False, 65 | cwd=str(project_root), 66 | capture_output=True, 67 | ) 68 | 69 | def cleanup_containers(): 70 | """Helper to clean up docker containers""" 71 | subprocess.run( 72 | [ 73 | "docker", 74 | "compose", 75 | "-f", 76 | str(docker_compose_file), 77 | "-p", 78 | compose_project_name, 79 | "down", 80 | "-v", 81 | ], 82 | check=False, 83 | cwd=str(project_root), 84 | ) 85 | 86 | # Always clean up before starting (handles previous failed runs) 87 | cleanup_containers() 88 | cleanup_directories() 89 | 90 | # Create fresh directories 91 | for directory in [test_data_dir, test_restic_data, test_restic_cache]: 92 | directory.mkdir(parents=True, exist_ok=True) 93 | 94 | # Create test data directory structure 95 | web_data_dir = test_data_dir / "web" 96 | web_data_dir.mkdir(parents=True, exist_ok=True) 97 | 98 | try: 99 | # Start docker compose 100 | subprocess.run( 101 | [ 102 | "docker", 103 | "compose", 104 | "-f", 105 | str(docker_compose_file), 106 | "-p", 107 | compose_project_name, 108 | "up", 109 | "-d", 110 | "--build", 111 | ], 112 | check=True, 113 | cwd=str(project_root), 114 | ) 115 | 116 | # Wait for services to be healthy 117 | max_wait = 60 118 | start_time = time.time() 119 | while time.time() - start_time < max_wait: 120 | result = subprocess.run( 121 | [ 122 | "docker", 123 | "compose", 124 | "-f", 125 | str(docker_compose_file), 126 | "-p", 127 | compose_project_name, 128 | "ps", 129 | "--format", 130 | "json", 131 | ], 132 | capture_output=True, 133 | text=True, 134 | cwd=str(project_root), 135 | ) 136 | 137 | if result.returncode == 0: 138 | # Check if all services are healthy or running 139 | time.sleep(5) 140 | break 141 | time.sleep(2) 142 | 143 | yield 144 | 145 | finally: 146 | # Always tear down, even if tests fail or are interrupted 147 | cleanup_containers() 148 | cleanup_directories() 149 | 150 | 151 | @pytest.fixture 152 | def backup_container(docker_client, compose_project_name, compose_up): 153 | """Return the backup container""" 154 | containers = docker_client.containers.list( 155 | filters={"label": f"com.docker.compose.project={compose_project_name}"} 156 | ) 157 | for container in containers: 158 | service_name = container.labels.get("com.docker.compose.service") 159 | if service_name == "backup": 160 | return container 161 | raise RuntimeError("Backup container not found") 162 | 163 | 164 | @pytest.fixture 165 | def run_backup(backup_container): 166 | """Fixture to execute backup command in the backup container""" 167 | 168 | def _run_backup(): 169 | exit_code, output = backup_container.exec_run("rcb backup") 170 | return exit_code, output.decode() 171 | 172 | return _run_backup 173 | 174 | 175 | @pytest.fixture 176 | def run_rcb_command(backup_container): 177 | """Fixture to execute arbitrary rcb commands in the backup container""" 178 | 179 | def _run_command(command: str): 180 | full_command = f"rcb {command}" 181 | exit_code, output = backup_container.exec_run(full_command) 182 | return exit_code, output.decode() 183 | 184 | return _run_command 185 | 186 | 187 | @pytest.fixture(autouse=True) 188 | def cleanup_restic_snapshots(backup_container): 189 | """Automatically clean up restic snapshots before each test to ensure isolation""" 190 | # Clean up snapshots before the test runs 191 | # This ensures each test starts with a clean slate 192 | exit_code, output = backup_container.exec_run( 193 | "sh -c 'restic snapshots --json 2>/dev/null || true'" 194 | ) 195 | 196 | # If there are any snapshots, remove them all 197 | if exit_code == 0 and output.decode().strip(): 198 | # Use restic forget with --prune to remove all snapshots 199 | backup_container.exec_run( 200 | "sh -c 'restic snapshots --quiet | tail -n +2 | head -n -2 | awk \"{print \\$1}\" | xargs -r -I {} restic forget {} --prune 2>/dev/null || true'" 201 | ) 202 | 203 | yield 204 | 205 | # Comment out if you want to inspect snapshots after failed tests 206 | 207 | 208 | @pytest.fixture 209 | def create_test_data(project_root): 210 | """Helper to create test data files""" 211 | 212 | def _create_data(relative_path: str, content: str): 213 | file_path = project_root / relative_path 214 | file_path.parent.mkdir(parents=True, exist_ok=True) 215 | file_path.write_text(content) 216 | return file_path 217 | 218 | return _create_data 219 | 220 | 221 | @pytest.fixture 222 | def mysql_container(docker_client, compose_project_name, compose_up): 223 | """Return the MySQL container""" 224 | containers = docker_client.containers.list( 225 | filters={"label": f"com.docker.compose.project={compose_project_name}"} 226 | ) 227 | for container in containers: 228 | service_name = container.labels.get("com.docker.compose.service") 229 | if service_name == "mysql": 230 | return container 231 | raise RuntimeError("MySQL container not found") 232 | 233 | 234 | @pytest.fixture 235 | def mariadb_container(docker_client, compose_project_name, compose_up): 236 | """Return the MariaDB container""" 237 | containers = docker_client.containers.list( 238 | filters={"label": f"com.docker.compose.project={compose_project_name}"} 239 | ) 240 | for container in containers: 241 | service_name = container.labels.get("com.docker.compose.service") 242 | if service_name == "mariadb": 243 | return container 244 | raise RuntimeError("MariaDB container not found") 245 | 246 | 247 | @pytest.fixture 248 | def postgres_container(docker_client, compose_project_name, compose_up): 249 | """Return the PostgreSQL container""" 250 | containers = docker_client.containers.list( 251 | filters={"label": f"com.docker.compose.project={compose_project_name}"} 252 | ) 253 | for container in containers: 254 | service_name = container.labels.get("com.docker.compose.service") 255 | if service_name == "postgres": 256 | return container 257 | raise RuntimeError("PostgreSQL container not found") 258 | 259 | 260 | @pytest.fixture 261 | def web_container(docker_client, compose_project_name, compose_up): 262 | """Return the web container""" 263 | containers = docker_client.containers.list( 264 | filters={"label": f"com.docker.compose.project={compose_project_name}"} 265 | ) 266 | for container in containers: 267 | service_name = container.labels.get("com.docker.compose.service") 268 | if service_name == "web": 269 | return container 270 | raise RuntimeError("Web container not found") 271 | 272 | 273 | @pytest.fixture(scope="session") 274 | def secondary_compose_project_name(): 275 | """Return a unique name for the secondary test compose project""" 276 | return "stack_back_secondary_test" 277 | 278 | 279 | @pytest.fixture(scope="session") 280 | def secondary_docker_compose_file(project_root): 281 | """Return the path to the secondary test docker-compose file""" 282 | return project_root / "docker-compose.test2.yaml" 283 | 284 | 285 | @pytest.fixture(scope="session") 286 | def secondary_compose_up( 287 | secondary_docker_compose_file, secondary_compose_project_name, project_root 288 | ): 289 | """Start secondary docker-compose services before tests and tear down after""" 290 | # Clean up any existing test data 291 | test_data_dir = project_root / "test_data" / "secondary_web" 292 | 293 | def cleanup_directories(): 294 | """Helper to clean up test directories""" 295 | if test_data_dir.exists(): 296 | # Use docker to remove (files owned by docker user) 297 | subprocess.run( 298 | [ 299 | "docker", 300 | "run", 301 | "--rm", 302 | "-v", 303 | f"{test_data_dir}:/data", 304 | "alpine:latest", 305 | "rm", 306 | "-rf", 307 | "/data", 308 | ], 309 | check=False, 310 | ) 311 | # Remove empty directory if still exists 312 | subprocess.run( 313 | ["rm", "-rf", str(test_data_dir)], 314 | check=False, 315 | cwd=str(project_root), 316 | capture_output=True, 317 | ) 318 | 319 | def cleanup_containers(): 320 | """Helper to clean up docker containers""" 321 | subprocess.run( 322 | [ 323 | "docker", 324 | "compose", 325 | "-f", 326 | str(secondary_docker_compose_file), 327 | "-p", 328 | secondary_compose_project_name, 329 | "down", 330 | "-v", 331 | ], 332 | check=False, 333 | cwd=str(project_root), 334 | ) 335 | 336 | # Always clean up before starting (handles previous failed runs) 337 | cleanup_containers() 338 | cleanup_directories() 339 | 340 | # Create fresh directories 341 | test_data_dir.mkdir(parents=True, exist_ok=True) 342 | 343 | try: 344 | # Start docker compose 345 | subprocess.run( 346 | [ 347 | "docker", 348 | "compose", 349 | "-f", 350 | str(secondary_docker_compose_file), 351 | "-p", 352 | secondary_compose_project_name, 353 | "up", 354 | "-d", 355 | "--build", 356 | ], 357 | check=True, 358 | cwd=str(project_root), 359 | ) 360 | 361 | # Wait for services to be healthy 362 | max_wait = 60 363 | start_time = time.time() 364 | while time.time() - start_time < max_wait: 365 | result = subprocess.run( 366 | [ 367 | "docker", 368 | "compose", 369 | "-f", 370 | str(secondary_docker_compose_file), 371 | "-p", 372 | secondary_compose_project_name, 373 | "ps", 374 | "--format", 375 | "json", 376 | ], 377 | capture_output=True, 378 | text=True, 379 | cwd=str(project_root), 380 | ) 381 | 382 | if result.returncode == 0: 383 | # Check if all services are healthy or running 384 | time.sleep(5) 385 | break 386 | time.sleep(2) 387 | 388 | yield 389 | 390 | finally: 391 | # Always tear down, even if tests fail or are interrupted 392 | cleanup_containers() 393 | cleanup_directories() 394 | 395 | 396 | @pytest.fixture 397 | def secondary_web_container( 398 | docker_client, secondary_compose_project_name, secondary_compose_up 399 | ): 400 | """Return the secondary web container""" 401 | containers = docker_client.containers.list( 402 | filters={ 403 | "label": f"com.docker.compose.project={secondary_compose_project_name}" 404 | } 405 | ) 406 | for container in containers: 407 | service_name = container.labels.get("com.docker.compose.service") 408 | if service_name == "secondary_web": 409 | return container 410 | raise RuntimeError("Secondary web container not found") 411 | 412 | 413 | @pytest.fixture 414 | def secondary_mysql_container( 415 | docker_client, secondary_compose_project_name, secondary_compose_up 416 | ): 417 | """Return the secondary MySQL container""" 418 | containers = docker_client.containers.list( 419 | filters={ 420 | "label": f"com.docker.compose.project={secondary_compose_project_name}" 421 | } 422 | ) 423 | for container in containers: 424 | service_name = container.labels.get("com.docker.compose.service") 425 | if service_name == "secondary_mysql": 426 | return container 427 | raise RuntimeError("Secondary MySQL container not found") 428 | 429 | 430 | @pytest.fixture 431 | def secondary_postgres_container( 432 | docker_client, secondary_compose_project_name, secondary_compose_up 433 | ): 434 | """Return the secondary PostgreSQL container""" 435 | containers = docker_client.containers.list( 436 | filters={ 437 | "label": f"com.docker.compose.project={secondary_compose_project_name}" 438 | } 439 | ) 440 | for container in containers: 441 | service_name = container.labels.get("com.docker.compose.service") 442 | if service_name == "secondary_postgres": 443 | return container 444 | raise RuntimeError("Secondary PostgreSQL container not found") 445 | 446 | 447 | @pytest.fixture 448 | def backup_container_with_multi_project( 449 | docker_client, compose_project_name, compose_up, secondary_compose_up, project_root 450 | ): 451 | """Return the backup container with INCLUDE_ALL_COMPOSE_PROJECTS enabled""" 452 | # Get the backup container 453 | containers = docker_client.containers.list( 454 | filters={"label": f"com.docker.compose.project={compose_project_name}"} 455 | ) 456 | backup_cont = None 457 | for container in containers: 458 | service_name = container.labels.get("com.docker.compose.service") 459 | if service_name == "backup": 460 | backup_cont = container 461 | break 462 | 463 | if backup_cont is None: 464 | raise RuntimeError("Backup container not found") 465 | 466 | # Set the environment variable for this test 467 | # We need to restart the container with the new environment variable 468 | # Since we can't easily modify a running container, we'll use exec to set it 469 | # and restart the backup process 470 | 471 | # Stop the container 472 | backup_cont.stop() 473 | 474 | # Get the container config 475 | container_info = docker_client.api.inspect_container(backup_cont.id) 476 | config = container_info["Config"] 477 | host_config = container_info["HostConfig"] 478 | 479 | # Add the environment variable 480 | env_list = config.get("Env", []) 481 | # Remove any existing INCLUDE_ALL_COMPOSE_PROJECTS 482 | env_list = [ 483 | e for e in env_list if not e.startswith("INCLUDE_ALL_COMPOSE_PROJECTS=") 484 | ] 485 | env_list.append("INCLUDE_ALL_COMPOSE_PROJECTS=true") 486 | 487 | # Remove the old container 488 | backup_cont.remove() 489 | 490 | # Create a new container with the updated environment 491 | new_container = docker_client.containers.create( 492 | config["Image"], 493 | environment=env_list, 494 | volumes=host_config.get("Binds", []), 495 | network=list(container_info["NetworkSettings"]["Networks"].keys())[0] 496 | if container_info["NetworkSettings"]["Networks"] 497 | else None, 498 | name=container_info["Name"].strip("/"), 499 | labels=config.get("Labels", {}), 500 | ) 501 | 502 | # Start the new container 503 | new_container.start() 504 | 505 | # Wait a bit for it to initialize 506 | time.sleep(5) 507 | 508 | yield new_container 509 | 510 | # Clean up - stop AND remove the container so compose creates a fresh one 511 | new_container.stop() 512 | new_container.remove() 513 | 514 | # Restart the original backup container setup 515 | # This will now create a fresh container without INCLUDE_ALL_COMPOSE_PROJECTS 516 | subprocess.run( 517 | [ 518 | "docker", 519 | "compose", 520 | "-f", 521 | str(project_root / "docker-compose.test.yaml"), 522 | "-p", 523 | compose_project_name, 524 | "up", 525 | "-d", 526 | "backup", 527 | ], 528 | check=False, 529 | cwd=str(project_root), 530 | ) 531 | --------------------------------------------------------------------------------