47 Commits

Author SHA1 Message Date
3wc
2a7e564a24 Switch ENTRYPOINT to try to resolve loop on start
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-01 22:43:12 -04:00
3wc
5f381f395d Update requirements 2024-10-01 22:43:12 -04:00
3wc
e0ee16426b Make entrypoint executable 2024-10-01 22:43:12 -04:00
3wc
92845c4142 Add --break-system-packages, surely we don't need a virtualenv 2024-10-01 22:43:12 -04:00
3wc
d0d0f29c79 Move entrypoint script into Docker image 2024-10-01 22:43:12 -04:00
3wc
88168de90e Move /entrypoint.sh to Dockerfile 2024-10-01 22:43:12 -04:00
3wc
71c88d0428 Remove redundant stuff from entrypoint 2024-10-01 22:43:12 -04:00
3wc
15b2d656bb Whoops, wrong image 2024-10-01 22:43:12 -04:00
3wc
46522a2e9a Switch to backup-bot-two image 2024-10-01 22:43:12 -04:00
3wc
30e88a972a Whoops skip shellcheck 2024-10-01 22:43:12 -04:00
3wc
64e09a6472 Reinstate Docker image 2024-10-01 22:43:12 -04:00
3wc
84d606fa80 Add CHANGELOG.md
[ci skip]
2024-04-09 22:51:09 -03:00
7865907811 fix push notification precendence race condition
Some checks failed
continuous-integration/drone/push Build is failing
2024-03-08 15:42:00 +01:00
dc66c02e23 make run_cron cmd independent from push_success_notifiaction
Some checks failed
continuous-integration/drone/push Build is failing
2024-02-13 11:53:27 +01:00
f730c70bfe feat: add retry option
Some checks failed
continuous-integration/drone/push Build is failing
2024-01-18 18:01:30 +01:00
faa7ae3dd1 fix Readme
Some checks failed
continuous-integration/drone/push Build is failing
2024-01-17 20:36:06 +01:00
79eeec428a Push Notifications #24
Some checks failed
continuous-integration/drone/push Build is failing
2024-01-16 19:40:31 +01:00
4164760dc6 Sepcify secret and volume donwload via env, fixes #44
Some checks failed
continuous-integration/drone/push Build is failing
2024-01-11 18:46:58 +01:00
e644679b8b Clearer service name in warning message. Fixes #46
Some checks failed
continuous-integration/drone/push Build is failing
2024-01-11 18:39:26 +01:00
0c587ac926 add spaces for missing snapshot, fixes #45
Some checks failed
continuous-integration/drone/push Build is failing
2024-01-11 18:34:58 +01:00
65686cd891 Fix python package install error
Some checks failed
continuous-integration/drone/push Build is failing
2023-12-19 01:16:12 +01:00
ac055c932e fix: remove bash/sh wrapping
Some checks failed
continuous-integration/drone/push Build is failing
2023-12-13 18:27:12 +01:00
64328c79b1 make --noninteractive a flag
Some checks failed
continuous-integration/drone/push Build is failing
2023-12-12 13:39:26 +01:00
15275b2571 structured json logging with -m flag
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-23 20:16:15 +01:00
4befebba38 Merge pull request 'fix removing quotes' (#40) from p4u1/backup-bot-two:fix-quotes into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #40
2023-11-11 08:15:12 +00:00
d2087a441e fix removing quotes
Some checks failed
continuous-integration/drone/pr Build is failing
2023-11-11 08:55:12 +01:00
f4d96b0875 update README
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-10 20:04:05 +01:00
c73bbe8c0d Always backup all apps to increase restic performance
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-09 10:30:19 +01:00
ff2b5a25a2 chore(deps): update docker docker tag to v24.0.7
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2023-10-27 07:01:33 +00:00
e186813a49 better error handling
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-25 13:37:06 +02:00
37cb51674f update README
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-24 21:14:39 +02:00
2ea59b4230 breaking change: rename env RESTIC_REPO to RESTIC_REPOSITORY
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-24 21:03:44 +02:00
354f964e7d fix(create): hande non existing secret files
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-20 00:17:44 +02:00
2bb27aadc4 fix: handle not running container
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-19 23:15:24 +02:00
66e1c9617d fix(download): dump volumes and secrets per default into /tmp/backup.tar.gz
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-18 14:10:58 +02:00
79d19e7ac5 chore: formatting
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-12 12:50:10 +02:00
359140781e fix(create): quote handling for bash pipefail wrapping of pre/post hooks
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-12 10:57:52 +02:00
8750ec1813 fix(snapshots): warn if no snapshots could be found
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-12 10:35:37 +02:00
8e76ad591e remove copy pasta line
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-11 18:16:58 +02:00
a3faa5d51f fix(ls): catch error if there is no snapshot
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-11 18:13:27 +02:00
a3f27fa6ba log before container command
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-11 17:46:27 +02:00
fe5d846c5f Revert "Revert "Revert "feat: add backupbot label"""
Some checks failed
continuous-integration/drone/push Build is failing
This reverts commit 79b7a01dda.
2023-10-11 14:39:18 +02:00
79b7a01dda Revert "Revert "feat: add backupbot label""
Some checks failed
continuous-integration/drone/push Build is failing
This reverts commit f8a8547b70.
2023-10-10 19:39:57 +02:00
f8a8547b70 Revert "feat: add backupbot label"
Some checks failed
continuous-integration/drone/push Build is failing
This reverts commit 4c2304a962.
2023-10-10 08:20:08 +02:00
192b1f1d9c Merge pull request 'feat: add backupbot label' (#33) from enable-label into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #33
2023-10-10 05:56:16 +00:00
4c2304a962 feat: add backupbot label
Some checks failed
continuous-integration/drone/pr Build is failing
2023-10-10 07:53:26 +02:00
69e7f07978 Merge pull request 'Backupbot Revolution' (#23) from backupbot_revolution into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #23
2023-10-09 10:54:22 +00:00
11 changed files with 271 additions and 128 deletions

View File

@ -2,11 +2,16 @@
kind: pipeline
name: linters
steps:
- name: run shellcheck
image: koalaman/shellcheck-alpine
commands:
- shellcheck backup.sh
trigger:
branch:
- main
- name: publish image
image: plugins/docker
settings:
username: 3wordchant
password:
from_secret: git_coopcloud_tech_token_3wc
repo: git.coopcloud.tech/coop-cloud/backup-bot-two
tags: 2.0.0
registry: git.coopcloud.tech
when:
event:
exclude:
- pull_request

View File

@ -4,10 +4,15 @@ SECRET_RESTIC_PASSWORD_VERSION=v1
COMPOSE_FILE=compose.yml
RESTIC_REPO=/backups/restic
RESTIC_REPOSITORY=/backups/restic
CRON_SCHEDULE='30 3 * * *'
# Push Notifiactions
#PUSH_URL_START=https://status.example.com/api/push/xxxxxxxxxx?status=up&msg=start
#PUSH_URL_SUCCESS=https://status.example.com/api/push/xxxxxxxxxx?status=up&msg=OK
#PUSH_URL_FAIL=https://status.example.com/api/push/xxxxxxxxxx?status=down&msg=fail
# swarm-cronjob, instead of built-in cron
#COMPOSE_FILE="$COMPOSE_FILE:compose.swarm-cronjob.yml"
@ -22,8 +27,8 @@ CRON_SCHEDULE='30 3 * * *'
#COMPOSE_FILE="$COMPOSE_FILE:compose.s3.yml"
# Secret restic repository
# use a secret to store the RESTIC_REPO if the repository location contains a secret value
# use a secret to store the RESTIC_REPOSITORY if the repository location contains a secret value
# i.E rest:https://user:SECRET_PASSWORD@host:8000/
# it overwrites the RESTIC_REPO variable
# it overwrites the RESTIC_REPOSITORY variable
#SECRET_RESTIC_REPO_VERSION=v1
#COMPOSE_FILE="$COMPOSE_FILE:compose.secret.yml"

6
CHANGELOG.md Normal file
View File

@ -0,0 +1,6 @@
# Change log
## 2.0.0 (unreleased)
- Rewrite from Bash to Python
- Add support for push notifications (#24)

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM docker:24.0.7-dind
RUN apk add --upgrade --no-cache restic bash python3 py3-pip py3-click py3-docker-py py3-json-logger curl
# Todo use requirements file with specific versions
RUN pip install --break-system-packages resticpy==1.0.2
COPY backupbot.py /usr/bin/backup
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT /entrypoint.sh

View File

@ -38,26 +38,26 @@ Backupbot II tries to help, by
* `abra app new backup-bot-two`
* `abra app config <app-name>`
- set storage options. Either configure `CRON_SCHEDULE`, or set up `swarm-cronjob`
* `abra app secret generate -a <app_name>`
* `abra app secret generate -a <backupbot_name>`
* `abra app deploy <app-name>`
## Configuration
Per default Backupbot stores the backups locally in the repository `/backups/restic`, which is accessible as volume at `/var/lib/docker/volumes/<app_name>_backups/_data/restic/`
Per default Backupbot stores the backups locally in the repository `/backups/restic`, which is accessible as volume at `/var/lib/docker/volumes/<backupbot_name>_backups/_data/restic/`
The backup location can be changed using the `RESTIC_REPO` env variable.
The backup location can be changed using the `RESTIC_REPOSITORY` env variable.
### S3 Storage
To use S3 storage as backup location set the following envs:
```
RESTIC_REPO=s3:<S3-SERVICE-URL>/<BUCKET-NAME>
RESTIC_REPOSITORY=s3:<S3-SERVICE-URL>/<BUCKET-NAME>
SECRET_AWS_SECRET_ACCESS_KEY_VERSION=v1
AWS_ACCESS_KEY_ID=<MY_ACCESS_KEY>
COMPOSE_FILE="$COMPOSE_FILE:compose.s3.yml"
```
and add your `<SECRET_ACCESS_KEY>` as docker secret:
`abra app secret insert <app_name> aws_secret_access_key v1 <SECRET_ACCESS_KEY>`
`abra app secret insert <backupbot_name> aws_secret_access_key v1 <SECRET_ACCESS_KEY>`
See [restic s3 docs](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for more information.
@ -67,7 +67,7 @@ See [restic s3 docs](https://restic.readthedocs.io/en/latest/030_preparing_a_new
To use SFTP storage as backup location set the following envs:
```
RESTIC_REPO=sftp:user@host:/restic-repo-path
RESTIC_REPOSITORY=sftp:user@host:/restic-repo-path
SECRET_SSH_KEY_VERSION=v1
SSH_HOST_KEY="hostname ssh-rsa AAAAB3...
COMPOSE_FILE="$COMPOSE_FILE:compose.ssh.yml"
@ -79,13 +79,14 @@ Add the key to your `authorized_keys`:
`ssh-copy-id -i backupkey <user>@<hostname>`
Add your `SSH_KEY` as docker secret:
```
abra app secret insert <app_name> ssh_key v1 """$(cat backupkey)
abra app secret insert <backupbot_name> ssh_key v1 """$(cat backupkey)
"""
```
> Attention: This command needs to be executed exactly as stated above, because it places a trailing newline at the end, if this is missing you will get the following error: `Load key "/run/secrets/ssh_key": error in libcrypto`
### Restic REST server Storage
You can simply set the `RESTIC_REPO` variable to your REST server URL `rest:http://host:8000/`.
You can simply set the `RESTIC_REPOSITORY` variable to your REST server URL `rest:http://host:8000/`.
If you access the REST server with a password `rest:https://user:pass@host:8000/` you should hide the whole URL containing the password inside a secret.
Uncomment these lines:
```
@ -94,63 +95,85 @@ COMPOSE_FILE="$COMPOSE_FILE:compose.secret.yml"
```
Add your REST server url as secret:
```
`abra app secret insert <app_name> restic_repo v1 "rest:https://user:pass@host:8000/"`
`abra app secret insert <backupbot_name> restic_repo v1 "rest:https://user:pass@host:8000/"`
```
The secret will overwrite the `RESTIC_REPO` variable.
The secret will overwrite the `RESTIC_REPOSITORY` variable.
See [restic REST docs](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#rest-server) for more information.
## Push notifications
The following env variables can be used to setup push notifications for backups. `PUSH_URL_START` is requested just before the backups starts, `PUSH_URL_SUCCESS` is only requested if the backup was successful and if the backup fails `PUSH_URL_FAIL` will be requested.
Each variable is optional and independent of the other.
```
PUSH_URL_START=https://status.example.com/api/push/xxxxxxxxxx?status=up&msg=start
PUSH_URL_SUCCESS=https://status.example.com/api/push/xxxxxxxxxx?status=up&msg=OK
PUSH_URL_FAIL=https://status.example.com/api/push/xxxxxxxxxx?status=down&msg=fail
```
## Usage
Run the cronjob that creates a backup, including the push notifications and docker logging:
`abra app cmd <backupbot_name> app run_cron`
Create a backup of all apps:
`abra app run <app_name> app -- backup create`
`abra app run <backupbot_name> app -- backup create`
> The apps to backup up need to be deployed
Create an individual backup:
`abra app run <app_name> app -- backup --host <target_app_name> create`
`abra app run <backupbot_name> app -- backup --host <target_app_name> create`
Create a backup to a local repository:
`abra app run <app_name> app -- backup create -r /backups/restic`
`abra app run <backupbot_name> app -- backup create -r /backups/restic`
> It is recommended to shutdown/undeploy an app before restoring the data
Restore the latest snapshot of all including apps:
`abra app run <app_name> app -- backup restore`
`abra app run <backupbot_name> app -- backup restore`
Restore a specific snapshot of an individual app:
`abra app run <app_name> app -- backup --host <target_app_name> restore --snapshot <snapshot_id>`
`abra app run <backupbot_name> app -- backup --host <target_app_name> restore --snapshot <snapshot_id>`
Show all snapshots:
`abra app run <app_name> app -- backup snapshots`
`abra app run <backupbot_name> app -- backup snapshots`
Show all snapshots containing a specific app:
`abra app run <app_name> app -- backup --host <target_app_name> snapshots`
`abra app run <backupbot_name> app -- backup --host <target_app_name> snapshots`
Show all files inside the latest snapshot (can be very verbose):
`abra app run <app_name> app -- backup ls`
`abra app run <backupbot_name> app -- backup ls`
Show specific files inside a selected snapshot:
`abra app run <app_name> app -- backup ls --snapshot <snapshot_id> --path /var/lib/docker/volumes/`
`abra app run <backupbot_name> app -- backup ls --snapshot <snapshot_id> --path /var/lib/docker/volumes/`
Download files from a snapshot:
```
filename=$(abra app run <app_name> app -- backup download --snapshot <snapshot_id> --path <absolute_path>)
abra app cp <app_name> app:$filename .
filename=$(abra app run <backupbot_name> app -- backup download --snapshot <snapshot_id> --path <absolute_path>)
abra app cp <backupbot_name> app:$filename .
```
## Run restic
```
abra app run <backupbot_name> app bash
export AWS_SECRET_ACCESS_KEY=$(cat $AWS_SECRET_ACCESS_KEY_FILE)
export RESTIC_PASSWORD=$(cat $RESTIC_PASSWORD_FILE)
restic snapshots
```
## Recipe Configuration

View File

@ -1,3 +1,10 @@
export ENTRYPOINT_VERSION=v1
export BACKUPBOT_VERSION=v1
export SSH_CONFIG_VERSION=v1
run_cron () {
schedule="$(crontab -l | tr -s " " | cut -d ' ' -f-5)"
rm -f /tmp/backup.log
echo "* * * * * $(crontab -l | tr -s " " | cut -d ' ' -f6-)" | crontab -
while [ ! -f /tmp/backup.log ]; do sleep 1; done
echo "$schedule $(crontab -l | tr -s " " | cut -d ' ' -f6-)" | crontab -
}

View File

@ -1,45 +1,73 @@
#!/usr/bin/python3
import os
import sys
import click
import json
import subprocess
import logging
import docker
import restic
import tarfile
import io
from pythonjsonlogger import jsonlogger
from datetime import datetime, timezone
from restic.errors import ResticFailedError
from pathlib import Path
from shutil import copyfile, rmtree
# logging.basicConfig(level=logging.INFO)
VOLUME_PATH = "/var/lib/docker/volumes/"
SECRET_PATH = '/secrets/'
SERVICE = None
logger = logging.getLogger("backupbot")
logging.addLevelName(55, 'SUMMARY')
setattr(logging, 'SUMMARY', 55)
setattr(logger, 'summary', lambda message, *args, **
kwargs: logger.log(55, message, *args, **kwargs))
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logger.critical("Uncaught exception", exc_info=(
exc_type, exc_value, exc_traceback))
sys.excepthook = handle_exception
@click.group()
@click.option('-l', '--log', 'loglevel')
@click.option('-m', '--machine-logs', 'machine_logs', is_flag=True)
@click.option('service', '--host', '-h', envvar='SERVICE')
@click.option('repository', '--repo', '-r', envvar='RESTIC_REPO', required=True)
def cli(loglevel, service, repository):
@click.option('repository', '--repo', '-r', envvar='RESTIC_REPOSITORY', required=True)
def cli(loglevel, service, repository, machine_logs):
global SERVICE
if service:
SERVICE = service.replace('.', '_')
if repository:
os.environ['RESTIC_REPO'] = repository
os.environ['RESTIC_REPOSITORY'] = repository
if loglevel:
numeric_level = getattr(logging, loglevel.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError('Invalid log level: %s' % loglevel)
logging.basicConfig(level=numeric_level)
logger.setLevel(numeric_level)
if machine_logs:
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
"%(levelname)s %(filename)s %(lineno)s %(process)d %(message)s", rename_fields={"levelname": "message_type"})
logHandler.setFormatter(formatter)
logger.addHandler(logHandler)
export_secrets()
init_repo()
def init_repo():
repo = os.environ['RESTIC_REPO']
logging.debug(f"set restic repository location: {repo}")
repo = os.environ['RESTIC_REPOSITORY']
logger.debug(f"set restic repository location: {repo}")
restic.repository = repo
restic.password_file = '/var/run/secrets/restic_password'
try:
@ -47,7 +75,7 @@ def init_repo():
except ResticFailedError as error:
if 'unable to open config file' in str(error):
result = restic.init()
logging.info(f"Initialized restic repo: {result}")
logger.info(f"Initialized restic repo: {result}")
else:
raise error
@ -55,20 +83,21 @@ def init_repo():
def export_secrets():
for env in os.environ:
if env.endswith('FILE') and not "COMPOSE_FILE" in env:
logging.debug(f"exported secret: {env}")
logger.debug(f"exported secret: {env}")
with open(os.environ[env]) as file:
secret = file.read()
secret = file.read()
os.environ[env.removesuffix('_FILE')] = secret
# logging.debug(f"Read secret value: {secret}")
# logger.debug(f"Read secret value: {secret}")
@cli.command()
def create():
@click.option('retries', '--retries', '-r', envvar='RETRIES', default=1)
def create(retries):
pre_commands, post_commands, backup_paths, apps = get_backup_cmds()
copy_secrets(apps)
backup_paths.append(SECRET_PATH)
run_commands(pre_commands)
backup_volumes(backup_paths, apps)
backup_volumes(backup_paths, apps, int(retries))
run_commands(post_commands)
@ -84,24 +113,28 @@ def get_backup_cmds():
for s in services:
labels = s.attrs['Spec']['Labels']
if (backup := labels.get('backupbot.backup')) and bool(backup):
# volumes: s.attrs['Spec']['TaskTemplate']['ContainerSpec']['Mounts'][0]['Source']
stack_name = labels['com.docker.stack.namespace']
if SERVICE and SERVICE != stack_name:
continue
# Remove this lines to backup only a specific service
# This will unfortenately decrease restice performance
# if SERVICE and SERVICE != stack_name:
# continue
backup_apps.add(stack_name)
container = container_by_service.get(s.name)
if not container:
logging.error(
backup_paths = backup_paths.union(
Path(VOLUME_PATH).glob(f"{stack_name}_*"))
if not (container := container_by_service.get(s.name)):
logger.error(
f"Container {s.name} is not running, hooks can not be executed")
continue
if prehook := labels.get('backupbot.backup.pre-hook'):
pre_commands[container] = prehook
if posthook := labels.get('backupbot.backup.post-hook'):
post_commands[container] = posthook
backup_paths = backup_paths.union(
Path(VOLUME_PATH).glob(f"{stack_name}_*"))
return pre_commands, post_commands, list(backup_paths), list(backup_apps)
def copy_secrets(apps):
# TODO: check if it is deployed
rmtree(SECRET_PATH, ignore_errors=True)
os.mkdir(SECRET_PATH)
client = docker.from_env()
@ -113,12 +146,16 @@ def copy_secrets(apps):
if (app_name in apps and
(app_secs := s.attrs['Spec']['TaskTemplate']['ContainerSpec'].get('Secrets'))):
if not container_by_service.get(s.name):
logging.error(
logger.error(
f"Container {s.name} is not running, secrets can not be copied.")
continue
container_id = container_by_service[s.name].id
for sec in app_secs:
src = f'/var/lib/docker/containers/{container_id}/mounts/secrets/{sec["SecretID"]}'
if not Path(src).exists():
logger.error(
f"For the secret {sec['SecretName']} the file {src} does not exist for {s.name}")
continue
dst = SECRET_PATH + sec['SecretName']
copyfile(src, dst)
@ -127,30 +164,43 @@ def run_commands(commands):
for container, command in commands.items():
if not command:
continue
# Remove bash/sh wrapping
command = command.removeprefix('bash -c').removeprefix('sh -c').removeprefix(' ')
# Remove quotes surrounding the command
if (len(command) >= 2 and command[0] == command[-1] and (command[0] == "'" or command[0] == '"')):
command = command[1:-1]
# Use bash's pipefail to return exit codes inside a pipe to prevent silent failure
command = command.removeprefix('bash -c \'').removeprefix('sh -c \'')
command = command.removesuffix('\'')
command = f"bash -c 'set -o pipefail;{command}'"
logger.info(f"run command in {container.name}:")
logger.info(command)
result = container.exec_run(command)
logging.info(f"run command in {container.name}")
logging.info(command)
if result.exit_code:
logging.error(
logger.error(
f"Failed to run command {command} in {container.name}: {result.output.decode()}")
else:
logging.info(result.output.decode())
logger.info(result.output.decode())
def backup_volumes(backup_paths, apps, dry_run=False):
result = restic.backup(backup_paths, dry_run=dry_run, tags=apps)
print(result)
logging.info(result)
def backup_volumes(backup_paths, apps, retries, dry_run=False):
while True:
try:
result = restic.backup(backup_paths, dry_run=dry_run, tags=apps)
logger.summary("backup finished", extra=result)
return
except ResticFailedError as error:
logger.error(
f"Backup failed for {apps}. Could not Backup these paths: {backup_paths}")
logger.error(error, exc_info=True)
if retries > 0:
retries -= 1
else:
exit(1)
@cli.command()
@click.option('snapshot', '--snapshot', '-s', envvar='SNAPSHOT', default='latest')
@click.option('target', '--target', '-t', envvar='TARGET', default='/')
@click.option('noninteractive', '--noninteractive', envvar='NONINTERACTIVE', default=False)
@click.option('noninteractive', '--noninteractive', envvar='NONINTERACTIVE', is_flag=True)
def restore(snapshot, target, noninteractive):
# Todo: recommend to shutdown the container
service_paths = VOLUME_PATH
@ -158,30 +208,41 @@ def restore(snapshot, target, noninteractive):
service_paths = service_paths + f'{SERVICE}_*'
snapshots = restic.snapshots(snapshot_id=snapshot)
if not snapshot:
logging.error("No Snapshots with ID {snapshots}")
logger.error("No Snapshots with ID {snapshots}")
exit(1)
if not noninteractive:
snapshot_date = datetime.fromisoformat(snapshots[0]['time'])
delta = datetime.now(tz=timezone.utc) - snapshot_date
print(f"You are going to restore Snapshot {snapshot} of {service_paths} at {target}")
print(
f"You are going to restore Snapshot {snapshot} of {service_paths} at {target}")
print(f"This snapshot is {delta} old")
print(f"THIS COMMAND WILL IRREVERSIBLY OVERWRITES {target}{service_paths.removeprefix('/')}")
print(
f"THIS COMMAND WILL IRREVERSIBLY OVERWRITES {target}{service_paths.removeprefix('/')}")
prompt = input("Type YES (uppercase) to continue: ")
if prompt != 'YES':
logging.error("Restore aborted")
logger.error("Restore aborted")
exit(1)
print(f"Restoring Snapshot {snapshot} of {service_paths} at {target}")
# TODO: use tags if no snapshot is selected, to use a snapshot including SERVICE
result = restic.restore(snapshot_id=snapshot,
include=service_paths, target_dir=target)
logging.debug(result)
logger.debug(result)
@cli.command()
def snapshots():
snapshots = restic.snapshots()
no_snapshots = True
for snap in snapshots:
if not SERVICE or (tags := snap.get('tags')) and SERVICE in tags:
print(snap['time'], snap['id'])
no_snapshots = False
if no_snapshots:
err_msg = "No Snapshots found"
if SERVICE:
service_name = SERVICE.replace('_', '.')
err_msg += f' for app {service_name}'
logger.warning(err_msg)
@cli.command()
@ -201,7 +262,17 @@ def list_files(snapshot, path):
cmd.append(snapshot)
if path:
cmd.append(path)
output = restic.internal.command_executor.execute(cmd)
try:
output = restic.internal.command_executor.execute(cmd)
except ResticFailedError as error:
if 'no snapshot found' in str(error):
err_msg = f'There is no snapshot "{snapshot}"'
if SERVICE:
err_msg += f' for the app "{SERVICE}"'
logger.error(err_msg)
exit(1)
else:
raise error
output = output.replace('}\n{', '}|{')
results = list(map(json.loads, output.split('|')))
return results
@ -210,62 +281,82 @@ def list_files(snapshot, path):
@cli.command()
@click.option('snapshot', '--snapshot', '-s', envvar='SNAPSHOT', default='latest')
@click.option('path', '--path', '-p', envvar='INCLUDE_PATH')
@click.option('volumes', '--volumes', '-v', is_flag=True)
@click.option('secrets', '--secrets', '-c', is_flag=True)
@click.option('volumes', '--volumes', '-v', envvar='VOLUMES')
@click.option('secrets', '--secrets', '-c', is_flag=True, envvar='SECRETS')
def download(snapshot, path, volumes, secrets):
if sum(map(bool, [path, volumes, secrets])) != 1:
logging.error("Please specify exactly one of '--path', '--volumes', '--secrets'")
exit(1)
file_dumps = []
if not any([path, volumes, secrets]):
volumes = secrets = True
if path:
path = path.removesuffix('/')
binary_output = dump(snapshot, path)
files = list_files(snapshot, path)
filetype = [f.get('type') for f in files if f.get('path') == path][0]
filename = "/tmp/" + Path(path).name
filename = Path(path).name
if filetype == 'dir':
filename = filename + ".tar"
output = dump(snapshot, path)
with open(filename, "wb") as file:
file.write(output)
print(filename)
elif volumes:
tarinfo = tarfile.TarInfo(name=filename)
tarinfo.size = len(binary_output)
file_dumps.append((binary_output, tarinfo))
if volumes:
if not SERVICE:
logging.error("Please specify '--host' when using '--volumes'")
logger.error("Please specify '--host' when using '--volumes'")
exit(1)
filename = f"/tmp/{SERVICE}.tar"
files = list_files(snapshot, VOLUME_PATH)
for f in files[1:]:
path = f[ 'path' ]
if SERVICE in path and f['type'] == 'dir':
content = dump(snapshot, path)
# Concatenate tar files (extract with tar -xi)
with open(filename, "ab") as file:
file.write(content)
elif secrets:
path = f['path']
if Path(path).name.startswith(SERVICE) and f['type'] == 'dir':
binary_output = dump(snapshot, path)
filename = f"{Path(path).name}.tar"
tarinfo = tarfile.TarInfo(name=filename)
tarinfo.size = len(binary_output)
file_dumps.append((binary_output, tarinfo))
if secrets:
if not SERVICE:
logging.error("Please specify '--host' when using '--secrets'")
logger.error("Please specify '--host' when using '--secrets'")
exit(1)
filename = f"/tmp/SECRETS_{SERVICE}.json"
filename = f"{SERVICE}.json"
files = list_files(snapshot, SECRET_PATH)
secrets = {}
for f in files[1:]:
path = f[ 'path' ]
if SERVICE in path and f['type'] == 'file':
path = f['path']
if Path(path).name.startswith(SERVICE) and f['type'] == 'file':
secret = dump(snapshot, path).decode()
secret_name = path.removeprefix(f'{SECRET_PATH}{SERVICE}_')
secrets[secret_name] = secret
with open(filename, "w") as file:
json.dump(secrets, file)
print(filename)
binary_output = json.dumps(secrets).encode()
tarinfo = tarfile.TarInfo(name=filename)
tarinfo.size = len(binary_output)
file_dumps.append((binary_output, tarinfo))
with tarfile.open('/tmp/backup.tar.gz', "w:gz") as tar:
print(f"Writing files to /tmp/backup.tar.gz...")
for binary_output, tarinfo in file_dumps:
tar.addfile(tarinfo, fileobj=io.BytesIO(binary_output))
size = get_formatted_size('/tmp/backup.tar.gz')
print(
f"Backup has been written to /tmp/backup.tar.gz with a size of {size}")
def get_formatted_size(file_path):
file_size = os.path.getsize(file_path)
units = ['Bytes', 'KB', 'MB', 'GB', 'TB']
for unit in units:
if file_size < 1024:
return f"{round(file_size, 3)} {unit}"
file_size /= 1024
return f"{round(file_size, 3)} {units[-1]}"
def dump(snapshot, path):
cmd = restic.cat.base_command() + ['dump']
if SERVICE:
cmd = cmd + ['--tag', SERVICE]
cmd = cmd +[snapshot, path]
logging.debug(f"Dumping {path} from snapshot '{snapshot}'")
cmd = cmd + [snapshot, path]
print(f"Dumping {path} from snapshot '{snapshot}'")
output = subprocess.run(cmd, capture_output=True)
if output.returncode:
logging.error(f"error while dumping {path} from snapshot '{snapshot}': {output.stderr}")
logger.error(
f"error while dumping {path} from snapshot '{snapshot}': {output.stderr}")
exit(1)
return output.stdout

View File

@ -3,7 +3,7 @@ version: "3.8"
services:
app:
environment:
- RESTIC_REPO_FILE=/run/secrets/restic_repo
- RESTIC_REPOSITORY_FILE=/run/secrets/restic_repo
secrets:
- restic_repo

View File

@ -2,7 +2,7 @@
version: "3.8"
services:
app:
image: docker:24.0.2-dind
image: git.coopcloud.tech/coop-cloud/backup-bot-two:2.0.0
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "/var/lib/docker/volumes/:/var/lib/docker/volumes/"
@ -10,7 +10,7 @@ services:
- backups:/backups
environment:
- CRON_SCHEDULE
- RESTIC_REPO
- RESTIC_REPOSITORY
- RESTIC_PASSWORD_FILE=/run/secrets/restic_password
secrets:
- restic_password
@ -19,14 +19,7 @@ services:
- coop-cloud.${STACK_NAME}.version=0.1.0+latest
- coop-cloud.${STACK_NAME}.timeout=${TIMEOUT:-300}
- coop-cloud.backupbot.enabled=true
configs:
- source: entrypoint
target: /entrypoint.sh
mode: 0555
- source: backupbot
target: /usr/bin/backup
mode: 0555
entrypoint: ['/entrypoint.sh']
#entrypoint: ['tail', '-f','/dev/null']
healthcheck:
test: "pgrep crond"
interval: 30s
@ -41,11 +34,3 @@ secrets:
volumes:
backups:
configs:
entrypoint:
name: ${STACK_NAME}_entrypoint_${ENTRYPOINT_VERSION}
file: entrypoint.sh
backupbot:
name: ${STACK_NAME}_backupbot_${BACKUPBOT_VERSION}
file: backupbot.py

24
entrypoint.sh Normal file → Executable file
View File

@ -1,11 +1,6 @@
#!/bin/sh
set -e -o pipefail
apk add --upgrade --no-cache restic bash python3 py3-pip
# Todo use requirements file with specific versions
pip install click==8.1.7 docker==6.1.3 resticpy==1.0.2
set -e
if [ -n "$SSH_HOST_KEY" ]
then
@ -14,7 +9,22 @@ fi
cron_schedule="${CRON_SCHEDULE:?CRON_SCHEDULE not set}"
echo "$cron_schedule backup create" | crontab -
if [ -n "$PUSH_URL_START" ]
then
push_start_notification="curl -s '$PUSH_URL_START' &&"
fi
if [ -n "$PUSH_URL_FAIL" ]
then
push_fail_notification="|| curl -s '$PUSH_URL_FAIL'"
fi
if [ -n "$PUSH_URL_SUCCESS" ]
then
push_notification=" && (grep -q 'backup finished' /tmp/backup.log && curl -s '$PUSH_URL_SUCCESS' $push_fail_notification)"
fi
echo "$cron_schedule $push_start_notification backup --machine-logs create 2>&1 | tee /tmp/backup.log $push_notification" | crontab -
crontab -l
crond -f -d8 -L /dev/stdout

View File

@ -1,3 +1,3 @@
Breaking Change: the variables `SERVER_NAME` and `RESTIC_HOST` are merged into `RESTIC_REPO`. The format can be looked up here: https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html
Breaking Change: the variables `SERVER_NAME` and `RESTIC_HOST` are merged into `RESTIC_REPOSITORY`. The format can be looked up here: https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html
ssh/sftp: `sftp:user@host:/repo-path`
S3: `s3:https://s3.example.com/bucket_name`