Compare commits
26 Commits
Author | SHA1 | Date |
---|---|---|
einarf | e6ca4aa9ca | |
einarf | 093dab93ca | |
einarf | 405bd4af15 | |
dreadper | 28dda6b09d | |
dreadper | b400138b73 | |
Einar Forselv | b52655a23b | |
Maximilian Wehrstedt | 323e299b7e | |
einarf | 5c33ccf0b1 | |
einarf | 764aac6744 | |
einarf | bbe57dfd69 | |
einarf | 4517880846 | |
einarf | 1fefd63c72 | |
Einar Forselv | 4e1af219e2 | |
Einar Forselv | 93f080d5b3 | |
Jannik | 0fc620bb1f | |
Jannik | f7958d7db9 | |
Jannik | 18ddb173ac | |
Jannik | f59a046bbc | |
Einar Forselv | 8b934dc12f | |
Einar Forselv | 8e9105fed5 | |
Einar Forselv | d7492e51f6 | |
Einar Forselv | 07a19f7f42 | |
Einar Forselv | 13d8e07a33 | |
Einar Forselv | 25b39b9908 | |
Einar Forselv | d0fdf2d1d3 | |
Einar Forselv | 3aa0704045 |
|
@ -0,0 +1,24 @@
|
||||||
|
# Read the Docs configuration file for Sphinx projects
|
||||||
|
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||||
|
|
||||||
|
# Required
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
# Set the OS, Python version and other tools you might need
|
||||||
|
build:
|
||||||
|
os: ubuntu-22.04
|
||||||
|
tools:
|
||||||
|
python: "3.10"
|
||||||
|
|
||||||
|
# Build documentation in the "docs/" directory with Sphinx
|
||||||
|
sphinx:
|
||||||
|
configuration: docs/conf.py
|
||||||
|
|
||||||
|
# Optionally build your docs in additional formats such as PDF and ePub
|
||||||
|
# formats:
|
||||||
|
# - pdf
|
||||||
|
# - epub
|
||||||
|
|
||||||
|
python:
|
||||||
|
install:
|
||||||
|
- requirements: docs/requirements.txt
|
|
@ -176,15 +176,20 @@ docker stack deploy -c swarm-stack.yml test
|
||||||
```
|
```
|
||||||
|
|
||||||
In dev we should ideally start the backup container manually
|
In dev we should ideally start the backup container manually
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose run --rm backup sh
|
docker-compose run --rm backup sh
|
||||||
# pip install the package in the container in editable mode to auto sync changes from host source
|
# pip install the package in the container in editable mode to auto sync changes from host source
|
||||||
pip3 install -e .
|
pip3 install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Remember to enable swarm mode with `docker swarm init/join` and disable swarm
|
||||||
|
mode with `docker swarm leave --force` when needed in development (single node setup).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome regardless of experience level. Don't hesitate submitting issues, opening partial or completed pull requests.
|
Contributions are welcome regardless of experience level.
|
||||||
|
Don't hesitate submitting issues, opening partial or completed pull requests.
|
||||||
|
|
||||||
[restic]: https://restic.net/
|
[restic]: https://restic.net/
|
||||||
[documentation]: https://restic-compose-backup.readthedocs.io
|
[documentation]: https://restic-compose-backup.readthedocs.io
|
||||||
|
|
|
@ -4,7 +4,7 @@ services:
|
||||||
build: ./src
|
build: ./src
|
||||||
env_file:
|
env_file:
|
||||||
- restic_compose_backup.env
|
- restic_compose_backup.env
|
||||||
- alerts.env
|
# - alerts.env
|
||||||
labels:
|
labels:
|
||||||
restic-compose-backup.volumes: true
|
restic-compose-backup.volumes: true
|
||||||
restic-compose-backup.volumes.include: 'src'
|
restic-compose-backup.volumes.include: 'src'
|
||||||
|
@ -32,7 +32,7 @@ services:
|
||||||
- SOME_VALUE=test
|
- SOME_VALUE=test
|
||||||
- ANOTHER_VALUE=1
|
- ANOTHER_VALUE=1
|
||||||
|
|
||||||
mysql:
|
mysql5:
|
||||||
image: mysql:5
|
image: mysql:5
|
||||||
labels:
|
labels:
|
||||||
restic-compose-backup.mysql: true
|
restic-compose-backup.mysql: true
|
||||||
|
@ -42,7 +42,19 @@ services:
|
||||||
- MYSQL_USER=myuser
|
- MYSQL_USER=myuser
|
||||||
- MYSQL_PASSWORD=mypassword
|
- MYSQL_PASSWORD=mypassword
|
||||||
volumes:
|
volumes:
|
||||||
- mysqldata:/var/lib/mysql
|
- mysqldata5:/var/lib/mysql
|
||||||
|
|
||||||
|
mysql8:
|
||||||
|
image: mysql:8
|
||||||
|
labels:
|
||||||
|
restic-compose-backup.mysql: true
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=my-secret-pw
|
||||||
|
- MYSQL_DATABASE=mydb
|
||||||
|
- MYSQL_USER=myuser
|
||||||
|
- MYSQL_PASSWORD=mypassword
|
||||||
|
volumes:
|
||||||
|
- mysqldata8:/var/lib/mysql
|
||||||
|
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:10
|
image: mariadb:10
|
||||||
|
@ -68,7 +80,8 @@ services:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysqldata:
|
mysqldata5:
|
||||||
|
mysqldata8:
|
||||||
mariadbdata:
|
mariadbdata:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ copyright = '2019, Zetta.IO Technology AS'
|
||||||
author = 'Zetta.IO Technology AS'
|
author = 'Zetta.IO Technology AS'
|
||||||
|
|
||||||
# The full version, including alpha/beta/rc tags
|
# The full version, including alpha/beta/rc tags
|
||||||
release = '0.5.0'
|
release = '0.6.0'
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -188,6 +188,28 @@ connecting to the Docker host. Combined with ``DOCKER_TLS_VERIFY``
|
||||||
this can be used to talk to docker through TLS in cases
|
this can be used to talk to docker through TLS in cases
|
||||||
were we cannot map in the docker socket.
|
were we cannot map in the docker socket.
|
||||||
|
|
||||||
|
INCLUDE_PROJECT_NAME
|
||||||
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Define this environment variable if your backup destination
|
||||||
|
paths needs project name as a prefix. This is useful
|
||||||
|
when running multiple projects.
|
||||||
|
|
||||||
|
EXCLUDE_BIND_MOUNTS
|
||||||
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Docker has to volumes types. Binds and volumes.
|
||||||
|
Volumes are docker volumes (``docker`volume list``).
|
||||||
|
Binds are paths mapped into the container from
|
||||||
|
the host for example in the ``volumes`` section
|
||||||
|
of a service.
|
||||||
|
|
||||||
|
If defined all host binds will be ignored globally.
|
||||||
|
This is useful when you only care about actual
|
||||||
|
docker volumes. Often host binds are only used
|
||||||
|
for mapping in configuration. This saves the user
|
||||||
|
from manually excluding these bind volumes.
|
||||||
|
|
||||||
SWARM_MODE
|
SWARM_MODE
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
{
|
||||||
|
"ID": "k5427pk4t7ss4d7ylacumeavz",
|
||||||
|
"Version": {
|
||||||
|
"Index": 30
|
||||||
|
},
|
||||||
|
"CreatedAt": "2020-03-08T17:25:59.451947759Z",
|
||||||
|
"UpdatedAt": "2020-03-08T17:26:38.552002711Z",
|
||||||
|
"Spec": {
|
||||||
|
"Labels": {},
|
||||||
|
"Role": "manager",
|
||||||
|
"Availability": "active"
|
||||||
|
},
|
||||||
|
"Description": {
|
||||||
|
"Hostname": "docker-desktop",
|
||||||
|
"Platform": {
|
||||||
|
"Architecture": "x86_64",
|
||||||
|
"OS": "linux"
|
||||||
|
},
|
||||||
|
"Resources": {
|
||||||
|
"NanoCPUs": 4000000000,
|
||||||
|
"MemoryBytes": 2085535744
|
||||||
|
},
|
||||||
|
"Engine": {
|
||||||
|
"EngineVersion": "19.03.5",
|
||||||
|
"Plugins": [{
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "awslogs"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "fluentd"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "gcplogs"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "gelf"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "journald"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "json-file"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "local"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "logentries"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "splunk"
|
||||||
|
}, {
|
||||||
|
"Type": "Log",
|
||||||
|
"Name": "syslog"
|
||||||
|
}, {
|
||||||
|
"Type": "Network",
|
||||||
|
"Name": "bridge"
|
||||||
|
}, {
|
||||||
|
"Type": "Network",
|
||||||
|
"Name": "host"
|
||||||
|
}, {
|
||||||
|
"Type": "Network",
|
||||||
|
"Name": "ipvlan"
|
||||||
|
}, {
|
||||||
|
"Type": "Network",
|
||||||
|
"Name": "macvlan"
|
||||||
|
}, {
|
||||||
|
"Type": "Network",
|
||||||
|
"Name": "null"
|
||||||
|
}, {
|
||||||
|
"Type": "Network",
|
||||||
|
"Name": "overlay"
|
||||||
|
}, {
|
||||||
|
"Type": "Volume",
|
||||||
|
"Name": "local"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"TLSInfo": {
|
||||||
|
"TrustRoot": "-----BEGIN CERTIFICATE-----\nMIIBazCCARCgAwIBAgIUfx7TP8c4SHCrwPPxjSFJQcfTP5QwCgYIKoZIzj0EAwIw\nEzERMA8GA1UEAxMIc3dhcm0tY2EwHhcNMjAwMzA4MTcyMTAwWhcNNDAwMzAzMTcy\nMTAwWjATMREwDwYDVQQDEwhzd2FybS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABGOa/9Rdd6qNc24wvuL/I9t5Vt3MJzlwC+WN0R6HrA4Ik1h2dmSRZTQqnCI7\nWh16y+PLaFwIfN0JkN4FrpnUBsyjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB\nAf8EBTADAQH/MB0GA1UdDgQWBBTAPDjHRwjQhNGUxqE3COHCOQrOkjAKBggqhkjO\nPQQDAgNJADBGAiEAxd/lPEKy3gt3nfZ8DX7kDaaNH8jSPgCBx3ejUs3SoaUCIQD3\nZ8dVxNvG4+Gvn28mDjWhTNLCn0BYW6JFE8eTI0xv4A==\n-----END CERTIFICATE-----\n",
|
||||||
|
"CertIssuerSubject": "MBMxETAPBgNVBAMTCHN3YXJtLWNh",
|
||||||
|
"CertIssuerPublicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY5r/1F13qo1zbjC+4v8j23lW3cwnOXAL5Y3RHoesDgiTWHZ2ZJFlNCqcIjtaHXrL48toXAh83QmQ3gWumdQGzA=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Status": {
|
||||||
|
"State": "ready",
|
||||||
|
"Addr": "192.168.65.3"
|
||||||
|
},
|
||||||
|
"ManagerStatus": {
|
||||||
|
"Leader": true,
|
||||||
|
"Reachability": "reachable",
|
||||||
|
"Addr": "192.168.65.3:2377"
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,8 +13,8 @@ When releasing a bugfix version we need to update the
|
||||||
main image as well.
|
main image as well.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build src --tag zettaio/restic-compose-backup:0.5
|
docker build src --tag zettaio/restic-compose-backup:0.6
|
||||||
docker build src --tag zettaio/restic-compose-backup:0.5.0
|
docker build src --tag zettaio/restic-compose-backup:0.6.0
|
||||||
|
|
||||||
docker push zettaio/restic-compose-backup:0.5
|
docker push zettaio/restic-compose-backup:0.5
|
||||||
docker push zettaio/restic-compose-backup:0.5.0
|
docker push zettaio/restic-compose-backup:0.5.0
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
# DOCKER_CERT_PATH=''
|
# DOCKER_CERT_PATH=''
|
||||||
|
|
||||||
SWARM_MODE=true
|
SWARM_MODE=true
|
||||||
|
INCLUDE_PROJECT_NAME=false
|
||||||
|
EXCLUDE_BIND_MOUNTS=false
|
||||||
|
|
||||||
RESTIC_REPOSITORY=/restic_data
|
RESTIC_REPOSITORY=/restic_data
|
||||||
RESTIC_PASSWORD=password
|
RESTIC_PASSWORD=password
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
FROM restic/restic:0.9.6
|
FROM restic/restic:0.9.6
|
||||||
|
|
||||||
RUN apk update && apk add python3 dcron mariadb-client postgresql-client
|
RUN apk update && apk add python3 \
|
||||||
|
dcron \
|
||||||
|
mariadb-client \
|
||||||
|
postgresql-client \
|
||||||
|
mariadb-connector-c-dev
|
||||||
|
|
||||||
ADD . /restic-compose-backup
|
ADD . /restic-compose-backup
|
||||||
WORKDIR /restic-compose-backup
|
WORKDIR /restic-compose-backup
|
||||||
RUN pip3 install -U pip setuptools && pip3 install -e .
|
RUN pip3 install -U pip setuptools wheel && pip3 install -e .
|
||||||
ENV XDG_CACHE_HOME=/cache
|
ENV XDG_CACHE_HOME=/cache
|
||||||
|
|
||||||
ENTRYPOINT []
|
ENTRYPOINT []
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Dump all env vars so we can source them in cron jobs
|
# Dump all env vars so we can source them in cron jobs
|
||||||
printenv | sed 's/^\(.*\)$/export \1/g' > /env.sh
|
rcb dump-env > /env.sh
|
||||||
|
|
||||||
# Write crontab
|
# Write crontab
|
||||||
rcb crontab > crontab
|
rcb crontab > crontab
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
__version__ = '0.5.0'
|
__version__ = '0.7.1'
|
||||||
|
|
|
@ -15,7 +15,7 @@ class SMTPAlert(BaseAlert):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.user = user
|
self.user = user
|
||||||
self.password = password
|
self.password = password or ""
|
||||||
self.to = to
|
self.to = to
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -34,7 +34,7 @@ class SMTPAlert(BaseAlert):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def properly_configured(self) -> bool:
|
def properly_configured(self) -> bool:
|
||||||
return self.host and self.port and self.user and self.password and len(self.to) > 0
|
return self.host and self.port and self.user and len(self.to) > 0
|
||||||
|
|
||||||
def send(self, subject: str = None, body: str = None, alert_type: str = 'INFO'):
|
def send(self, subject: str = None, body: str = None, alert_type: str = 'INFO'):
|
||||||
# send_mail("Hello world!")
|
# send_mail("Hello world!")
|
||||||
|
|
|
@ -51,12 +51,26 @@ def main():
|
||||||
elif args.action == "crontab":
|
elif args.action == "crontab":
|
||||||
crontab(config)
|
crontab(config)
|
||||||
|
|
||||||
|
elif args.action == "dump-env":
|
||||||
|
dump_env()
|
||||||
|
|
||||||
|
# Random test stuff here
|
||||||
|
elif args.action == "test":
|
||||||
|
nodes = utils.get_swarm_nodes()
|
||||||
|
print("Swarm nodes:")
|
||||||
|
for node in nodes:
|
||||||
|
addr = node.attrs['Status']['Addr']
|
||||||
|
state = node.attrs['Status']['State']
|
||||||
|
print(' - {} {} {}'.format(node.id, addr, state))
|
||||||
|
|
||||||
|
|
||||||
def status(config, containers):
|
def status(config, containers):
|
||||||
"""Outputs the backup config for the compose setup"""
|
"""Outputs the backup config for the compose setup"""
|
||||||
logger.info("Status for compose project '%s'", containers.project_name)
|
logger.info("Status for compose project '%s'", containers.project_name)
|
||||||
logger.info("Repository: '%s'", config.repository)
|
logger.info("Repository: '%s'", config.repository)
|
||||||
logger.info("Backup currently running?: %s", containers.backup_process_running)
|
logger.info("Backup currently running?: %s", containers.backup_process_running)
|
||||||
|
logger.info("Include project name in backup path?: %s", utils.is_true(config.include_project_name))
|
||||||
|
logger.debug("Exclude bind mounts from backups?: %s", utils.is_true(config.exclude_bind_mounts))
|
||||||
logger.info("Checking docker availability")
|
logger.info("Checking docker availability")
|
||||||
|
|
||||||
utils.list_containers()
|
utils.list_containers()
|
||||||
|
@ -82,12 +96,21 @@ def status(config, containers):
|
||||||
|
|
||||||
if container.volume_backup_enabled:
|
if container.volume_backup_enabled:
|
||||||
for mount in container.filter_mounts():
|
for mount in container.filter_mounts():
|
||||||
logger.info(' - volume: %s', mount.source)
|
logger.info(
|
||||||
|
' - volume: %s -> %s',
|
||||||
|
mount.source,
|
||||||
|
container.get_volume_backup_destination(mount, '/volumes'),
|
||||||
|
)
|
||||||
|
|
||||||
if container.database_backup_enabled:
|
if container.database_backup_enabled:
|
||||||
instance = container.instance
|
instance = container.instance
|
||||||
ping = instance.ping()
|
ping = instance.ping()
|
||||||
logger.info(' - %s (is_ready=%s)', instance.container_type, ping == 0)
|
logger.info(
|
||||||
|
' - %s (is_ready=%s) -> %s',
|
||||||
|
instance.container_type,
|
||||||
|
ping == 0,
|
||||||
|
instance.backup_destination_path(),
|
||||||
|
)
|
||||||
if ping != 0:
|
if ping != 0:
|
||||||
logger.error("Database '%s' in service %s cannot be reached",
|
logger.error("Database '%s' in service %s cannot be reached",
|
||||||
instance.container_type, container.service_name)
|
instance.container_type, container.service_name)
|
||||||
|
@ -270,6 +293,14 @@ def crontab(config):
|
||||||
print(cron.generate_crontab(config))
|
print(cron.generate_crontab(config))
|
||||||
|
|
||||||
|
|
||||||
|
def dump_env():
|
||||||
|
"""Dump all environment variables to a script that can be sourced from cron"""
|
||||||
|
print("#!/bin/bash")
|
||||||
|
print("# This file was generated by restic-compose-backup")
|
||||||
|
for key, value in os.environ.items():
|
||||||
|
print("export {}='{}'".format(key, value))
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = argparse.ArgumentParser(prog='restic_compose_backup')
|
parser = argparse.ArgumentParser(prog='restic_compose_backup')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
@ -283,6 +314,8 @@ def parse_args():
|
||||||
'cleanup',
|
'cleanup',
|
||||||
'version',
|
'version',
|
||||||
'crontab',
|
'crontab',
|
||||||
|
'dump-env',
|
||||||
|
'test',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|
|
@ -13,6 +13,8 @@ class Config:
|
||||||
self.cron_schedule = os.environ.get('CRON_SCHEDULE') or self.default_crontab_schedule
|
self.cron_schedule = os.environ.get('CRON_SCHEDULE') or self.default_crontab_schedule
|
||||||
self.cron_command = os.environ.get('CRON_COMMAND') or self.default_backup_command
|
self.cron_command = os.environ.get('CRON_COMMAND') or self.default_backup_command
|
||||||
self.swarm_mode = os.environ.get('SWARM_MODE') or False
|
self.swarm_mode = os.environ.get('SWARM_MODE') or False
|
||||||
|
self.include_project_name = os.environ.get('INCLUDE_PROJECT_NAME') or False
|
||||||
|
self.exclude_bind_mounts = os.environ.get('EXCLUDE_BIND_MOUNTS') or False
|
||||||
|
|
||||||
# Log
|
# Log
|
||||||
self.log_level = os.environ.get('LOG_LEVEL')
|
self.log_level = os.environ.get('LOG_LEVEL')
|
||||||
|
|
|
@ -72,7 +72,8 @@ class Container:
|
||||||
@property
|
@property
|
||||||
def service_name(self) -> str:
|
def service_name(self) -> str:
|
||||||
"""Name of the container/service"""
|
"""Name of the container/service"""
|
||||||
return self.get_label('com.docker.compose.service', default='') or self.get_label('com.docker.swarm.service.name', default='')
|
return self.get_label('com.docker.compose.service', default='') or \
|
||||||
|
self.get_label('com.docker.swarm.service.name', default='')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def backup_process_label(self) -> str:
|
def backup_process_label(self) -> str:
|
||||||
|
@ -193,11 +194,15 @@ class Container:
|
||||||
"""Get all mounts for this container matching include/exclude filters"""
|
"""Get all mounts for this container matching include/exclude filters"""
|
||||||
filtered = []
|
filtered = []
|
||||||
|
|
||||||
|
# If exclude_bind_mounts is true, only volume mounts are kept in the list of mounts
|
||||||
|
exclude_bind_mounts = utils.is_true(config.exclude_bind_mounts)
|
||||||
|
mounts = list(filter(lambda m: not exclude_bind_mounts or m.type == "volume", self._mounts))
|
||||||
|
|
||||||
if not self.volume_backup_enabled:
|
if not self.volume_backup_enabled:
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
if self._include:
|
if self._include:
|
||||||
for mount in self._mounts:
|
for mount in mounts:
|
||||||
for pattern in self._include:
|
for pattern in self._include:
|
||||||
if pattern in mount.source:
|
if pattern in mount.source:
|
||||||
break
|
break
|
||||||
|
@ -207,14 +212,14 @@ class Container:
|
||||||
filtered.append(mount)
|
filtered.append(mount)
|
||||||
|
|
||||||
elif self._exclude:
|
elif self._exclude:
|
||||||
for mount in self._mounts:
|
for mount in mounts:
|
||||||
for pattern in self._exclude:
|
for pattern in self._exclude:
|
||||||
if pattern in mount.source:
|
if pattern in mount.source:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
filtered.append(mount)
|
filtered.append(mount)
|
||||||
else:
|
else:
|
||||||
return self._mounts
|
return mounts
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
@ -224,12 +229,26 @@ class Container:
|
||||||
volumes = {}
|
volumes = {}
|
||||||
for mount in mounts:
|
for mount in mounts:
|
||||||
volumes[mount.source] = {
|
volumes[mount.source] = {
|
||||||
'bind': str(Path(source_prefix) / self.service_name / Path(utils.strip_root(mount.destination))),
|
'bind': self.get_volume_backup_destination(mount, source_prefix),
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
return volumes
|
return volumes
|
||||||
|
|
||||||
|
def get_volume_backup_destination(self, mount, source_prefix) -> str:
|
||||||
|
"""Get the destination path for backups of the given mount"""
|
||||||
|
destination = Path(source_prefix)
|
||||||
|
|
||||||
|
if utils.is_true(config.include_project_name):
|
||||||
|
project_name = self.project_name
|
||||||
|
if project_name != '':
|
||||||
|
destination /= project_name
|
||||||
|
|
||||||
|
destination /= self.service_name
|
||||||
|
destination /= Path(utils.strip_root(mount.destination))
|
||||||
|
|
||||||
|
return str(destination)
|
||||||
|
|
||||||
def get_credentials(self) -> dict:
|
def get_credentials(self) -> dict:
|
||||||
"""dict: get credentials for the service"""
|
"""dict: get credentials for the service"""
|
||||||
raise NotImplementedError("Base container class don't implement this")
|
raise NotImplementedError("Base container class don't implement this")
|
||||||
|
@ -242,6 +261,10 @@ class Container:
|
||||||
"""Back up this service"""
|
"""Back up this service"""
|
||||||
raise NotImplementedError("Base container class don't implement this")
|
raise NotImplementedError("Base container class don't implement this")
|
||||||
|
|
||||||
|
def backup_destination_path(self) -> str:
|
||||||
|
"""Return the path backups will be saved at"""
|
||||||
|
raise NotImplementedError("Base container class don't implement this")
|
||||||
|
|
||||||
def dump_command(self) -> list:
|
def dump_command(self) -> list:
|
||||||
"""list: create a dump command restic and use to send data through stdin"""
|
"""list: create a dump command restic and use to send data through stdin"""
|
||||||
raise NotImplementedError("Base container class don't implement this")
|
raise NotImplementedError("Base container class don't implement this")
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from restic_compose_backup.containers import Container
|
from restic_compose_backup.containers import Container
|
||||||
from restic_compose_backup.config import Config
|
from restic_compose_backup.config import config, Config
|
||||||
from restic_compose_backup import (
|
from restic_compose_backup import (
|
||||||
commands,
|
commands,
|
||||||
restic,
|
restic,
|
||||||
|
@ -39,6 +41,7 @@ class MariadbContainer(Container):
|
||||||
f"--port={creds['port']}",
|
f"--port={creds['port']}",
|
||||||
f"--user={creds['username']}",
|
f"--user={creds['username']}",
|
||||||
"--all-databases",
|
"--all-databases",
|
||||||
|
"--no-tablespaces",
|
||||||
]
|
]
|
||||||
|
|
||||||
def backup(self):
|
def backup(self):
|
||||||
|
@ -48,10 +51,23 @@ class MariadbContainer(Container):
|
||||||
with utils.environment('MYSQL_PWD', creds['password']):
|
with utils.environment('MYSQL_PWD', creds['password']):
|
||||||
return restic.backup_from_stdin(
|
return restic.backup_from_stdin(
|
||||||
config.repository,
|
config.repository,
|
||||||
f'/databases/{self.service_name}/all_databases.sql',
|
self.backup_destination_path(),
|
||||||
self.dump_command(),
|
self.dump_command(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def backup_destination_path(self) -> str:
|
||||||
|
destination = Path("/databases")
|
||||||
|
|
||||||
|
if utils.is_true(config.include_project_name):
|
||||||
|
project_name = self.project_name
|
||||||
|
if project_name != "":
|
||||||
|
destination /= project_name
|
||||||
|
|
||||||
|
destination /= self.service_name
|
||||||
|
destination /= "all_databases.sql"
|
||||||
|
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
class MysqlContainer(Container):
|
class MysqlContainer(Container):
|
||||||
container_type = 'mysql'
|
container_type = 'mysql'
|
||||||
|
@ -85,6 +101,7 @@ class MysqlContainer(Container):
|
||||||
f"--port={creds['port']}",
|
f"--port={creds['port']}",
|
||||||
f"--user={creds['username']}",
|
f"--user={creds['username']}",
|
||||||
"--all-databases",
|
"--all-databases",
|
||||||
|
"--no-tablespaces",
|
||||||
]
|
]
|
||||||
|
|
||||||
def backup(self):
|
def backup(self):
|
||||||
|
@ -94,10 +111,23 @@ class MysqlContainer(Container):
|
||||||
with utils.environment('MYSQL_PWD', creds['password']):
|
with utils.environment('MYSQL_PWD', creds['password']):
|
||||||
return restic.backup_from_stdin(
|
return restic.backup_from_stdin(
|
||||||
config.repository,
|
config.repository,
|
||||||
f'/databases/{self.service_name}/all_databases.sql',
|
self.backup_destination_path(),
|
||||||
self.dump_command(),
|
self.dump_command(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def backup_destination_path(self) -> str:
|
||||||
|
destination = Path("/databases")
|
||||||
|
|
||||||
|
if utils.is_true(config.include_project_name):
|
||||||
|
project_name = self.project_name
|
||||||
|
if project_name != "":
|
||||||
|
destination /= project_name
|
||||||
|
|
||||||
|
destination /= self.service_name
|
||||||
|
destination /= "all_databases.sql"
|
||||||
|
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
class PostgresContainer(Container):
|
class PostgresContainer(Container):
|
||||||
container_type = 'postgres'
|
container_type = 'postgres'
|
||||||
|
@ -141,6 +171,19 @@ class PostgresContainer(Container):
|
||||||
with utils.environment('PGPASSWORD', creds['password']):
|
with utils.environment('PGPASSWORD', creds['password']):
|
||||||
return restic.backup_from_stdin(
|
return restic.backup_from_stdin(
|
||||||
config.repository,
|
config.repository,
|
||||||
f"/databases/{self.service_name}/{creds['database']}.sql",
|
self.backup_destination_path(),
|
||||||
self.dump_command(),
|
self.dump_command(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def backup_destination_path(self) -> str:
|
||||||
|
destination = Path("/databases")
|
||||||
|
|
||||||
|
if utils.is_true(config.include_project_name):
|
||||||
|
project_name = self.project_name
|
||||||
|
if project_name != "":
|
||||||
|
destination /= project_name
|
||||||
|
|
||||||
|
destination /= self.service_name
|
||||||
|
destination /= f"{self.get_credentials()['database']}.sql"
|
||||||
|
|
||||||
|
return destination
|
||||||
|
|
|
@ -22,7 +22,7 @@ def setup(level: str = 'warning'):
|
||||||
|
|
||||||
ch = logging.StreamHandler(stream=sys.stdout)
|
ch = logging.StreamHandler(stream=sys.stdout)
|
||||||
ch.setLevel(level)
|
ch.setLevel(level)
|
||||||
# ch.setFormatter(logging.Formatter(f'%(asctime)s - {HOSTNAME} - %(name)s - %(levelname)s - %(message)s'))
|
# ch.setFormatter(logging.Formatter('%(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('%(asctime)s - {HOSTNAME} - %(levelname)s - %(message)s'))
|
||||||
ch.setFormatter(logging.Formatter(f'%(asctime)s - %(levelname)s: %(message)s'))
|
ch.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s: %(message)s'))
|
||||||
logger.addHandler(ch)
|
logger.addHandler(ch)
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List, TYPE_CHECKING
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import docker
|
import docker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from restic_compose_backup.containers import Container
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TRUE_VALUES = ['1', 'true', 'True', True, 1]
|
TRUE_VALUES = ['1', 'true', 'True', True, 1]
|
||||||
|
@ -37,6 +40,18 @@ def list_containers() -> List[dict]:
|
||||||
return [c.attrs for c in all_containers]
|
return [c.attrs for c in all_containers]
|
||||||
|
|
||||||
|
|
||||||
|
def get_swarm_nodes():
|
||||||
|
client = docker_client()
|
||||||
|
# NOTE: If not a swarm node docker.errors.APIError is raised
|
||||||
|
# 503 Server Error: Service Unavailable
|
||||||
|
# ("This node is not a swarm manager. Use "docker swarm init" or
|
||||||
|
# "docker swarm join" to connect this node to swarm and try again.")
|
||||||
|
try:
|
||||||
|
return client.nodes.list()
|
||||||
|
except docker.errors.APIError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def remove_containers(containers: List['Container']):
|
def remove_containers(containers: List['Container']):
|
||||||
client = docker_client()
|
client = docker_client()
|
||||||
logger.info('Attempting to delete stale backup process containers')
|
logger.info('Attempting to delete stale backup process containers')
|
||||||
|
|
|
@ -3,12 +3,15 @@ from setuptools import setup, find_namespace_packages
|
||||||
setup(
|
setup(
|
||||||
name="restic-compose-backup",
|
name="restic-compose-backup",
|
||||||
url="https://github.com/ZettaIO/restic-compose-backup",
|
url="https://github.com/ZettaIO/restic-compose-backup",
|
||||||
version="0.5.0",
|
version="0.7.1",
|
||||||
author="Einar Forselv",
|
author="Einar Forselv",
|
||||||
author_email="eforselv@gmail.com",
|
author_email="eforselv@gmail.com",
|
||||||
packages=find_namespace_packages(include=['restic_compose_backup']),
|
packages=find_namespace_packages(include=[
|
||||||
|
'restic_compose_backup',
|
||||||
|
'restic_compose_backup.*',
|
||||||
|
]),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'docker==4.1.*',
|
'docker~=6.1.3',
|
||||||
],
|
],
|
||||||
entry_points={'console_scripts': [
|
entry_points={'console_scripts': [
|
||||||
'restic-compose-backup = restic_compose_backup.cli:main',
|
'restic-compose-backup = restic_compose_backup.cli:main',
|
||||||
|
|
|
@ -3,6 +3,9 @@ import os
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
os.environ['RESTIC_REPOSITORY'] = "test"
|
||||||
|
os.environ['RESTIC_PASSWORD'] = "password"
|
||||||
|
|
||||||
from restic_compose_backup import utils
|
from restic_compose_backup import utils
|
||||||
from restic_compose_backup.containers import RunningContainers
|
from restic_compose_backup.containers import RunningContainers
|
||||||
import fixtures
|
import fixtures
|
||||||
|
@ -15,8 +18,8 @@ class ResticBackupTests(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
"""Set up basic environment variables"""
|
"""Set up basic environment variables"""
|
||||||
os.environ['RESTIC_REPOSITORY'] = "test"
|
# os.environ['RESTIC_REPOSITORY'] = "test"
|
||||||
os.environ['RESTIC_PASSWORD'] = "password"
|
# os.environ['RESTIC_PASSWORD'] = "password"
|
||||||
|
|
||||||
def createContainers(self):
|
def createContainers(self):
|
||||||
backup_hash = fixtures.generate_sha256()
|
backup_hash = fixtures.generate_sha256()
|
||||||
|
|
|
@ -14,9 +14,16 @@ services:
|
||||||
- global
|
- global
|
||||||
volumes:
|
volumes:
|
||||||
- mariadbdata:/var/lib/mysql
|
- mariadbdata:/var/lib/mysql
|
||||||
|
files:
|
||||||
|
image: nginx:1.17-alpine
|
||||||
|
labels:
|
||||||
|
restic-compose-backup.volumes: "true"
|
||||||
|
volumes:
|
||||||
|
- files:/srv/files
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadbdata:
|
mariadbdata:
|
||||||
|
files:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
global:
|
global:
|
||||||
|
|
8
tox.ini
8
tox.ini
|
@ -5,13 +5,13 @@
|
||||||
skipsdist = True
|
skipsdist = True
|
||||||
setupdir={toxinidir}/src
|
setupdir={toxinidir}/src
|
||||||
envlist =
|
envlist =
|
||||||
py37
|
py38
|
||||||
pep8
|
pep8
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
usedevelop = True
|
usedevelop = True
|
||||||
basepython =
|
basepython =
|
||||||
py37: python3.7
|
py38: python3.8
|
||||||
|
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/src//tests/requirements.txt
|
-r{toxinidir}/src//tests/requirements.txt
|
||||||
|
@ -23,7 +23,7 @@ commands =
|
||||||
[testenv:pep8]
|
[testenv:pep8]
|
||||||
usedevelop = false
|
usedevelop = false
|
||||||
deps = flake8
|
deps = flake8
|
||||||
basepython = python3.7
|
basepython = python3.8
|
||||||
commands = flake8
|
commands = flake8
|
||||||
|
|
||||||
[pytest]
|
[pytest]
|
||||||
|
@ -53,4 +53,4 @@ norecursedirs = tests/* .venv/* .tox/* build/ docs/
|
||||||
ignore = H405,D100,D101,D102,D103,D104,D105,D200,D202,D203,D204,D205,D211,D301,D400,D401,W503
|
ignore = H405,D100,D101,D102,D103,D104,D105,D200,D202,D203,D204,D205,D211,D301,D400,D401,W503
|
||||||
show-source = True
|
show-source = True
|
||||||
max-line-length = 120
|
max-line-length = 120
|
||||||
exclude = .tox,.venv*,tests,build,conf.py
|
exclude = .tox,env,tests,build,conf.py
|
||||||
|
|
Loading…
Reference in New Issue