mirror of
https://github.com/ZettaIO/restic-compose-backup.git
synced 2025-10-10 04:10:57 +00:00
Compare commits
47 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
23649bc86e | ||
|
a9619e1cc8 | ||
|
70afcd2c6a | ||
|
01ae6ee0bf | ||
|
516117f634 | ||
|
04bf13ecc4 | ||
|
dbf238c5a9 | ||
|
7294d85c09 | ||
|
3662d4ed9a | ||
|
9892903e97 | ||
|
060438c1c3 | ||
|
5f6b1cd7a3 | ||
|
47d74a2ef7 | ||
|
11fdffb719 | ||
|
be3b3668bc | ||
|
b52655a23b | ||
|
323e299b7e | ||
|
5c33ccf0b1 | ||
|
764aac6744 | ||
|
bbe57dfd69 | ||
|
4517880846 | ||
|
1fefd63c72 | ||
|
4e1af219e2 | ||
|
93f080d5b3 | ||
|
0fc620bb1f | ||
|
f7958d7db9 | ||
|
18ddb173ac | ||
|
f59a046bbc | ||
|
8b934dc12f | ||
|
8e9105fed5 | ||
|
d7492e51f6 | ||
|
07a19f7f42 | ||
|
13d8e07a33 | ||
|
25b39b9908 | ||
|
d0fdf2d1d3 | ||
|
3aa0704045 | ||
|
cf668e2153 | ||
|
d4c77cf43d | ||
|
cecc647a10 | ||
|
61ec487e24 | ||
|
0bab85f5cf | ||
|
1a100d73ab | ||
|
270137d931 | ||
|
e4263822bf | ||
|
311bedb5ab | ||
|
88cf894689 | ||
|
6817f0999f |
27
README.md
27
README.md
@@ -162,9 +162,34 @@ pip install -r docs/requirements.txt
|
||||
python src/setup.py build_sphinx
|
||||
```
|
||||
|
||||
# Local dev setup
|
||||
|
||||
The git repository contains a simple local setup for development
|
||||
|
||||
```bash
|
||||
# Create an overlay network to link the compose project and stack
|
||||
docker network create --driver overlay --attachable global
|
||||
# Start the compose project
|
||||
docker-compose up -d
|
||||
# Deploy the stack
|
||||
docker stack deploy -c swarm-stack.yml test
|
||||
```
|
||||
|
||||
In dev we should ideally start the backup container manually
|
||||
|
||||
```bash
|
||||
docker-compose run --rm backup sh
|
||||
# pip install the package in the container in editable mode to auto sync changes from host source
|
||||
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
|
||||
|
||||
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/
|
||||
[documentation]: https://restic-compose-backup.readthedocs.io
|
||||
|
55
TODO.md
Normal file
55
TODO.md
Normal file
@@ -0,0 +1,55 @@
|
||||
|
||||
# TODO for 1.0
|
||||
|
||||
Upgrade restic to 15.x
|
||||
|
||||
* Make backup types generic with some standard protocol
|
||||
- New backup types can be registered
|
||||
- When a backup is started we invoke methods in the specific backend
|
||||
- The backend should have access to all information about containers
|
||||
- The backend should be able to run the command in its own container or the target container
|
||||
* Don't fetch all containers for all commands. Some commands are just alerts and restic only related
|
||||
* More detailed cron setup separating backup time, purge time etc
|
||||
* Support mariadb
|
||||
* Support influxdb
|
||||
* Support backup priority (restic-compose-backup.before-backup.priority=1)
|
||||
* Look at bug fixes in forks
|
||||
* Use shorter label names. `rcb.priority` instead of `restic-compose-backup.before-backup.priority`
|
||||
* Support simple commands in labels
|
||||
|
||||
|
||||
## Other misc
|
||||
|
||||
* restic unlock needed in some cases?
|
||||
* Each snapshot in restic could be tagged with the service name
|
||||
|
||||
|
||||
* Is there some elegant way to support a restore?
|
||||
* Possibly back up volumes in different snapshots?
|
||||
|
||||
|
||||
Use generators in some way to chain actions?
|
||||
Action -> Some command
|
||||
Use global logger
|
||||
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Testing
|
||||
|
||||
docker run -it --entrypoint sh --rm restic/restic:0.15.1
|
||||
|
||||
Will install python 3.10
|
||||
|
||||
apk add --no-cache python3 py3-pip dcron
|
||||
|
||||
## Changelog
|
||||
|
||||
* Upgrade restic to 0.15.1
|
||||
* Upgraded to python docker 6.0.x
|
||||
|
||||
## Misc
|
||||
|
||||
* Run rcb command
|
||||
* (Optional) Collect docker info
|
||||
* (Optional) Issue restic command
|
@@ -1,13 +1,16 @@
|
||||
version: '3'
|
||||
version: '3.7'
|
||||
services:
|
||||
backup:
|
||||
build: ./src
|
||||
env_file:
|
||||
- restic_compose_backup.env
|
||||
- alerts.env
|
||||
# - alerts.env
|
||||
labels:
|
||||
restic-compose-backup.volumes: true
|
||||
restic-compose-backup.volumes.include: 'src'
|
||||
# networks:
|
||||
# - default
|
||||
# - global
|
||||
volumes:
|
||||
# Map in docker socket
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
@@ -64,7 +67,16 @@ services:
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
# InfluxDB OSS
|
||||
# influxdb:
|
||||
# image: influxdb:1.8
|
||||
|
||||
volumes:
|
||||
mysqldata:
|
||||
mariadbdata:
|
||||
pgdata:
|
||||
# influx_data:
|
||||
|
||||
# networks:
|
||||
# global:
|
||||
# external: true
|
||||
|
@@ -22,7 +22,7 @@ copyright = '2019, Zetta.IO Technology AS'
|
||||
author = 'Zetta.IO Technology AS'
|
||||
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = '0.4.2'
|
||||
release = '0.6.0'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
|
@@ -188,6 +188,33 @@ connecting to the Docker host. Combined with ``DOCKER_TLS_VERIFY``
|
||||
this can be used to talk to docker through TLS in cases
|
||||
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
|
||||
~~~~~~~~~~
|
||||
|
||||
If defined containers in swarm stacks are also evaluated.
|
||||
|
||||
Compose Labels
|
||||
--------------
|
||||
|
||||
|
196
extras/example-conpose-container.json
Normal file
196
extras/example-conpose-container.json
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"Id": "efa5196b4959648e3efcf5ae9f24bc4032849c2665805a5b405216f343b4decd",
|
||||
"Created": "2020-03-05T21:07:34.88927951Z",
|
||||
"Path": "docker-entrypoint.sh",
|
||||
"Args": ["mysqld"],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 4887,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2020-03-06T01:31:39.728842925Z",
|
||||
"FinishedAt": "2020-03-06T01:31:33.847583199Z"
|
||||
},
|
||||
"Image": "sha256:1fd0e719c4952e22a99e30662fdd7daad53e7e53fbe135d543cc6b82be213951",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/efa5196b4959648e3efcf5ae9f24bc4032849c2665805a5b405216f343b4decd/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/efa5196b4959648e3efcf5ae9f24bc4032849c2665805a5b405216f343b4decd/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/efa5196b4959648e3efcf5ae9f24bc4032849c2665805a5b405216f343b4decd/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/efa5196b4959648e3efcf5ae9f24bc4032849c2665805a5b405216f343b4decd/efa5196b4959648e3efcf5ae9f24bc4032849c2665805a5b405216f343b4decd-json.log",
|
||||
"Name": "/restic-compose-backup_mariadb_1",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": ["restic-compose-backup_mariadbdata:/var/lib/mysql:rw"],
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "restic-compose-backup_default",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": [],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"Capabilities": null,
|
||||
"Dns": null,
|
||||
"DnsOptions": null,
|
||||
"DnsSearch": null,
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "shareable",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 0,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"ConsoleSize": [0, 0],
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": null,
|
||||
"BlkioDeviceReadBps": null,
|
||||
"BlkioDeviceWriteBps": null,
|
||||
"BlkioDeviceReadIOps": null,
|
||||
"BlkioDeviceWriteIOps": null,
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": null,
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"KernelMemory": 0,
|
||||
"KernelMemoryTCP": 0,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": false,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": null,
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": ["/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware"],
|
||||
"ReadonlyPaths": ["/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger"]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"LowerDir": "/var/lib/docker/overlay2/96e51e6162c0cb4385248375192ec777dd42b3ae7973e402de351f3932c502d0-init/diff:/var/lib/docker/overlay2/38780a41f93b7a20de03f1d76febb885f9213906fb30bad17cb3ad231fb7ce43/diff:/var/lib/docker/overlay2/a2abce521690b1baf6aa61e109a4659cb4272936871bc1afa73271eb8e453449/diff:/var/lib/docker/overlay2/a696286588d1d33b994b7f6e31c176c5f7e67c4f757d730323a7b6591d55f786/diff:/var/lib/docker/overlay2/c4bd8133c0d9547945d38a9998439082ce7b53df7e64737add5a5c824e6f67f2/diff:/var/lib/docker/overlay2/110e275ef21b8c9cc2cd0cce312fed5aabceb056460f637b958dfee56b7b3be8/diff:/var/lib/docker/overlay2/831c8a624e424f298766028e76a8ac08df0c5cf4564f63cae61330a8bce0cf63/diff:/var/lib/docker/overlay2/7ad8ae774951ec40c68b0993ef07ef3d70aa8aed44ea9f1e4d943ca5404cc717/diff:/var/lib/docker/overlay2/19bca9fb61ef1156f8a97313c126a6c06d7fe44a6c49e3affe16f50f2d5e56ff/diff:/var/lib/docker/overlay2/dcd4dda04d06b0a0c7e78517c6209fd67735b3027afda2c85a92de37ff7297d1/diff:/var/lib/docker/overlay2/babf41f5fe1f7b88c17cfce27214a4ad9473b0f8e0f118db948d2acddf4d4798/diff:/var/lib/docker/overlay2/b5f97865010acd5b04b4031d6223cd0b34fab89267891d61256ea16936be52f8/diff:/var/lib/docker/overlay2/6aba0159141ebb6d6783181d154c65046447b7d2bebce65d44c4939ba7943cca/diff:/var/lib/docker/overlay2/c71c34fe0e7e95409a9fc18698f0aee505940fd96aa3718836e2d89f3cfb2d49/diff:/var/lib/docker/overlay2/3be993436e2a6764a6c3c57a2e948f7a57e45ed0ec26cdd3366f4c1106c69869/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/96e51e6162c0cb4385248375192ec777dd42b3ae7973e402de351f3932c502d0/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/96e51e6162c0cb4385248375192ec777dd42b3ae7973e402de351f3932c502d0/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/96e51e6162c0cb4385248375192ec777dd42b3ae7973e402de351f3932c502d0/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [{
|
||||
"Type": "volume",
|
||||
"Name": "restic-compose-backup_mariadbdata",
|
||||
"Source": "/var/lib/docker/volumes/restic-compose-backup_mariadbdata/_data",
|
||||
"Destination": "/var/lib/mysql",
|
||||
"Driver": "local",
|
||||
"Mode": "rw",
|
||||
"RW": true,
|
||||
"Propagation": ""
|
||||
}],
|
||||
"Config": {
|
||||
"Hostname": "efa5196b4959",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"ExposedPorts": {
|
||||
"3306/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": ["MYSQL_ROOT_PASSWORD=my-secret-pw", "MYSQL_DATABASE=mydb", "MYSQL_USER=myuser", "MYSQL_PASSWORD=mypassword", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "GOSU_VERSION=1.10", "GPG_KEYS=177F4010FE56CA3336300305F1656F24C74CD1D8", "MARIADB_MAJOR=10.4", "MARIADB_VERSION=1:10.4.12+maria~bionic"],
|
||||
"Cmd": ["mysqld"],
|
||||
"Image": "mariadb:10",
|
||||
"Volumes": {
|
||||
"/var/lib/mysql": {}
|
||||
},
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": ["docker-entrypoint.sh"],
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "c6ecde85ad111d324a4c97cde3a03898074b026c68ecffc0f7020e5eca9a71d7",
|
||||
"com.docker.compose.container-number": "1",
|
||||
"com.docker.compose.oneoff": "False",
|
||||
"com.docker.compose.project": "restic-compose-backup",
|
||||
"com.docker.compose.project.config_files": "docker-compose.yaml",
|
||||
"com.docker.compose.project.working_dir": "C:\\Users\\efors\\projects\\zetta.io\\projects\\restic-compose-backup",
|
||||
"com.docker.compose.service": "mariadb",
|
||||
"com.docker.compose.version": "1.25.4",
|
||||
"restic-compose-backup.mariadb": "True"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "d462bb5dfdd26aba12b8a395ac90262ab00d65408bf60dfa1ade0ab6a1851c70",
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"Ports": {
|
||||
"3306/tcp": null
|
||||
},
|
||||
"SandboxKey": "/var/run/docker/netns/d462bb5dfdd2",
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "",
|
||||
"Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "",
|
||||
"IPPrefixLen": 0,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "",
|
||||
"Networks": {
|
||||
"restic-compose-backup_default": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": ["efa5196b4959", "mariadb"],
|
||||
"NetworkID": "8f3349b0debec88f9f48fff02d84cda3feae0a0e8c516e8b42e5777bb03db1cb",
|
||||
"EndpointID": "0b75d3f00aa077fe95156bc80463d33fb21d241a287b33c06769047855c38400",
|
||||
"Gateway": "172.19.0.1",
|
||||
"IPAddress": "172.19.0.3",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"MacAddress": "02:42:ac:13:00:03",
|
||||
"DriverOpts": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
207
extras/example-stack-container.json
Normal file
207
extras/example-stack-container.json
Normal file
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"Id": "56c57903b6da3afd331312b244ddd0324f5b21cbbe5fc30072edf24781d80f76",
|
||||
"Created": "2020-03-06T22:36:17.266061631Z",
|
||||
"Path": "docker-entrypoint.sh",
|
||||
"Args": ["mysqld"],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 35967,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2020-03-06T22:36:17.636265528Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:1fd0e719c4952e22a99e30662fdd7daad53e7e53fbe135d543cc6b82be213951",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/56c57903b6da3afd331312b244ddd0324f5b21cbbe5fc30072edf24781d80f76/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/56c57903b6da3afd331312b244ddd0324f5b21cbbe5fc30072edf24781d80f76/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/56c57903b6da3afd331312b244ddd0324f5b21cbbe5fc30072edf24781d80f76/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/56c57903b6da3afd331312b244ddd0324f5b21cbbe5fc30072edf24781d80f76/56c57903b6da3afd331312b244ddd0324f5b21cbbe5fc30072edf24781d80f76-json.log",
|
||||
"Name": "/test_mariadb.1.q4uji32qvw4tkuvwx3pbnbgqq",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "default",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"Capabilities": null,
|
||||
"Dns": null,
|
||||
"DnsOptions": null,
|
||||
"DnsSearch": null,
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 0,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"ConsoleSize": [0, 0],
|
||||
"Isolation": "default",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": null,
|
||||
"BlkioDeviceReadBps": null,
|
||||
"BlkioDeviceWriteBps": null,
|
||||
"BlkioDeviceReadIOps": null,
|
||||
"BlkioDeviceWriteIOps": null,
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": null,
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"KernelMemory": 0,
|
||||
"KernelMemoryTCP": 0,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": false,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": null,
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"Mounts": [{
|
||||
"Type": "volume",
|
||||
"Source": "test_mariadbdata",
|
||||
"Target": "/var/lib/mysql",
|
||||
"VolumeOptions": {
|
||||
"Labels": {
|
||||
"com.docker.stack.namespace": "test"
|
||||
}
|
||||
}
|
||||
}],
|
||||
"MaskedPaths": ["/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware"],
|
||||
"ReadonlyPaths": ["/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger"]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"LowerDir": "/var/lib/docker/overlay2/ba8a39bdb1d2e25d373b6b00c764be3d37353e57cf03981c4c3e5a20ae6a602b-init/diff:/var/lib/docker/overlay2/38780a41f93b7a20de03f1d76febb885f9213906fb30bad17cb3ad231fb7ce43/diff:/var/lib/docker/overlay2/a2abce521690b1baf6aa61e109a4659cb4272936871bc1afa73271eb8e453449/diff:/var/lib/docker/overlay2/a696286588d1d33b994b7f6e31c176c5f7e67c4f757d730323a7b6591d55f786/diff:/var/lib/docker/overlay2/c4bd8133c0d9547945d38a9998439082ce7b53df7e64737add5a5c824e6f67f2/diff:/var/lib/docker/overlay2/110e275ef21b8c9cc2cd0cce312fed5aabceb056460f637b958dfee56b7b3be8/diff:/var/lib/docker/overlay2/831c8a624e424f298766028e76a8ac08df0c5cf4564f63cae61330a8bce0cf63/diff:/var/lib/docker/overlay2/7ad8ae774951ec40c68b0993ef07ef3d70aa8aed44ea9f1e4d943ca5404cc717/diff:/var/lib/docker/overlay2/19bca9fb61ef1156f8a97313c126a6c06d7fe44a6c49e3affe16f50f2d5e56ff/diff:/var/lib/docker/overlay2/dcd4dda04d06b0a0c7e78517c6209fd67735b3027afda2c85a92de37ff7297d1/diff:/var/lib/docker/overlay2/babf41f5fe1f7b88c17cfce27214a4ad9473b0f8e0f118db948d2acddf4d4798/diff:/var/lib/docker/overlay2/b5f97865010acd5b04b4031d6223cd0b34fab89267891d61256ea16936be52f8/diff:/var/lib/docker/overlay2/6aba0159141ebb6d6783181d154c65046447b7d2bebce65d44c4939ba7943cca/diff:/var/lib/docker/overlay2/c71c34fe0e7e95409a9fc18698f0aee505940fd96aa3718836e2d89f3cfb2d49/diff:/var/lib/docker/overlay2/3be993436e2a6764a6c3c57a2e948f7a57e45ed0ec26cdd3366f4c1106c69869/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/ba8a39bdb1d2e25d373b6b00c764be3d37353e57cf03981c4c3e5a20ae6a602b/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/ba8a39bdb1d2e25d373b6b00c764be3d37353e57cf03981c4c3e5a20ae6a602b/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/ba8a39bdb1d2e25d373b6b00c764be3d37353e57cf03981c4c3e5a20ae6a602b/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [{
|
||||
"Type": "volume",
|
||||
"Name": "test_mariadbdata",
|
||||
"Source": "/var/lib/docker/volumes/test_mariadbdata/_data",
|
||||
"Destination": "/var/lib/mysql",
|
||||
"Driver": "local",
|
||||
"Mode": "z",
|
||||
"RW": true,
|
||||
"Propagation": ""
|
||||
}],
|
||||
"Config": {
|
||||
"Hostname": "56c57903b6da",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"ExposedPorts": {
|
||||
"3306/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": ["MYSQL_DATABASE=mydb", "MYSQL_PASSWORD=mypassword", "MYSQL_ROOT_PASSWORD=my-secret-pw", "MYSQL_USER=myuser", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "GOSU_VERSION=1.10", "GPG_KEYS=177F4010FE56CA3336300305F1656F24C74CD1D8", "MARIADB_MAJOR=10.4", "MARIADB_VERSION=1:10.4.12+maria~bionic"],
|
||||
"Cmd": ["mysqld"],
|
||||
"Image": "mariadb:10@sha256:d1ceee944c90ee3b596266de1b0ac25d2f34adbe9c35156b75bcb9a7047c7545",
|
||||
"Volumes": {
|
||||
"/var/lib/mysql": {}
|
||||
},
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": ["docker-entrypoint.sh"],
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"com.docker.stack.namespace": "test",
|
||||
"com.docker.swarm.node.id": "gj73oe0vgmldlv2pdcj243231",
|
||||
"com.docker.swarm.service.id": "jewh88xvythjkga24wy1thxc2",
|
||||
"com.docker.swarm.service.name": "test_mariadb",
|
||||
"com.docker.swarm.task": "",
|
||||
"com.docker.swarm.task.id": "q4uji32qvw4tkuvwx3pbnbgqq",
|
||||
"com.docker.swarm.task.name": "test_mariadb.1.q4uji32qvw4tkuvwx3pbnbgqq",
|
||||
"restic-compose-backup.mariadb": "true"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "5aa81b0859dfd6f6be629eb966ce365f22dc86620359cce3e3d25d5291b539db",
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"Ports": {
|
||||
"3306/tcp": null
|
||||
},
|
||||
"SandboxKey": "/var/run/docker/netns/5aa81b0859df",
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "",
|
||||
"Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "",
|
||||
"IPPrefixLen": 0,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "",
|
||||
"Networks": {
|
||||
"test_default": {
|
||||
"IPAMConfig": {
|
||||
"IPv4Address": "10.0.1.3"
|
||||
},
|
||||
"Links": null,
|
||||
"Aliases": ["56c57903b6da"],
|
||||
"NetworkID": "8aweh54u31eq3i47vqdr2aonc",
|
||||
"EndpointID": "5369b4c82a479a3e9dfb3547cb7ac3a0fab888e38ad5c1d0ad02b0e9a9523a64",
|
||||
"Gateway": "",
|
||||
"IPAddress": "10.0.1.3",
|
||||
"IPPrefixLen": 24,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"MacAddress": "02:42:0a:00:01:03",
|
||||
"DriverOpts": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
93
extras/example_swarm_nodes.json
Normal file
93
extras/example_swarm_nodes.json
Normal file
@@ -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,9 +13,9 @@ When releasing a bugfix version we need to update the
|
||||
main image as well.
|
||||
|
||||
```bash
|
||||
docker build src --tag zettaio/restic-compose-backup:0.4
|
||||
docker build src --tag zettaio/restic-compose-backup:0.4.1
|
||||
docker build src --tag zettaio/restic-compose-backup:0.6
|
||||
docker build src --tag zettaio/restic-compose-backup:0.6.0
|
||||
|
||||
docker push zettaio/restic-compose-backup:0.4
|
||||
docker push zettaio/restic-compose-backup:0.4.1
|
||||
docker push zettaio/restic-compose-backup:0.5
|
||||
docker push zettaio/restic-compose-backup:0.5.0
|
||||
```
|
||||
|
@@ -4,6 +4,10 @@
|
||||
# DOCKER_TLS_VERIFY=1
|
||||
# DOCKER_CERT_PATH=''
|
||||
|
||||
SWARM_MODE=false
|
||||
INCLUDE_PROJECT_NAME=false
|
||||
EXCLUDE_BIND_MOUNTS=false
|
||||
|
||||
RESTIC_REPOSITORY=/restic_data
|
||||
RESTIC_PASSWORD=password
|
||||
|
||||
|
@@ -1,10 +1,12 @@
|
||||
FROM restic/restic:0.9.6
|
||||
FROM restic/restic:0.15.1
|
||||
|
||||
RUN apk update && apk add python3 dcron mariadb-client postgresql-client
|
||||
RUN apk update && apk add python3 py3-pip dcron
|
||||
# Should be removed
|
||||
RUN apk update && apk add mariadb-client postgresql-client
|
||||
|
||||
ADD . /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
|
||||
|
||||
ENTRYPOINT []
|
||||
|
@@ -1 +1 @@
|
||||
__version__ = '0.4.2'
|
||||
__version__ = '1.0.0'
|
||||
|
@@ -15,7 +15,7 @@ class SMTPAlert(BaseAlert):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.password = password or ""
|
||||
self.to = to
|
||||
|
||||
@classmethod
|
||||
@@ -34,7 +34,7 @@ class SMTPAlert(BaseAlert):
|
||||
|
||||
@property
|
||||
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'):
|
||||
# send_mail("Hello world!")
|
||||
|
37
src/restic_compose_backup/backup/base.py
Normal file
37
src/restic_compose_backup/backup/base.py
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
|
||||
class BackupBase:
|
||||
"""
|
||||
Base class for specific backup types such as various databases.
|
||||
|
||||
A backup type is responsible for processing all actions defined
|
||||
on a service. This includes pre-run and post-run actions.
|
||||
|
||||
All backup objects are instantiated before all the backup
|
||||
execution begins to sanity check the configuration and
|
||||
report the current parsed configuration to the user.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Possibly pass in the service object here?
|
||||
# Grab labels from service.
|
||||
pass
|
||||
|
||||
def pre_run(self):
|
||||
"""
|
||||
Pre-run raw command.
|
||||
Pre-run execution in a container.
|
||||
"""
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Run the backup
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def post_run(self):
|
||||
"""
|
||||
Post-run raw command.
|
||||
Post-run execution in a container.
|
||||
"""
|
||||
pass
|
0
src/restic_compose_backup/backup/influxdb.py
Normal file
0
src/restic_compose_backup/backup/influxdb.py
Normal file
0
src/restic_compose_backup/backup/mariadb.py
Normal file
0
src/restic_compose_backup/backup/mariadb.py
Normal file
0
src/restic_compose_backup/backup/mysql.py
Normal file
0
src/restic_compose_backup/backup/mysql.py
Normal file
9
src/restic_compose_backup/backup/volumes.py
Normal file
9
src/restic_compose_backup/backup/volumes.py
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
# Zepla
|
||||
# listens
|
||||
# to
|
||||
# Asmongolds
|
||||
# advice
|
||||
# on
|
||||
# Online
|
||||
# Harassment
|
@@ -1,289 +1,22 @@
|
||||
import argparse
|
||||
import os
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from restic_compose_backup import (
|
||||
alerts,
|
||||
backup_runner,
|
||||
log,
|
||||
restic,
|
||||
)
|
||||
from restic_compose_backup.config import Config
|
||||
from restic_compose_backup.containers import RunningContainers
|
||||
from restic_compose_backup import cron, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from restic_compose_backup import commands, log
|
||||
|
||||
|
||||
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)
|
||||
|
||||
elif args.action == 'version':
|
||||
import restic_compose_backup
|
||||
print(restic_compose_backup.__version__)
|
||||
|
||||
elif args.action == "crontab":
|
||||
crontab(config)
|
||||
"""Main entry point for the application"""
|
||||
args = parse_args(sorted(commands.COMMANDS.keys()))
|
||||
command = commands.COMMANDS[args.action](args)
|
||||
command.run()
|
||||
|
||||
|
||||
def status(config, containers):
|
||||
"""Outputs the backup config for the compose setup"""
|
||||
logger.info("Status for compose project '%s'", containers.project_name)
|
||||
logger.info("Repository: '%s'", config.repository)
|
||||
logger.info("Backup currently running?: %s", containers.backup_process_running)
|
||||
logger.info("Checking docker availability")
|
||||
|
||||
utils.list_containers()
|
||||
|
||||
if containers.stale_backup_process_containers:
|
||||
utils.remove_containers(containers.stale_backup_process_containers)
|
||||
|
||||
# Check if repository is initialized with restic snapshots
|
||||
if not restic.is_initialized(config.repository):
|
||||
logger.info("Could not get repository info. Attempting to initialize it.")
|
||||
result = restic.init_repo(config.repository)
|
||||
if result == 0:
|
||||
logger.info("Successfully initialized repository: %s", config.repository)
|
||||
else:
|
||||
logger.error("Failed to initialize repository")
|
||||
|
||||
logger.info("%s Detected Config %s", "-" * 25, "-" * 25)
|
||||
|
||||
# Start making snapshots
|
||||
backup_containers = containers.containers_for_backup()
|
||||
for container in backup_containers:
|
||||
logger.info('service: %s', container.service_name)
|
||||
|
||||
if container.volume_backup_enabled:
|
||||
for mount in container.filter_mounts():
|
||||
logger.info(' - volume: %s', mount.source)
|
||||
|
||||
if container.database_backup_enabled:
|
||||
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.*' label")
|
||||
|
||||
logger.info("-" * 67)
|
||||
|
||||
|
||||
def backup(config, containers):
|
||||
"""Request a backup to start"""
|
||||
# Make sure we don't spawn multiple backup processes
|
||||
if containers.backup_process_running:
|
||||
alerts.send(
|
||||
subject="Backup process container already running",
|
||||
body=(
|
||||
"A backup process container is already running. \n"
|
||||
f"Id: {containers.backup_process_container.id}\n"
|
||||
f"Name: {containers.backup_process_container.name}\n"
|
||||
),
|
||||
alert_type='ERROR',
|
||||
)
|
||||
raise RuntimeError("Backup process already running")
|
||||
|
||||
# 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('/volumes')
|
||||
volumes.update(mounts)
|
||||
|
||||
logger.debug('Starting backup container with image %s', containers.this_container.image)
|
||||
try:
|
||||
result = backup_runner.run(
|
||||
image=containers.this_container.image,
|
||||
command='restic-compose-backup start-backup-process',
|
||||
volumes=volumes,
|
||||
environment=containers.this_container.environment,
|
||||
source_container_id=containers.this_container.id,
|
||||
labels={
|
||||
containers.backup_process_label: 'True',
|
||||
"com.docker.compose.project": containers.project_name,
|
||||
},
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
alerts.send(
|
||||
subject="Exception during backup",
|
||||
body=str(ex),
|
||||
alert_type='ERROR',
|
||||
)
|
||||
return
|
||||
|
||||
logger.info('Backup container exit code: %s', result)
|
||||
|
||||
# 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):
|
||||
"""The actual backup process running inside the spawned container"""
|
||||
if not utils.is_true(os.environ.get('BACKUP_PROCESS_CONTAINER')):
|
||||
logger.error(
|
||||
"Cannot run backup process in this container. Use backup command instead. "
|
||||
"This will spawn a new container with the necessary mounts."
|
||||
)
|
||||
alerts.send(
|
||||
subject="Cannot run backup process in this container",
|
||||
body=(
|
||||
"Cannot run backup process in this container. Use backup command instead. "
|
||||
"This will spawn a new container with the necessary mounts."
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
status(config, containers)
|
||||
errors = False
|
||||
|
||||
# Did we actually get any volumes mounted?
|
||||
try:
|
||||
has_volumes = os.stat('/volumes') is not None
|
||||
except FileNotFoundError:
|
||||
logger.warning("Found no volumes to back up")
|
||||
has_volumes = False
|
||||
|
||||
# Warn if there is nothing to do
|
||||
if len(containers.containers_for_backup()) == 0 and not has_volumes:
|
||||
logger.error("No containers for backup found")
|
||||
exit(1)
|
||||
|
||||
if has_volumes:
|
||||
try:
|
||||
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('Volume backup exited with non-zero code: %s', vol_result)
|
||||
errors = True
|
||||
except Exception as ex:
|
||||
logger.error('Exception raised during volume backup')
|
||||
logger.exception(ex)
|
||||
errors = True
|
||||
|
||||
# back up databases
|
||||
logger.info('Backing up databases')
|
||||
for container in containers.containers_for_backup():
|
||||
if container.database_backup_enabled:
|
||||
try:
|
||||
instance = container.instance
|
||||
logger.info('Backing up %s in service %s', instance.container_type, instance.service_name)
|
||||
result = instance.backup()
|
||||
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.exception(ex)
|
||||
errors = True
|
||||
|
||||
if errors:
|
||||
logger.error('Exit code: %s', errors)
|
||||
exit(1)
|
||||
|
||||
# Only run cleanup if backup was successful
|
||||
result = cleanup(config, container)
|
||||
logger.debug('cleanup exit code: %s', result)
|
||||
if result != 0:
|
||||
logger.error('cleanup exit code: %s', result)
|
||||
exit(1)
|
||||
|
||||
# Test the repository for errors
|
||||
logger.info("Checking the repository for errors")
|
||||
result = restic.check(config.repository)
|
||||
if result != 0:
|
||||
logger.error('Check exit code: %s', result)
|
||||
exit(1)
|
||||
|
||||
logger.info('Backup completed')
|
||||
|
||||
|
||||
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 and prune_result
|
||||
|
||||
|
||||
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 crontab(config):
|
||||
"""Generate the crontab"""
|
||||
print(cron.generate_crontab(config))
|
||||
|
||||
|
||||
def parse_args():
|
||||
def parse_args(choices: List[str]):
|
||||
parser = argparse.ArgumentParser(prog='restic_compose_backup')
|
||||
parser.add_argument(
|
||||
'action',
|
||||
choices=[
|
||||
'status',
|
||||
'snapshots',
|
||||
'backup',
|
||||
'start-backup-process',
|
||||
'alert',
|
||||
'cleanup',
|
||||
'version',
|
||||
'crontab',
|
||||
],
|
||||
choices=choices,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--log-level',
|
||||
|
@@ -1,91 +0,0 @@
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test():
|
||||
return run(['ls', '/volumes'])
|
||||
|
||||
|
||||
def ping_mysql(host, port, username) -> int:
|
||||
"""Check if the mysql is up and can be reached"""
|
||||
return run([
|
||||
'mysqladmin',
|
||||
'ping',
|
||||
'--host',
|
||||
host,
|
||||
'--port',
|
||||
port,
|
||||
'--user',
|
||||
username,
|
||||
])
|
||||
|
||||
|
||||
def ping_mariadb(host, port, username) -> int:
|
||||
"""Check if the mariadb is up and can be reached"""
|
||||
return run([
|
||||
'mysqladmin',
|
||||
'ping',
|
||||
'--host',
|
||||
host,
|
||||
'--port',
|
||||
port,
|
||||
'--user',
|
||||
username,
|
||||
])
|
||||
|
||||
|
||||
def ping_postgres(host, port, username, password) -> int:
|
||||
"""Check if postgres can be reached"""
|
||||
return run([
|
||||
"pg_isready",
|
||||
f"--host={host}",
|
||||
f"--port={port}",
|
||||
f"--username={username}",
|
||||
])
|
||||
|
||||
|
||||
def run(cmd: List[str]) -> int:
|
||||
"""Run a command with parameters"""
|
||||
logger.debug('cmd: %s', ' '.join(cmd))
|
||||
child = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
||||
stdoutdata, stderrdata = child.communicate()
|
||||
|
||||
if stdoutdata.strip():
|
||||
log_std('stdout', stdoutdata.decode(),
|
||||
logging.DEBUG if child.returncode == 0 else logging.ERROR)
|
||||
|
||||
if stderrdata.strip():
|
||||
log_std('stderr', stderrdata.decode(), logging.ERROR)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def log_std(source: str, data: str, level: int):
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode()
|
||||
|
||||
if not data.strip():
|
||||
return
|
||||
|
||||
log_func = logger.debug if level == logging.DEBUG else logger.error
|
||||
log_func('%s %s %s', '-' * 10, source, '-' * 10)
|
||||
|
||||
lines = data.split('\n')
|
||||
if lines[-1] == '':
|
||||
lines.pop()
|
||||
|
||||
for line in lines:
|
||||
log_func(line)
|
||||
|
||||
log_func('-' * 28)
|
19
src/restic_compose_backup/commands/__init__.py
Normal file
19
src/restic_compose_backup/commands/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import sys
|
||||
from importlib import import_module
|
||||
from pkgutil import iter_modules
|
||||
from typing import Dict
|
||||
from .base import BaseCommand
|
||||
|
||||
|
||||
def get_commands() -> Dict[str, BaseCommand]:
|
||||
"""Return the list of available command classes"""
|
||||
_commands = {}
|
||||
current_module = sys.modules[__name__]
|
||||
for module_info in iter_modules(current_module.__path__):
|
||||
module = import_module(f'restic_compose_backup.commands.{module_info.name}')
|
||||
if hasattr(module, 'Command'):
|
||||
_commands[module_info.name] = module.Command
|
||||
return _commands
|
||||
|
||||
|
||||
COMMANDS = get_commands()
|
15
src/restic_compose_backup/commands/alert.py
Normal file
15
src/restic_compose_backup/commands/alert.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .base import BaseCommand
|
||||
from restic_compose_backup import alerts
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Send an alert"""
|
||||
name = "alert"
|
||||
|
||||
def run(self):
|
||||
"""Test alerts"""
|
||||
self.logger.info("Testing alerts")
|
||||
containers = self.get_containers()
|
||||
alerts.send(
|
||||
subject="{}: Test Alert".format(containers.project_name),
|
||||
body="Test message",
|
||||
)
|
62
src/restic_compose_backup/commands/backup.py
Normal file
62
src/restic_compose_backup/commands/backup.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from .base import BaseCommand
|
||||
from restic_compose_backup import backup_runner, alerts
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Backup a directory"""
|
||||
name = "backup"
|
||||
|
||||
def run(self):
|
||||
"""Run the backup command"""
|
||||
containers = self.get_containers()
|
||||
|
||||
if containers.backup_process_running:
|
||||
alerts.send(
|
||||
subject="Backup process container already running",
|
||||
body=(
|
||||
"A backup process container is already running. \n"
|
||||
f"Id: {containers.backup_process_container.id}\n"
|
||||
f"Name: {containers.backup_process_container.name}\n"
|
||||
),
|
||||
alert_type='ERROR',
|
||||
)
|
||||
raise RuntimeError("Backup process already running")
|
||||
|
||||
# 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('/volumes')
|
||||
volumes.update(mounts)
|
||||
|
||||
self.logger.debug('Starting backup container with image %s', containers.this_container.image)
|
||||
try:
|
||||
result = backup_runner.run(
|
||||
image=containers.this_container.image,
|
||||
command='restic-compose-backup start_backup_process',
|
||||
volumes=volumes,
|
||||
environment=containers.this_container.environment,
|
||||
source_container_id=containers.this_container.id,
|
||||
labels={
|
||||
containers.backup_process_label: 'True',
|
||||
"com.docker.compose.project": containers.project_name,
|
||||
},
|
||||
)
|
||||
except Exception as ex:
|
||||
self.logger.exception(ex)
|
||||
alerts.send(
|
||||
subject="Exception during backup",
|
||||
body=str(ex),
|
||||
alert_type='ERROR',
|
||||
)
|
||||
return
|
||||
|
||||
self.logger.info('Backup container exit code: %s', result)
|
||||
|
||||
# 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',
|
||||
)
|
32
src/restic_compose_backup/commands/base.py
Normal file
32
src/restic_compose_backup/commands/base.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
from restic_compose_backup.config import Config
|
||||
from restic_compose_backup.containers import RunningContainers
|
||||
from restic_compose_backup import log
|
||||
|
||||
|
||||
class BaseCommand:
|
||||
"""Base class for all commands"""
|
||||
name = "base"
|
||||
|
||||
def __init__(self, cli_args, *args, **kwargs):
|
||||
self.cli_args = cli_args
|
||||
self.log_level = cli_args.log_level
|
||||
self.config = Config()
|
||||
log.setup(level=self.log_level or self.config.log_level)
|
||||
self.logger = log.logger
|
||||
|
||||
def get_containers(self):
|
||||
"""Get running containers"""
|
||||
containers = RunningContainers()
|
||||
containers.this_container.set_config_env('LOG_LEVEL', self.log_level)
|
||||
return containers
|
||||
|
||||
def run(self):
|
||||
"""Run the command"""
|
||||
raise NotImplementedError
|
||||
|
||||
def run_command(self, command: str):
|
||||
"""Run a command by name and return the result"""
|
||||
from . import COMMANDS
|
||||
command = COMMANDS[command]
|
||||
command(self.cli_args).run()
|
21
src/restic_compose_backup/commands/cleanup.py
Normal file
21
src/restic_compose_backup/commands/cleanup.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from .base import BaseCommand
|
||||
from restic_compose_backup import restic
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Cleanup old snapshots"""
|
||||
name = "cleanup"
|
||||
|
||||
def run(self):
|
||||
"""Run forget / prune to minimize storage space"""
|
||||
self.logger.info('Forget outdated snapshots')
|
||||
forget_result = restic.forget(
|
||||
self.config.repository,
|
||||
self.config.keep_daily,
|
||||
self.config.keep_weekly,
|
||||
self.config.keep_monthly,
|
||||
self.config.keep_yearly,
|
||||
)
|
||||
self.logger.info('Prune stale data freeing storage space')
|
||||
prune_result = restic.prune(self.config.repository)
|
||||
return forget_result and prune_result
|
10
src/restic_compose_backup/commands/crontab.py
Normal file
10
src/restic_compose_backup/commands/crontab.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .base import BaseCommand
|
||||
from restic_compose_backup import cron
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Manage crontab"""
|
||||
name = "crontab"
|
||||
|
||||
def run(self):
|
||||
"""Generate the crontab"""
|
||||
print(cron.generate_crontab(self.config))
|
13
src/restic_compose_backup/commands/snapshots.py
Normal file
13
src/restic_compose_backup/commands/snapshots.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from .base import BaseCommand
|
||||
from restic_compose_backup import restic
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""List snapshots"""
|
||||
name = "snapshots"
|
||||
|
||||
def run(self):
|
||||
"""Display restic snapshots"""
|
||||
stdout, stderr = restic.snapshots(self.config.repository, last=True)
|
||||
for line in stdout.decode().split('\n'):
|
||||
print(line)
|
89
src/restic_compose_backup/commands/start_backup_process.py
Normal file
89
src/restic_compose_backup/commands/start_backup_process.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
from . import BaseCommand
|
||||
from restic_compose_backup import restic, alerts, utils
|
||||
|
||||
class Command(BaseCommand):
|
||||
name = "start_backup_process"
|
||||
|
||||
def run(self):
|
||||
"""The actual backup process running inside the spawned container"""
|
||||
if not utils.is_true(os.environ.get('BACKUP_PROCESS_CONTAINER')):
|
||||
self.logger.error(
|
||||
"Cannot run backup process in this container. Use backup command instead. "
|
||||
"This will spawn a new container with the necessary mounts."
|
||||
)
|
||||
alerts.send(
|
||||
subject="Cannot run backup process in this container",
|
||||
body=(
|
||||
"Cannot run backup process in this container. Use backup command instead. "
|
||||
"This will spawn a new container with the necessary mounts."
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
self.run_command("status") # status(config, containers)
|
||||
errors = False
|
||||
containers = self.get_containers()
|
||||
|
||||
# Did we actually get any volumes mounted?
|
||||
try:
|
||||
has_volumes = os.stat('/volumes') is not None
|
||||
except FileNotFoundError:
|
||||
self.logger.warning("Found no volumes to back up")
|
||||
has_volumes = False
|
||||
|
||||
# Warn if there is nothing to do
|
||||
if len(containers.containers_for_backup()) == 0 and not has_volumes:
|
||||
self.logger.error("No containers for backup found")
|
||||
exit(1)
|
||||
|
||||
if has_volumes:
|
||||
try:
|
||||
self.logger.info('Backing up volumes')
|
||||
vol_result = restic.backup_files(self.config.repository, source='/volumes')
|
||||
self.logger.debug('Volume backup exit code: %s', vol_result)
|
||||
if vol_result != 0:
|
||||
self.logger.error('Volume backup exited with non-zero code: %s', vol_result)
|
||||
errors = True
|
||||
except Exception as ex:
|
||||
self.logger.error('Exception raised during volume backup')
|
||||
self.logger.exception(ex)
|
||||
errors = True
|
||||
|
||||
# back up databases
|
||||
self.logger.info('Backing up databases')
|
||||
for container in containers.containers_for_backup():
|
||||
if container.database_backup_enabled:
|
||||
try:
|
||||
instance = container.instance
|
||||
self.logger.info('Backing up %s in service %s', instance.container_type, instance.service_name)
|
||||
result = instance.backup()
|
||||
self.logger.debug('Exit code: %s', result)
|
||||
if result != 0:
|
||||
self.logger.error('Backup command exited with non-zero code: %s', result)
|
||||
errors = True
|
||||
except Exception as ex:
|
||||
self.logger.exception(ex)
|
||||
errors = True
|
||||
|
||||
if errors:
|
||||
self.logger.error('Exit code: %s', errors)
|
||||
exit(1)
|
||||
|
||||
# Only run cleanup if backup was successful
|
||||
#result = cleanup(config, container)
|
||||
self.run_command("cleanup")
|
||||
|
||||
self.logger.debug('cleanup exit code: %s', result)
|
||||
if result != 0:
|
||||
self.logger.error('cleanup exit code: %s', result)
|
||||
exit(1)
|
||||
|
||||
# Test the repository for errors
|
||||
self.logger.info("Checking the repository for errors")
|
||||
result = restic.check(self.config.repository)
|
||||
if result != 0:
|
||||
self.logger.error('Check exit code: %s', result)
|
||||
exit(1)
|
||||
|
||||
self.logger.info('Backup completed')
|
66
src/restic_compose_backup/commands/status.py
Normal file
66
src/restic_compose_backup/commands/status.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from .base import BaseCommand
|
||||
from restic_compose_backup import utils, restic
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Show status"""
|
||||
name = "status"
|
||||
|
||||
def run(self):
|
||||
"""Outputs the backup config for the compose setup"""
|
||||
containers = self.get_containers()
|
||||
|
||||
self.logger.info("Status for compose project '%s'", containers.project_name)
|
||||
self.logger.info("Repository: '%s'", self.config.repository)
|
||||
self.logger.info("Backup currently running?: %s", containers.backup_process_running)
|
||||
self.logger.info("Include project name in backup path?: %s", utils.is_true(self.config.include_project_name))
|
||||
self.logger.debug("Exclude bind mounts from backups?: %s", utils.is_true(self.config.exclude_bind_mounts))
|
||||
self.logger.info("Checking docker availability")
|
||||
|
||||
utils.list_containers()
|
||||
|
||||
if containers.stale_backup_process_containers:
|
||||
utils.remove_containers(containers.stale_backup_process_containers)
|
||||
|
||||
# Check if repository is initialized with restic snapshots
|
||||
if not restic.is_initialized(self.config.repository):
|
||||
self.logger.info("Could not get repository info. Attempting to initialize it.")
|
||||
result = restic.init_repo(self.config.repository)
|
||||
if result == 0:
|
||||
self.logger.info("Successfully initialized repository: %s", self.config.repository)
|
||||
else:
|
||||
self.logger.error("Failed to initialize repository")
|
||||
|
||||
self.logger.info("%s Detected Config %s", "-" * 25, "-" * 25)
|
||||
|
||||
# Start making snapshots
|
||||
backup_containers = containers.containers_for_backup()
|
||||
for container in backup_containers:
|
||||
self.logger.info('service: %s', container.service_name)
|
||||
|
||||
if container.volume_backup_enabled:
|
||||
for mount in container.filter_mounts():
|
||||
self.logger.info(
|
||||
' - volume: %s -> %s',
|
||||
mount.source,
|
||||
container.get_volume_backup_destination(mount, '/volumes'),
|
||||
)
|
||||
|
||||
if container.database_backup_enabled:
|
||||
instance = container.instance
|
||||
# ping = instance.ping()
|
||||
ping = 0
|
||||
self.logger.info(
|
||||
' - %s (is_ready=%s) -> %s',
|
||||
instance.container_type,
|
||||
ping == 0,
|
||||
instance.backup_destination_path(),
|
||||
)
|
||||
if ping != 0:
|
||||
self.logger.error("Database '%s' in service %s cannot be reached",
|
||||
instance.container_type, container.service_name)
|
||||
|
||||
if len(backup_containers) == 0:
|
||||
self.logger.info("No containers in the project has 'restic-compose-backup.*' label")
|
||||
|
||||
self.logger.info("-" * 67)
|
15
src/restic_compose_backup/commands/test.py
Normal file
15
src/restic_compose_backup/commands/test.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .base import BaseCommand
|
||||
from restic_compose_backup import utils
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Test a command"""
|
||||
name = "test"
|
||||
|
||||
def run(self):
|
||||
"""Random test command"""
|
||||
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))
|
10
src/restic_compose_backup/commands/version.py
Normal file
10
src/restic_compose_backup/commands/version.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Show version"""
|
||||
name = "version"
|
||||
|
||||
def run(self):
|
||||
import restic_compose_backup
|
||||
print(restic_compose_backup.__version__)
|
@@ -12,6 +12,9 @@ class Config:
|
||||
self.password = os.environ.get('RESTIC_REPOSITORY')
|
||||
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.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
|
||||
self.log_level = os.environ.get('LOG_LEVEL')
|
||||
@@ -31,3 +34,6 @@ class Config:
|
||||
|
||||
if not self.password:
|
||||
raise ValueError("RESTIC_REPOSITORY env var not set")
|
||||
|
||||
|
||||
config = Config()
|
||||
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from restic_compose_backup import enums, utils
|
||||
from restic_compose_backup.config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,6 +64,37 @@ class Container:
|
||||
"""Image name"""
|
||||
return self.get_config('Image')
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Container name"""
|
||||
return self._data['Name'].replace('/', '')
|
||||
|
||||
@property
|
||||
def service_name(self) -> str:
|
||||
"""Name of the container/service"""
|
||||
return self.get_label('com.docker.compose.service', default='') or \
|
||||
self.get_label('com.docker.swarm.service.name', default='')
|
||||
|
||||
@property
|
||||
def backup_process_label(self) -> str:
|
||||
"""str: The unique backup process label for this project"""
|
||||
return f"{enums.LABEL_BACKUP_PROCESS}-{self.project_name}"
|
||||
|
||||
@property
|
||||
def project_name(self) -> str:
|
||||
"""str: Name of the compose setup"""
|
||||
return self.get_label('com.docker.compose.project', default='')
|
||||
|
||||
@property
|
||||
def stack_name(self) -> str:
|
||||
"""str: Name of the stack is present"""
|
||||
return self.get_label("com.docker.stack.namespace")
|
||||
|
||||
@property
|
||||
def is_oneoff(self) -> bool:
|
||||
"""Was this container started with run command?"""
|
||||
return self.get_label('com.docker.compose.oneoff', default='False') == 'True'
|
||||
|
||||
@property
|
||||
def environment(self) -> list:
|
||||
"""All configured env vars for the container as a list"""
|
||||
@@ -150,31 +182,6 @@ class Container:
|
||||
"""bool: Is the container running?"""
|
||||
return self._state.get('Running', False)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Container name"""
|
||||
return self._data['Name'].replace('/', '')
|
||||
|
||||
@property
|
||||
def service_name(self) -> str:
|
||||
"""Name of the container/service"""
|
||||
return self.get_label('com.docker.compose.service', default='')
|
||||
|
||||
@property
|
||||
def backup_process_label(self) -> str:
|
||||
"""str: The unique backup process label for this project"""
|
||||
return f"{enums.LABEL_BACKUP_PROCESS}-{self.project_name}"
|
||||
|
||||
@property
|
||||
def project_name(self) -> str:
|
||||
"""Name of the compose setup"""
|
||||
return self.get_label('com.docker.compose.project', default='')
|
||||
|
||||
@property
|
||||
def is_oneoff(self) -> bool:
|
||||
"""Was this container started with run command?"""
|
||||
return self.get_label('com.docker.compose.oneoff', default='False') == 'True'
|
||||
|
||||
def get_config(self, name, default=None):
|
||||
"""Get value from config dict"""
|
||||
return self._config.get(name, default)
|
||||
@@ -187,11 +194,15 @@ class Container:
|
||||
"""Get all mounts for this container matching include/exclude filters"""
|
||||
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:
|
||||
return filtered
|
||||
|
||||
if self._include:
|
||||
for mount in self._mounts:
|
||||
for mount in mounts:
|
||||
for pattern in self._include:
|
||||
if pattern in mount.source:
|
||||
break
|
||||
@@ -201,14 +212,14 @@ class Container:
|
||||
filtered.append(mount)
|
||||
|
||||
elif self._exclude:
|
||||
for mount in self._mounts:
|
||||
for mount in mounts:
|
||||
for pattern in self._exclude:
|
||||
if pattern in mount.source:
|
||||
break
|
||||
else:
|
||||
filtered.append(mount)
|
||||
else:
|
||||
return self._mounts
|
||||
return mounts
|
||||
|
||||
return filtered
|
||||
|
||||
@@ -218,12 +229,26 @@ class Container:
|
||||
volumes = {}
|
||||
for mount in mounts:
|
||||
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,
|
||||
}
|
||||
|
||||
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:
|
||||
"""dict: get credentials for the service"""
|
||||
raise NotImplementedError("Base container class don't implement this")
|
||||
@@ -236,6 +261,10 @@ class Container:
|
||||
"""Back up this service"""
|
||||
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:
|
||||
"""list: create a dump command restic and use to send data through stdin"""
|
||||
raise NotImplementedError("Base container class don't implement this")
|
||||
@@ -354,10 +383,21 @@ class RunningContainers:
|
||||
if container.is_backup_process_container:
|
||||
self.backup_process_container = container
|
||||
|
||||
# Detect containers belonging to the current compose setup
|
||||
if (container.project_name == self.this_container.project_name
|
||||
and not container.is_oneoff):
|
||||
if container != self.backup_process_container:
|
||||
# --- Determine what containers should be evaludated
|
||||
|
||||
# If not swarm mode we need to filter in compose project
|
||||
if not config.swarm_mode:
|
||||
if container.project_name != self.this_container.project_name:
|
||||
continue
|
||||
|
||||
# Containers started manually are not included
|
||||
if container.is_oneoff:
|
||||
continue
|
||||
|
||||
# Do not include the backup process container
|
||||
if container == self.backup_process_container:
|
||||
continue
|
||||
|
||||
self.containers.append(container)
|
||||
|
||||
@property
|
||||
|
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
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 (
|
||||
commands,
|
||||
restic,
|
||||
@@ -48,10 +50,23 @@ class MariadbContainer(Container):
|
||||
with utils.environment('MYSQL_PWD', creds['password']):
|
||||
return restic.backup_from_stdin(
|
||||
config.repository,
|
||||
f'/databases/{self.service_name}/all_databases.sql',
|
||||
self.backup_destination_path(),
|
||||
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):
|
||||
container_type = 'mysql'
|
||||
@@ -94,10 +109,23 @@ class MysqlContainer(Container):
|
||||
with utils.environment('MYSQL_PWD', creds['password']):
|
||||
return restic.backup_from_stdin(
|
||||
config.repository,
|
||||
f'/databases/{self.service_name}/all_databases.sql',
|
||||
self.backup_destination_path(),
|
||||
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):
|
||||
container_type = 'postgres'
|
||||
@@ -141,6 +169,19 @@ class PostgresContainer(Container):
|
||||
with utils.environment('PGPASSWORD', creds['password']):
|
||||
return restic.backup_from_stdin(
|
||||
config.repository,
|
||||
f"/databases/{self.service_name}/{creds['database']}.sql",
|
||||
self.backup_destination_path(),
|
||||
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,10 @@ def setup(level: str = 'warning'):
|
||||
|
||||
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'))
|
||||
# ch.setFormatter(logging.Formatter('%(asctime)s - {HOSTNAME} - %(name)s - %(levelname)s - %(message)s'))
|
||||
# ch.setFormatter(logging.Formatter('%(asctime)s - {HOSTNAME} - %(levelname)s - %(message)s'))
|
||||
ch.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s: %(message)s'))
|
||||
|
||||
# Prevent duplicate handlers
|
||||
if not logger.handlers:
|
||||
logger.addHandler(ch)
|
||||
|
@@ -4,7 +4,7 @@ Restic commands
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
from subprocess import Popen, PIPE
|
||||
from restic_compose_backup import commands
|
||||
from restic_compose_backup import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,13 +14,13 @@ def init_repo(repository: str):
|
||||
Attempt to initialize the repository.
|
||||
Doing this after the repository is initialized
|
||||
"""
|
||||
return commands.run(restic(repository, [
|
||||
return utils.run(restic(repository, [
|
||||
"init",
|
||||
]))
|
||||
|
||||
|
||||
def backup_files(repository: str, source='/volumes'):
|
||||
return commands.run(restic(repository, [
|
||||
return utils.run(restic(repository, [
|
||||
"--verbose",
|
||||
"backup",
|
||||
source,
|
||||
@@ -49,10 +49,10 @@ def backup_from_stdin(repository: str, filename: str, source_command: List[str])
|
||||
exit_code = 0 if (source_exit == 0 and dest_exit == 0) else 1
|
||||
|
||||
if stdout:
|
||||
commands.log_std('stdout', stdout, logging.DEBUG if exit_code == 0 else logging.ERROR)
|
||||
utils.log_std('stdout', stdout, logging.DEBUG if exit_code == 0 else logging.ERROR)
|
||||
|
||||
if stderr:
|
||||
commands.log_std('stderr', stderr, logging.ERROR)
|
||||
utils.log_std('stderr', stderr, logging.ERROR)
|
||||
|
||||
return exit_code
|
||||
|
||||
@@ -62,21 +62,21 @@ def snapshots(repository: str, last=True) -> Tuple[str, str]:
|
||||
args = ["snapshots"]
|
||||
if last:
|
||||
args.append('--last')
|
||||
return commands.run_capture_std(restic(repository, args))
|
||||
return utils.run_capture_std(restic(repository, args))
|
||||
|
||||
|
||||
def is_initialized(repository: str) -> bool:
|
||||
"""
|
||||
Checks if a repository is initialized using snapshots command.
|
||||
Note that this cannot separate between uninitalized repo
|
||||
and other errors, but this method is reccomended by the restic
|
||||
Note that this cannot separate between uninitialized repo
|
||||
and other errors, but this method is recommended by the restic
|
||||
community.
|
||||
"""
|
||||
return commands.run(restic(repository, ["snapshots", '--last'])) == 0
|
||||
return utils.run(restic(repository, ["snapshots", '--last'])) == 0
|
||||
|
||||
|
||||
def forget(repository: str, daily: str, weekly: str, monthly: str, yearly: str):
|
||||
return commands.run(restic(repository, [
|
||||
return utils.run(restic(repository, [
|
||||
'forget',
|
||||
'--group-by',
|
||||
'paths',
|
||||
@@ -92,13 +92,13 @@ def forget(repository: str, daily: str, weekly: str, monthly: str, yearly: str):
|
||||
|
||||
|
||||
def prune(repository: str):
|
||||
return commands.run(restic(repository, [
|
||||
return utils.run(restic(repository, [
|
||||
'prune',
|
||||
]))
|
||||
|
||||
|
||||
def check(repository: str):
|
||||
return commands.run(restic(repository, [
|
||||
return utils.run(restic(repository, [
|
||||
"check",
|
||||
# "--with-cache",
|
||||
]))
|
||||
|
@@ -1,11 +1,14 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import List, Tuple, TYPE_CHECKING
|
||||
from subprocess import Popen, PIPE
|
||||
from contextlib import contextmanager
|
||||
import docker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from restic_compose_backup.containers import Container
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TRUE_VALUES = ['1', 'true', 'True', True, 1]
|
||||
|
||||
|
||||
@@ -37,6 +40,18 @@ def list_containers() -> List[dict]:
|
||||
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']):
|
||||
client = docker_client()
|
||||
logger.info('Attempting to delete stale backup process containers')
|
||||
@@ -49,9 +64,9 @@ def remove_containers(containers: List['Container']):
|
||||
logger.exception(ex)
|
||||
|
||||
|
||||
def is_true(value):
|
||||
def is_true(value) -> True:
|
||||
"""
|
||||
Evaluates the truthfullness of a bool value in container labels
|
||||
Evaluates the truthfulness of a bool value in container labels
|
||||
"""
|
||||
return value in TRUE_VALUES
|
||||
|
||||
@@ -70,7 +85,7 @@ def strip_root(path):
|
||||
|
||||
@contextmanager
|
||||
def environment(name, value):
|
||||
"""Tempset env var"""
|
||||
"""Temp-set environment variables"""
|
||||
old_val = os.environ.get(name)
|
||||
os.environ[name] = value
|
||||
try:
|
||||
@@ -80,3 +95,89 @@ def environment(name, value):
|
||||
del os.environ[name]
|
||||
else:
|
||||
os.environ[name] = old_val
|
||||
|
||||
|
||||
def test():
|
||||
return run(['ls', '/volumes'])
|
||||
|
||||
|
||||
def ping_mysql(host, port, username) -> int:
|
||||
"""Check if the mysql is up and can be reached"""
|
||||
return run([
|
||||
'mysqladmin',
|
||||
'ping',
|
||||
'--host',
|
||||
host,
|
||||
'--port',
|
||||
port,
|
||||
'--user',
|
||||
username,
|
||||
])
|
||||
|
||||
|
||||
def ping_mariadb(host, port, username) -> int:
|
||||
"""Check if the mariadb is up and can be reached"""
|
||||
return run([
|
||||
'mysqladmin',
|
||||
'ping',
|
||||
'--host',
|
||||
host,
|
||||
'--port',
|
||||
port,
|
||||
'--user',
|
||||
username,
|
||||
])
|
||||
|
||||
|
||||
def ping_postgres(host, port, username, password) -> int:
|
||||
"""Check if postgres can be reached"""
|
||||
return run([
|
||||
"pg_isready",
|
||||
f"--host={host}",
|
||||
f"--port={port}",
|
||||
f"--username={username}",
|
||||
])
|
||||
|
||||
|
||||
def run(cmd: List[str]) -> int:
|
||||
"""Run a command with parameters"""
|
||||
logger.debug('cmd: %s', ' '.join(cmd))
|
||||
child = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
||||
stdoutdata, stderrdata = child.communicate()
|
||||
|
||||
if stdoutdata.strip():
|
||||
log_std('stdout', stdoutdata.decode(),
|
||||
logging.DEBUG if child.returncode == 0 else logging.ERROR)
|
||||
|
||||
if stderrdata.strip():
|
||||
log_std('stderr', stderrdata.decode(), logging.ERROR)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def log_std(source: str, data: str, level: int):
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode()
|
||||
|
||||
if not data.strip():
|
||||
return
|
||||
|
||||
log_func = logger.debug if level == logging.DEBUG else logger.error
|
||||
log_func('%s %s %s', '-' * 10, source, '-' * 10)
|
||||
|
||||
lines = data.split('\n')
|
||||
if lines[-1] == '':
|
||||
lines.pop()
|
||||
|
||||
for line in lines:
|
||||
log_func(line)
|
||||
|
||||
log_func('-' * 28)
|
||||
|
@@ -3,12 +3,13 @@ from setuptools import setup, find_namespace_packages
|
||||
setup(
|
||||
name="restic-compose-backup",
|
||||
url="https://github.com/ZettaIO/restic-compose-backup",
|
||||
version="0.4.2",
|
||||
version="1.0.0",
|
||||
author="Einar Forselv",
|
||||
author_email="eforselv@gmail.com",
|
||||
packages=find_namespace_packages(include=['restic_compose_backup']),
|
||||
install_requires=[
|
||||
'docker==4.1.*',
|
||||
# 'docker==4.1.*',
|
||||
'docker~=6.0.0',
|
||||
],
|
||||
entry_points={'console_scripts': [
|
||||
'restic-compose-backup = restic_compose_backup.cli:main',
|
||||
|
@@ -3,6 +3,9 @@ import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
os.environ['RESTIC_REPOSITORY'] = "test"
|
||||
os.environ['RESTIC_PASSWORD'] = "password"
|
||||
|
||||
from restic_compose_backup import utils
|
||||
from restic_compose_backup.containers import RunningContainers
|
||||
import fixtures
|
||||
@@ -15,8 +18,8 @@ class ResticBackupTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up basic environment variables"""
|
||||
os.environ['RESTIC_REPOSITORY'] = "test"
|
||||
os.environ['RESTIC_PASSWORD'] = "password"
|
||||
# os.environ['RESTIC_REPOSITORY'] = "test"
|
||||
# os.environ['RESTIC_PASSWORD'] = "password"
|
||||
|
||||
def createContainers(self):
|
||||
backup_hash = fixtures.generate_sha256()
|
||||
|
30
swarm-stack.yml
Normal file
30
swarm-stack.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10
|
||||
labels:
|
||||
restic-compose-backup.mariadb: "true"
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=my-secret-pw
|
||||
- MYSQL_DATABASE=mydb
|
||||
- MYSQL_USER=myuser
|
||||
- MYSQL_PASSWORD=mypassword
|
||||
networks:
|
||||
- global
|
||||
volumes:
|
||||
- mariadbdata:/var/lib/mysql
|
||||
files:
|
||||
image: nginx:1.17-alpine
|
||||
labels:
|
||||
restic-compose-backup.volumes: "true"
|
||||
volumes:
|
||||
- files:/srv/files
|
||||
|
||||
volumes:
|
||||
mariadbdata:
|
||||
files:
|
||||
|
||||
networks:
|
||||
global:
|
||||
external: true
|
8
tox.ini
8
tox.ini
@@ -5,13 +5,13 @@
|
||||
skipsdist = True
|
||||
setupdir={toxinidir}/src
|
||||
envlist =
|
||||
py37
|
||||
py38
|
||||
pep8
|
||||
|
||||
[testenv]
|
||||
usedevelop = True
|
||||
basepython =
|
||||
py37: python3.7
|
||||
py38: python3.8
|
||||
|
||||
deps =
|
||||
-r{toxinidir}/src//tests/requirements.txt
|
||||
@@ -23,7 +23,7 @@ commands =
|
||||
[testenv:pep8]
|
||||
usedevelop = false
|
||||
deps = flake8
|
||||
basepython = python3.7
|
||||
basepython = python3.8
|
||||
commands = flake8
|
||||
|
||||
[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
|
||||
show-source = True
|
||||
max-line-length = 120
|
||||
exclude = .tox,.venv*,tests,build,conf.py
|
||||
exclude = .tox,env,tests,build,conf.py
|
||||
|
Reference in New Issue
Block a user