50 Commits
0.2.0 ... 0.3.0

Author SHA1 Message Date
Einar Forselv
0a8bbc40c3 Doc banner 2019-12-05 02:31:20 +01:00
Einar Forselv
69b014e88e Update index.rst 2019-12-05 02:26:50 +01:00
Einar Forselv
51742efd33 sphinx: set master doc 2019-12-05 02:15:36 +01:00
Einar Forselv
c6b9f2dc1e Update setup.py 2019-12-05 02:04:36 +01:00
Einar Forselv
2216d76af5 Update release.md 2019-12-05 02:04:11 +01:00
Einar Forselv
3c891aa8b8 Update README.md 2019-12-05 01:58:50 +01:00
Einar Forselv
e3ab8e0e5a Set up docs 2019-12-05 01:58:39 +01:00
Einar Forselv
2c448bdcae Create LICENSE 2019-12-05 00:58:32 +01:00
Einar Forselv
b9d5233510 Do not expose db passwords when pinging 2019-12-05 00:38:58 +01:00
Einar Forselv
9dabf01051 Allow overriding container env vars 2019-12-05 00:38:09 +01:00
Einar Forselv
fdfb28fc47 Propagate log level to parent container 2019-12-05 00:37:13 +01:00
Einar Forselv
1978ee5946 Do not leak passwords in logs 2019-12-05 00:27:56 +01:00
Einar Forselv
38c59b2436 Use XDG_CACHE_HOME to control cache dir 2019-12-04 23:49:32 +01:00
Einar Forselv
4e480ed8e0 Finetune dockerignore 2019-12-04 23:46:56 +01:00
Einar Forselv
96beeab5bd Support forget / prune 2019-12-04 23:25:15 +01:00
Einar Forselv
7dd72ee5ce Remove debug print 2019-12-04 23:24:56 +01:00
Einar Forselv
ef07645664 Add common env vars to env file 2019-12-04 23:24:35 +01:00
Einar Forselv
9f33cbcc39 Remove old environment block 2019-12-04 23:23:53 +01:00
Einar Forselv
f29eab3249 Don't copy log files into image 2019-12-04 23:23:32 +01:00
Einar Forselv
2864145d56 Update README.md 2019-12-04 23:01:06 +01:00
Einar Forselv
6a4e87a2eb Tweak ingores 2019-12-04 23:01:01 +01:00
Einar Forselv
faa2d9ff7e Do not filter mounts if volume backup is not enabled 2019-12-04 22:25:09 +01:00
Einar Forselv
91901ee35c Broken tests due to incorrect labels 2019-12-04 22:17:57 +01:00
Einar Forselv
d3933f8913 Back up to /volumes and /databases 2019-12-04 22:17:42 +01:00
Einar Forselv
7f6b140a00 rcb cleanup 2019-12-04 22:03:49 +01:00
Einar Forselv
6bc88957e7 snapshots --last 2019-12-04 22:02:53 +01:00
Einar Forselv
eaf8b5cc78 Send alert from the main container 2019-12-04 21:24:10 +01:00
Einar Forselv
1ca678f6b4 Send alert when something went wrong during backup 2019-12-04 20:55:33 +01:00
Einar Forselv
d9a082d044 Generic alert send 2019-12-04 20:55:01 +01:00
Einar Forselv
850df45a69 Working discord webhook 2019-12-04 20:31:58 +01:00
Einar Forselv
96889b02a9 Update smtp.py 2019-12-04 20:31:41 +01:00
Einar Forselv
6a3b06f371 Debug log in alert module 2019-12-04 20:28:06 +01:00
Einar Forselv
1edd7ca771 alert test command 2019-12-04 19:36:32 +01:00
Einar Forselv
a4a8a2f462 Working mail alerts + alert system tweaks 2019-12-04 19:36:14 +01:00
Einar Forselv
26ea7a2a00 Shortcut property for getting project name 2019-12-04 19:34:50 +01:00
Einar Forselv
85e9efb769 Basic alert system setup 2019-12-04 03:58:27 +01:00
Einar Forselv
7ca7f56258 Sane cron default: Every day at 2am 2019-12-04 03:35:45 +01:00
Einar Forselv
9fad6f5f38 Note about Popen buffer size 2019-12-04 03:33:30 +01:00
Einar Forselv
f288f77aa4 rcb snapshots 2019-12-04 03:12:36 +01:00
Einar Forselv
f8a9f0e7e9 Support running commands capturing stdout 2019-12-04 03:12:13 +01:00
Einar Forselv
b8757929fa Reduce log format 2019-12-04 03:11:46 +01:00
Einar Forselv
bdf2ea5e41 More logging cleanup 2019-12-04 01:58:01 +01:00
Einar Forselv
00cf68fa3e Properly capture stdout an std err in backup_from_stdin 2019-12-04 01:57:52 +01:00
Einar Forselv
fb3f5b38c3 Shorten log format 2019-12-04 01:30:05 +01:00
Einar Forselv
4ff0df1b35 Clean up logging 2019-12-04 01:12:26 +01:00
Einar Forselv
c78d208e66 Dockerfile: Remove unnecessary layer 2019-12-04 00:50:05 +01:00
Einar Forselv
f4c2cf9bb7 Update README.md 2019-12-04 00:36:28 +01:00
Einar Forselv
947a56b21e Configurable log level: ENV + cmd 2019-12-04 00:31:13 +01:00
Einar Forselv
4ad575cfe3 Update README.md 2019-12-03 10:19:12 +01:00
Einar Forselv
bd55a691e7 Create release.md 2019-12-03 10:19:09 +01:00
27 changed files with 681 additions and 103 deletions

View File

@@ -1,9 +1,19 @@
.venv/
.vscode/
.gitignore
Dockerfile
extras/
restic_cache/
restic_data/
tests/
docker-compose.yaml
.gitignore
*.env
*.log
docker-compose.yaml
*.ini
*.egg-info
__pycache__
.DS_Store
.git
.pytest_cache
.dockerignore
build/
docs/

5
.gitignore vendored
View File

@@ -19,3 +19,8 @@ venv
/private/
restic_data/
restic_cache/
alerts.env
# docs
build/
docs/_build

View File

@@ -4,8 +4,8 @@ RUN apk update && apk add python3 dcron mariadb-client postgresql-client
ADD . /restic-compose-backup
WORKDIR /restic-compose-backup
RUN pip3 install -U pip setuptools
RUN pip3 install -e .
RUN pip3 install -U pip setuptools && pip3 install -e .
ENV XDG_CACHE_HOME=/cache
ENTRYPOINT []
CMD ["./entrypoint.sh"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Zetta.IO
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,22 +1,44 @@
# restic-compose-backup
*WORK IN PROGRESS*
![docs](https://readthedocs.org/projects/restic-compose-backup/badge/?version=latest)
Backup using https://restic.net/ for a docker-compose setup.
* [restic-compose-backup Documentation](https://restic-compose-backup.readthedocs.io)
* [restic-compose-backup on Github](https://github.com/ZettaIO/restic-compose-backup)
* [restic-compose-backup on Docker Hub](https://hub.docker.com/r/zettaio/restic-compose-backup)
Features:
* Back up docker volumes or host binds
* Back up mariadb postgres
* Back up mariadb databases
* Back up mysql databases
* Notifications over mail/smtp
* Notifications to Discord through webhooks
Please report issus on [github](https://github.com/ZettaIO/restic-compose-backup/issues).
Automatically detects and backs up volumes, mysql, mariadb and postgres databases in a docker-compose setup.
This includes both host mapped volumes and actual docker volumes.
* Each service in the compose setup is configured with a label
to enable backup of volumes or databases
* When backup starts a new instance of the container is created
mapping in all the needed volumes. It will copy networks etc
to ensure databases can be reached
* Volumes are mounted to `/backup/<service_name>/<path>`
in the backup process container. `/backup` is pushed into restic
* Databases are backed up from stdin / dumps
* Cron triggers backup
* Volumes are mounted to `/volumes/<service_name>/<path>`
in the backup process container. `/volumes` is pushed into restic
* Databases are backed up from stdin / dumps into restic using path `/databases/<service_name>/dump.sql`
* Cron triggers backup at 2AM every day
## Install
```bash
docker pull zettaio/restic-compose-backup
```
.. or clone this repo and build it.
## Configuration
@@ -29,6 +51,29 @@ RESTIC_PASSWORD
Backend specific env vars : https://restic.readthedocs.io/en/stable/040_backup.html#environment-variables
Additional env vars:
```bash
# Prune rules
RESTIC_KEEP_DAILY=7
RESTIC_KEEP_WEEKLY=4
RESTIC_KEEP_MONTHLY=12
RESTIC_KEEP_YEARLY=3
# Logging level (debug,info,warning,error)
LOG_LEVEL=info
# SMTP alerts
EMAIL_HOST=my.mail.host
EMAIL_PORT=465
EMAIL_HOST_USER=johndoe
EMAIL_HOST_PASSWORD=s3cr3tpassw0rd
EMAIL_SEND_TO=johndoe@gmail.com
# Discord webhook
DISCORD_WEBHOOK=https://discordapp.com/api/webhooks/...
```
### Volumes
```yaml
@@ -40,6 +85,10 @@ services:
environment:
- RESTIC_REPOSITORY=<whatever restic supports>
- RESTIC_PASSWORD=hopefullyasecturepw
- RESTIC_KEEP_DAILY=7
- RESTIC_KEEP_WEEKLY=4
- RESTIC_KEEP_MONTHLY=12
- RESTIC_KEEP_YEARLY=3
env_file:
- some_other_vars.env
volumes:
@@ -106,7 +155,7 @@ volumes:
Will dump databases directly into restic through stdin.
They will appear in restic as a separate snapshot with
path `/backup/<service_name>/dump.sql` or similar.
path `/databases/<service_name>/dump.sql` or similar.
```yaml
mariadb:
@@ -132,8 +181,19 @@ path `/backup/<service_name>/dump.sql` or similar.
## Running Tests
```
```bash
python setup.py develop
pip install -r tests/requirements.txt
pytest tests
```
## Building Docs
```bash
pip install -r docs/requirements.txt
python setup.py build_sphinx
```
## Contributing
Contributions are welcome regardless of experience level. Don't hesitate submitting issues, opening partial or completed pull requests.

View File

@@ -1 +1 @@
1 * * * * source /env.sh && rcb backup > /proc/1/fd/1
0 2 * * * source /env.sh && rcb backup > /proc/1/fd/1

View File

@@ -9,8 +9,8 @@ services:
- /var/run/docker.sock:/tmp/docker.sock:ro
# Map backup database locally
- ./restic_data:/restic_data
- ./restic_cache:/restic_cache
# Map in project source
- ./restic_cache:/cache
# Map in project source in dev
- .:/restic-compose-backup
web:
image: nginx

20
docs/Makefile Normal file
View File

@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

57
docs/conf.py Normal file
View File

@@ -0,0 +1,57 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'restic-compose-backup'
copyright = '2019, Zetta.IO Technology AS'
author = 'Zetta.IO Technology AS'
# The full version, including alpha/beta/rc tags
release = '0.2.0'
# -- General configuration ---------------------------------------------------
master_doc = 'index'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

21
docs/index.rst Normal file
View File

@@ -0,0 +1,21 @@
.. restic-compose-backup documentation master file, created by
sphinx-quickstart on Thu Dec 5 01:34:58 2019.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to restic-compose-backup's documentation!
=================================================
Simple backup with restic for small to medium docker-compose setups.
.. toctree::
:maxdepth: 2
:caption: Contents:
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

2
docs/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
sphinx
sphinx-rtd-theme

13
extras/release.md Normal file
View File

@@ -0,0 +1,13 @@
# Making a release
- Update version in setup.py
- Build and tag image
- push: `docker push zettaio/restic-compose-backup:<version>`
- Ensure RTD has new docs published
## Example
```bash
docker build . --tag zettaio/restic-compose-backup:0.2.0
docker push zettaio/restic-compose-backup:0.2.0
```

View File

@@ -4,6 +4,21 @@ DOCKER_BASE_URL=unix://tmp/docker.sock
RESTIC_REPOSITORY=/restic_data
RESTIC_PASSWORD=password
RESTIC_KEEP_DAILY=7
RESTIC_KEEP_WEEKLY=4
RESTIC_KEEP_MONTHLY=12
RESTIC_KEEP_YEARLY=3
LOG_LEVEL=info
# EMAIL_HOST=
# EMAIL_PORT=
# EMAIL_HOST_USER=
# EMAIL_HOST_PASSWORD=
# EMAIL_SEND_TO=
# DISCORD_WEBHOOK=u
# Various env vars for restic : https://restic.readthedocs.io/en/stable/040_backup.html#environment-variables
# RESTIC_REPOSITORY Location of repository (replaces -r)
# RESTIC_PASSWORD_FILE Location of password file (replaces --password-file)

View File

@@ -0,0 +1,43 @@
import logging
from restic_compose_backup.alerts.smtp import SMTPAlert
from restic_compose_backup.alerts.discord import DiscordWebhookAlert
from restic_compose_backup.config import Config
logger = logging.getLogger(__name__)
ALERT_INFO = 'INFO',
ALERT_ERROR = 'ERROR'
ALERT_TYPES = [ALERT_INFO, ALERT_ERROR]
BACKENDS = [SMTPAlert, DiscordWebhookAlert]
def send(subject: str = None, body: str = None, alert_type: str = 'INFO'):
"""Send alert to all configured backends"""
alert_classes = configured_alert_types()
for instance in alert_classes:
logger.info('Configured: %s', instance.name)
try:
instance.send(
subject=f'[{alert_type}] {subject}',
body=body,
)
except Exception as ex:
logger.error("Exception raised when sending alert [%s]: %s", instance.name, ex)
if len(alert_classes) == 0:
logger.info("No alerts configured")
def configured_alert_types():
"""Returns a list of configured alert class instances"""
logger.debug('Getting alert backends')
entires = []
for cls in BACKENDS:
instance = cls.create_from_env()
logger.debug("Alert backend '%s' configured: %s", cls.name, instance != None)
if instance:
entires.append(instance)
return entires

View File

@@ -0,0 +1,14 @@
class BaseAlert:
name = None
def create_from_env(self):
return None
@property
def properly_configured(self) -> bool:
return False
def send(self, subject: str = None, body: str = None, alert_type: str = None):
pass

View File

@@ -0,0 +1,46 @@
import os
import logging
from urllib.parse import urlparse
import requests
from restic_compose_backup.alerts.base import BaseAlert
logger = logging.getLogger(__name__)
class DiscordWebhookAlert(BaseAlert):
name = 'discord_webhook'
success_codes = [200]
def __init__(self, webhook_url):
self.url = webhook_url
@classmethod
def create_from_env(cls):
instance = cls(os.environ.get('DISCORD_WEBHOOK'))
if instance.properly_configured:
return instance
return None
@property
def properly_configured(self) -> bool:
return isinstance(self.url, str) and self.url.startswith("https://")
def send(self, subject: str = None, body: str = None, alert_type: str = None):
"""Send basic webhook request. Max embed size is 6000"""
logger.info("Triggering discord webhook")
data = {
'embeds': [
{
'title': subject,
'description': body[:5000],
},
]
}
response = requests.post(self.url, params={'wait': True}, json=data)
if response.status_code not in self.success_codes:
log.error("Discord webhook failed: %s: %s", response.status_code, response.content)
else:
logger.info('Discord webhook successful')

View File

@@ -0,0 +1,56 @@
import os
import smtplib
import logging
from email.mime.text import MIMEText
from restic_compose_backup.alerts.base import BaseAlert
logger = logging.getLogger(__name__)
class SMTPAlert(BaseAlert):
name = 'smtp'
def __init__(self, host, port, user, password, to):
self.host = host
self.port = port
self.user = user
self.password = password
self.to = to
@classmethod
def create_from_env(cls):
instance = cls(
os.environ.get('EMAIL_HOST'),
os.environ.get('EMAIL_PORT'),
os.environ.get('EMAIL_HOST_USER'),
os.environ.get('EMAIL_HOST_PASSWORD'),
(os.environ.get('EMAIL_SEND_TO') or "").split(','),
)
if instance.properly_configured:
return instance
return None
@property
def properly_configured(self) -> bool:
return self.host and self.port and self.user and self.password and len(self.to) > 0
def send(self, subject: str = None, body: str = None, alert_type: str = 'INFO'):
# send_mail("Hello world!")
msg = MIMEText(body)
msg['Subject'] = f"[{alert_type}] {subject}"
msg['From'] = self.user
msg['To'] = ', '.join(self.to)
try:
logger.info("Connecting to %s port %s", self.host, self.port)
server = smtplib.SMTP_SSL(self.host, self.port)
server.ehlo()
server.login(self.user, self.password)
server.sendmail(self.user, self.to, msg.as_string())
logger.info('Email sent')
except Exception as ex:
logger.error(ex)
finally:
server.close()

View File

@@ -3,6 +3,7 @@ import pprint
import logging
from restic_compose_backup import (
alerts,
backup_runner,
log,
restic,
@@ -17,26 +18,37 @@ def main():
"""CLI entrypoint"""
args = parse_args()
config = Config()
log.setup(level=args.log_level or config.log_level)
containers = RunningContainers()
# Ensure log level is propagated to parent container if overridden
if args.log_level:
containers.this_container.set_config_env('LOG_LEVEL', args.log_level)
if args.action == 'status':
status(config, containers)
elif args.action == 'snapshots':
snapshots(config, containers)
elif args.action == 'backup':
backup(config, containers)
elif args.action == 'start-backup-process':
start_backup_process(config, containers)
elif args.action == 'cleanup':
cleanup(config, containers)
elif args.action == 'alert':
alert(config, containers)
def status(config, containers):
"""Outputs the backup config for the compose setup"""
logger.info("Backup config for compose project '%s'", containers.this_container.project_name)
logger.info("Current service: %s", containers.this_container.name)
# logger.info("Backup process: %s", containers.backup_process_container.name
# if containers.backup_process_container else 'Not Running')
logger.info("Backup running: %s", containers.backup_process_running)
logger.info("Status for compose project '%s'", containers.project_name)
logger.info("Backup currently running?: %s", containers.backup_process_running)
logger.info("%s Detected Config %s", "-" * 25, "-" * 25)
backup_containers = containers.containers_for_backup()
for container in backup_containers:
@@ -50,10 +62,14 @@ def status(config, containers):
instance = container.instance
ping = instance.ping()
logger.info(' - %s (is_ready=%s)', instance.container_type, ping == 0)
if ping != 0:
logger.error("Database '%s' in service %s cannot be reached", instance.container_type, container.service_name)
if len(backup_containers) == 0:
logger.info("No containers in the project has 'restic-compose-backup.enabled' label")
logger.info("-" * 67)
def backup(config, containers):
"""Request a backup to start"""
@@ -61,18 +77,16 @@ def backup(config, containers):
if containers.backup_process_running:
raise ValueError("Backup process already running")
logger.info("Initializing repository")
logger.info("Initializing repository (may fail if already initalized)")
# TODO: Errors when repo already exists
restic.init_repo(config.repository)
logger.info("Starting backup container..")
# Map all volumes from the backup container into the backup process container
volumes = containers.this_container.volumes
# Map volumes from other containers we are backing up
mounts = containers.generate_backup_mounts('/backup')
mounts = containers.generate_backup_mounts('/volumes')
volumes.update(mounts)
result = backup_runner.run(
@@ -83,11 +97,18 @@ def backup(config, containers):
source_container_id=containers.this_container.id,
labels={
"restic-compose-backup.backup_process": 'True',
"com.docker.compose.project": containers.this_container.project_name,
"com.docker.compose.project": containers.project_name,
},
)
logger.info('Backup container exit code: %s', result)
# TODO: Alert
# Alert the user if something went wrong
if result != 0:
alerts.send(
subject="Backup process exited with non-zero code",
body=open('backup.log').read(),
alert_type='ERROR',
)
def start_backup_process(config, containers):
@@ -101,16 +122,19 @@ def start_backup_process(config, containers):
return
status(config, containers)
logger.info("start-backup-process")
errors = False
# Back up volumes
try:
vol_result = restic.backup_files(config.repository, source='/backup')
logger.info('Volume backup exit code: %s', vol_result)
# TODO: Alert
logger.info('Backing up volumes')
vol_result = restic.backup_files(config.repository, source='/volumes')
logger.debug('Volume backup exit code: %s', vol_result)
if vol_result != 0:
logger.error('Backup command exited with non-zero code: %s', vol_result)
errors = True
except Exception as ex:
logger.error(ex)
# TODO: Alert
errors = True
# back up databases
for container in containers.containers_for_backup():
@@ -119,18 +143,65 @@ def start_backup_process(config, containers):
instance = container.instance
logger.info('Backing up %s in service %s', instance.container_type, instance.service_name)
result = instance.backup()
logger.info('Exit code: %s', result)
# TODO: Alert
logger.debug('Exit code: %s', result)
if result != 0:
logger.error('Backup command exited with non-zero code: %s', result)
errors = True
except Exception as ex:
logger.error(ex)
# TODO: Alert
errors = True
if errors:
exit(1)
# Only run cleanup if backup was successful
result = cleanup(config, container)
logger.debug('cleanup exit code: %s', errors)
if result != 0:
exit(1)
def cleanup(config, containers):
"""Run forget / prune to minimize storage space"""
logger.info('Forget outdated snapshots')
forget_result = restic.forget(
config.repository,
config.keep_daily,
config.keep_weekly,
config.keep_monthly,
config.keep_yearly,
)
logger.info('Prune stale data freeing storage space')
prune_result = restic.prune(config.repository)
return forget_result == 0 and prune_result == 0
def snapshots(config, containers):
"""Display restic snapshots"""
stdout, stderr = restic.snapshots(config.repository, last=True)
for line in stdout.decode().split('\n'):
print(line)
def alert(config, containers):
"""Test alerts"""
logger.info("Testing alerts")
alerts.send(
subject="{}: Test Alert".format(containers.project_name),
body="Test message",
)
def parse_args():
parser = argparse.ArgumentParser(prog='restic_compose_backup')
parser.add_argument(
'action',
choices=['status', 'backup', 'start-backup-process'],
choices=['status', 'snapshots', 'backup', 'start-backup-process', 'alert', 'cleanup'],
)
parser.add_argument(
'--log-level',
default=None,
choices=list(log.LOG_LEVELS.keys()),
help="Log level"
)
return parser.parse_args()

View File

@@ -1,15 +1,15 @@
import logging
from typing import List
from typing import List, Tuple
from subprocess import Popen, PIPE
logger = logging.getLogger(__name__)
def test():
return run_command(['ls', '/backup'])
return run_command(['ls', '/volumes'])
def ping_mysql(host, port, username, password) -> int:
def ping_mysql(host, port, username) -> int:
"""Check if the mysql is up and can be reached"""
return run([
'mysqladmin',
@@ -20,11 +20,10 @@ def ping_mysql(host, port, username, password) -> int:
port,
'--user',
username,
f'--password={password}',
])
def ping_mariadb(host, port, username, password) -> int:
def ping_mariadb(host, port, username): #, password) -> int:
"""Check if the mariadb is up and can be reached"""
return run([
'mysqladmin',
@@ -35,7 +34,6 @@ def ping_mariadb(host, port, username, password) -> int:
port,
'--user',
username,
f'--password={password}',
])
@@ -51,18 +49,25 @@ def ping_postgres(host, port, username, password) -> int:
def run(cmd: List[str]) -> int:
"""Run a command with parameters"""
logger.info('cmd: %s', ' '.join(cmd))
logger.debug('cmd: %s', ' '.join(cmd))
child = Popen(cmd, stdout=PIPE, stderr=PIPE)
stdoutdata, stderrdata = child.communicate()
if stdoutdata:
logger.info(stdoutdata.decode().strip())
logger.info('-' * 28)
logger.debug(stdoutdata.decode().strip())
logger.debug('-' * 28)
if stderrdata:
logger.info('%s STDERR %s', '-' * 10, '-' * 10)
logger.info(stderrdata.decode().strip())
logger.info('-' * 28)
logger.error('%s STDERR %s', '-' * 10, '-' * 10)
logger.error(stderrdata.decode().strip())
logger.error('-' * 28)
logger.info("returncode %s", child.returncode)
logger.debug("returncode %s", child.returncode)
return child.returncode
def run_capture_std(cmd: List[str]) -> Tuple[str, str]:
"""Run a command with parameters and return stdout, stderr"""
logger.debug('cmd: %s', ' '.join(cmd))
child = Popen(cmd, stdout=PIPE, stderr=PIPE)
return child.communicate()

View File

@@ -4,16 +4,26 @@ import os
class Config:
"""Bag for config values"""
def __init__(self, check=True):
self.repository = os.environ['RESTIC_REPOSITORY']
self.password = os.environ['RESTIC_PASSWORD']
# Mandatory values
self.repository = os.environ.get('RESTIC_REPOSITORY')
self.password = os.environ.get('RESTIC_REPOSITORY')
self.docker_base_url = os.environ.get('DOCKER_BASE_URL') or "unix://tmp/docker.sock"
# Log
self.log_level = os.environ.get('LOG_LEVEL')
# forget / keep
self.keep_daily = os.environ.get('KEEP_DAILY') or "7"
self.keep_weekly = os.environ.get('KEEP_WEEKLY') or "4"
self.keep_monthly = os.environ.get('KEEP_MONTHLY') or "12"
self.keep_yearly = os.environ.get('KEEP_YEARLY') or "3"
if check:
self.check()
def check(self):
if not self.repository:
raise ValueError("CONTAINER env var not set")
raise ValueError("RESTIC_REPOSITORY env var not set")
if not self.password:
raise ValueError("PASSWORD env var not set")
raise ValueError("RESTIC_REPOSITORY env var not set")

View File

@@ -63,7 +63,7 @@ class Container:
@property
def environment(self) -> list:
"""All configured env vars for the container as a list"""
return self.get_config('Env', default=[])
return self.get_config('Env')
def get_config_env(self, name) -> str:
"""Get a config environment variable by name"""
@@ -71,6 +71,17 @@ class Container:
data = {i[0:i.find('=')]: i[i.find('=')+1:] for i in self.environment}
return data.get(name)
def set_config_env(self, name, value):
"""Set an environment variable"""
env = self.environment
new_value = f'{name}={value}'
for i, entry in enumerate(env):
if f'{name}=' in entry:
env[i] = new_value
break
else:
env.append(new_value)
@property
def volumes(self) -> dict:
"""
@@ -158,8 +169,12 @@ class Container:
return self._labels.get(name, None)
def filter_mounts(self):
"""Get all mounts for this container matching include/exclude filters"""
"""Get all mounts for this container matching include/exclude filters"""
filtered = []
if not self.volume_backup_enabled:
return filtered
if self._include:
for mount in self._mounts:
for pattern in self._include:
@@ -182,7 +197,7 @@ class Container:
return filtered
def volumes_for_backup(self, source_prefix='/backup', mode='ro'):
def volumes_for_backup(self, source_prefix='/volumes', mode='ro'):
"""Get volumes configured for backup"""
mounts = self.filter_mounts()
volumes = {}
@@ -319,6 +334,11 @@ class RunningContainers:
if container.id != self.this_container.id:
self.containers.append(container)
@property
def project_name(self) -> str:
"""str: Name of the compose project"""
return self.this_container.project_name
@property
def backup_process_running(self) -> bool:
"""Is the backup process container running?"""
@@ -328,7 +348,7 @@ class RunningContainers:
"""Obtain all containers with backup enabled"""
return [container for container in self.containers if container.backup_enabled]
def generate_backup_mounts(self, dest_prefix='/backup') -> dict:
def generate_backup_mounts(self, dest_prefix='/volumes') -> dict:
"""Generate mounts for backup for the entire compose setup"""
mounts = {}
for container in self.containers_for_backup():

View File

@@ -22,12 +22,13 @@ class MariadbContainer(Container):
def ping(self) -> bool:
"""Check the availability of the service"""
creds = self.get_credentials()
return commands.ping_mysql(
creds['host'],
creds['port'],
creds['username'],
creds['password'],
)
with utils.environment('MYSQL_PWD', creds['password']):
return commands.ping_mariadb(
creds['host'],
creds['port'],
creds['username'],
)
def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin"""
@@ -37,17 +38,19 @@ class MariadbContainer(Container):
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}",
f"--password={creds['password']}",
"--all-databases",
]
def backup(self):
config = Config()
return restic.backup_from_stdin(
config.repository,
f'/backup/{self.service_name}/all_databases.sql',
self.dump_command(),
)
creds = self.get_credentials()
with utils.environment('MYSQL_PWD', creds['password']):
return restic.backup_from_stdin(
config.repository,
f'/databases/{self.service_name}/all_databases.sql',
self.dump_command(),
)
class MysqlContainer(Container):
@@ -65,12 +68,13 @@ class MysqlContainer(Container):
def ping(self) -> bool:
"""Check the availability of the service"""
creds = self.get_credentials()
return commands.ping_mysql(
creds['host'],
creds['port'],
creds['username'],
creds['password'],
)
with utils.environment('MYSQL_PWD', creds['password']):
return commands.ping_mysql(
creds['host'],
creds['port'],
creds['username'],
)
def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin"""
@@ -80,17 +84,19 @@ class MysqlContainer(Container):
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}",
f"--password={creds['password']}",
"--all-databases",
]
def backup(self):
config = Config()
return restic.backup_from_stdin(
config.repository,
f'/backup/{self.service_name}/all_databases.sql',
self.dump_command(),
)
creds = self.get_credentials()
with utils.environment('MYSQL_PWD', creds['password']):
return restic.backup_from_stdin(
config.repository,
f'/databases/{self.service_name}/all_databases.sql',
self.dump_command(),
)
class PostgresContainer(Container):
@@ -135,6 +141,6 @@ class PostgresContainer(Container):
with utils.environment('PGPASSWORD', creds['password']):
return restic.backup_from_stdin(
config.repository,
f"/backup/{self.service_name}/{creds['database']}.sql",
f"/databases/{self.service_name}/{creds['database']}.sql",
self.dump_command(),
)

View File

@@ -5,9 +5,23 @@ import sys
logger = logging.getLogger('restic_compose_backup')
HOSTNAME = os.environ['HOSTNAME']
level = logging.INFO
logger.setLevel(level)
ch = logging.StreamHandler(stream=sys.stdout)
ch.setLevel(level)
ch.setFormatter(logging.Formatter(f'%(asctime)s - {HOSTNAME} - %(name)s - %(levelname)s - %(message)s'))
logger.addHandler(ch)
DEFAULT_LOG_LEVEL = logging.INFO
LOG_LEVELS = {
'debug': logging.DEBUG,
'info': logging.INFO,
'warning': logging.WARNING,
'error': logging.ERROR,
}
def setup(level: str = 'warning'):
"""Set up logging"""
level = level or ""
level = LOG_LEVELS.get(level.lower(), DEFAULT_LOG_LEVEL)
logger.setLevel(level)
ch = logging.StreamHandler(stream=sys.stdout)
ch.setLevel(level)
# ch.setFormatter(logging.Formatter(f'%(asctime)s - {HOSTNAME} - %(name)s - %(levelname)s - %(message)s'))
# ch.setFormatter(logging.Formatter(f'%(asctime)s - {HOSTNAME} - %(levelname)s - %(message)s'))
ch.setFormatter(logging.Formatter(f'%(asctime)s - %(levelname)s: %(message)s'))
logger.addHandler(ch)

View File

@@ -2,7 +2,7 @@
Restic commands
"""
import logging
from typing import List
from typing import List, Tuple
from subprocess import Popen, PIPE
from restic_compose_backup import commands
@@ -19,7 +19,7 @@ def init_repo(repository: str):
]))
def backup_files(repository: str, source='/backup'):
def backup_files(repository: str, source='/volumes'):
return commands.run(restic(repository, [
"--verbose",
"backup",
@@ -40,19 +40,51 @@ def backup_from_stdin(repository: str, filename: str, source_command: List[str])
])
# pipe source command into dest command
# NOTE: Using the default buffer size: io.DEFAULT_BUFFER_SIZE = 8192
# We might want to tweak that to speed up large dumps.
# Actual tests tests must be done.
source_process = Popen(source_command, stdout=PIPE)
dest_process = Popen(dest_command, stdin=source_process.stdout)
dest_process.communicate()
dest_process = Popen(dest_command, stdin=source_process.stdout, stdout=PIPE, stderr=PIPE)
stdout, stderr = dest_process.communicate()
if stdout:
for line in stdout.decode().split('\n'):
logger.debug(line)
if stderr:
for line in stderr.decode().split('\n'):
logger.error(line)
# Ensure both processes exited with code 0
source_exit, dest_exit = source_process.poll(), dest_process.poll()
return 0 if (source_exit == 0 and dest_exit == 0) else 1
def snapshots(repository: str):
return commands.run(restic(repository, [
"snapshots",
]))
def snapshots(repository: str, last=True) -> Tuple[str, str]:
args = ["snapshots"]
if last:
args.append('--last')
return commands.run_capture_std(restic(repository, args))
def forget(repository: str, daily: str, weekly: str, monthly: str, yearly: str):
return restic(repository, [
'forget',
'--keep-daily',
daily,
'--keep-weekly',
weekly,
'--keep-monthly',
monthly,
'--keep-yearly',
yearly,
])
def prune(repository: str):
return restic(repository, [
'prune',
])
def check(repository: str):
@@ -65,8 +97,6 @@ def restic(repository: str, args: List[str]):
"""Generate restic command"""
return [
"restic",
"--cache-dir",
"/restic_cache",
"-r",
repository,
] + args

View File

@@ -3,7 +3,7 @@ from setuptools import setup, find_namespace_packages
setup(
name="restic-compose-backup",
url="https://github.com/ZettaIO/restic-compose-backup",
version="0.2.0",
version="0.3.0",
author="Einar Forselv",
author_email="eforselv@gmail.com",
packages=find_namespace_packages(include=['restic_compose_backup']),

View File

@@ -59,6 +59,7 @@ class ResticBackupTests(unittest.TestCase):
{
'service': 'web',
'labels': {
'restic-compose-backup.volumes': True,
'test': 'test',
},
'mounts': [{
@@ -111,7 +112,7 @@ class ResticBackupTests(unittest.TestCase):
with mock.patch(list_containers_func, fixtures.containers(containers=containers)):
cnt = RunningContainers()
self.assertTrue(len(cnt.containers_for_backup()) == 2)
self.assertEqual(cnt.generate_backup_mounts(), {'test': {'bind': '/backup/web/test', 'mode': 'ro'}})
self.assertEqual(cnt.generate_backup_mounts(), {'test': {'bind': '/volumes/web/test', 'mode': 'ro'}})
def test_include(self):
containers = self.createContainers()
@@ -119,7 +120,8 @@ class ResticBackupTests(unittest.TestCase):
{
'service': 'web',
'labels': {
'restic-compose-backup.include': 'media',
'restic-compose-backup.volumes': True,
'restic-compose-backup.volumes.include': 'media',
},
'mounts': [
{
@@ -142,6 +144,7 @@ class ResticBackupTests(unittest.TestCase):
self.assertNotEqual(web_service, None, msg="Web service not found")
mounts = web_service.filter_mounts()
print(mounts)
self.assertEqual(len(mounts), 1)
self.assertEqual(mounts[0].source, '/srv/files/media')
@@ -151,7 +154,8 @@ class ResticBackupTests(unittest.TestCase):
{
'service': 'web',
'labels': {
'restic-compose-backup.exclude': 'stuff',
'restic-compose-backup.volumes': True,
'restic-compose-backup.volumes.exclude': 'stuff',
},
'mounts': [
{