31 Commits

Author SHA1 Message Date
3wc
d998b61117 Don't duplicate stack name in vol path
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 22:02:54 +00:00
3wc
c93d5c6f44 set.add() returns None 🤡
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 22:00:35 +00:00
3wc
52e52a1e1d Set theory
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 21:56:51 +00:00
3wc
771cf31824 Unglob
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 21:55:21 +00:00
3wc
83834c6570 Whoops fix Mounts path
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 21:53:34 +00:00
3wc
98b5f077e2 Allow selective path spec
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 21:50:54 +00:00
3wc
ed687e52c3 Remove redundant stuff from entrypoint 2023-11-10 18:11:32 +00:00
3wc
cf06532da9 Whoops, wrong image 2023-11-10 18:09:50 +00:00
3wc
319deaba4b Switch to backup-bot-two image
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 16:55:41 +00:00
3wc
3c44300a2e Whoops skip shellcheck
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-10 14:53:48 +00:00
3wc
5ac3a48125 Reinstate Docker image
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-11-10 14:52:59 +00: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
10 changed files with 151 additions and 78 deletions

View File

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

View File

@ -4,7 +4,7 @@ SECRET_RESTIC_PASSWORD_VERSION=v1
COMPOSE_FILE=compose.yml COMPOSE_FILE=compose.yml
RESTIC_REPO=/backups/restic RESTIC_REPOSITORY=/backups/restic
CRON_SCHEDULE='30 3 * * *' CRON_SCHEDULE='30 3 * * *'
@ -22,8 +22,8 @@ CRON_SCHEDULE='30 3 * * *'
#COMPOSE_FILE="$COMPOSE_FILE:compose.s3.yml" #COMPOSE_FILE="$COMPOSE_FILE:compose.s3.yml"
# Secret restic repository # 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/ # 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 #SECRET_RESTIC_REPO_VERSION=v1
#COMPOSE_FILE="$COMPOSE_FILE:compose.secret.yml" #COMPOSE_FILE="$COMPOSE_FILE:compose.secret.yml"

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM docker:24.0.7-dind
RUN apk add --upgrade --no-cache restic bash python3 py3-pip
# Todo use requirements file with specific versions
RUN pip install click==8.1.7 docker==6.1.3 resticpy==1.0.2
COPY backupbot.py /usr/bin/backup
ENTRYPOINT /bin/bash

View File

@ -45,13 +45,13 @@ Backupbot II tries to help, by
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/<app_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 ### S3 Storage
To use S3 storage as backup location set the following envs: 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 SECRET_AWS_SECRET_ACCESS_KEY_VERSION=v1
AWS_ACCESS_KEY_ID=<MY_ACCESS_KEY> AWS_ACCESS_KEY_ID=<MY_ACCESS_KEY>
COMPOSE_FILE="$COMPOSE_FILE:compose.s3.yml" COMPOSE_FILE="$COMPOSE_FILE:compose.s3.yml"
@ -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: 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 SECRET_SSH_KEY_VERSION=v1
SSH_HOST_KEY="hostname ssh-rsa AAAAB3... SSH_HOST_KEY="hostname ssh-rsa AAAAB3...
COMPOSE_FILE="$COMPOSE_FILE:compose.ssh.yml" COMPOSE_FILE="$COMPOSE_FILE:compose.ssh.yml"
@ -85,7 +85,7 @@ abra app secret insert <app_name> ssh_key v1 """$(cat backupkey)
### Restic REST server Storage ### 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. 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: Uncomment these lines:
``` ```
@ -96,7 +96,7 @@ 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 <app_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. See [restic REST docs](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#rest-server) for more information.
@ -151,6 +151,14 @@ filename=$(abra app run <app_name> app -- backup download --snapshot <snapshot_i
abra app cp <app_name> app:$filename . abra app cp <app_name> app:$filename .
``` ```
## Run restic
```
abra app run <app_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 ## Recipe Configuration

View File

@ -1,3 +1,3 @@
export ENTRYPOINT_VERSION=v1 export ENTRYPOINT_VERSION=v2
export BACKUPBOT_VERSION=v1 export BACKUPBOT_VERSION=v1
export SSH_CONFIG_VERSION=v1 export SSH_CONFIG_VERSION=v1

View File

@ -7,6 +7,8 @@ import subprocess
import logging import logging
import docker import docker
import restic import restic
import tarfile
import io
from datetime import datetime, timezone from datetime import datetime, timezone
from restic.errors import ResticFailedError from restic.errors import ResticFailedError
from pathlib import Path from pathlib import Path
@ -21,13 +23,13 @@ SERVICE = None
@click.group() @click.group()
@click.option('-l', '--log', 'loglevel') @click.option('-l', '--log', 'loglevel')
@click.option('service', '--host', '-h', envvar='SERVICE') @click.option('service', '--host', '-h', envvar='SERVICE')
@click.option('repository', '--repo', '-r', envvar='RESTIC_REPO', required=True) @click.option('repository', '--repo', '-r', envvar='RESTIC_REPOSITORY', required=True)
def cli(loglevel, service, repository): def cli(loglevel, service, repository):
global SERVICE global SERVICE
if service: if service:
SERVICE = service.replace('.', '_') SERVICE = service.replace('.', '_')
if repository: if repository:
os.environ['RESTIC_REPO'] = repository os.environ['RESTIC_REPOSITORY'] = repository
if loglevel: if loglevel:
numeric_level = getattr(logging, loglevel.upper(), None) numeric_level = getattr(logging, loglevel.upper(), None)
if not isinstance(numeric_level, int): if not isinstance(numeric_level, int):
@ -38,7 +40,7 @@ def cli(loglevel, service, repository):
def init_repo(): def init_repo():
repo = os.environ['RESTIC_REPO'] repo = os.environ['RESTIC_REPOSITORY']
logging.debug(f"set restic repository location: {repo}") logging.debug(f"set restic repository location: {repo}")
restic.repository = repo restic.repository = repo
restic.password_file = '/var/run/secrets/restic_password' restic.password_file = '/var/run/secrets/restic_password'
@ -57,7 +59,7 @@ def export_secrets():
if env.endswith('FILE') and not "COMPOSE_FILE" in env: if env.endswith('FILE') and not "COMPOSE_FILE" in env:
logging.debug(f"exported secret: {env}") logging.debug(f"exported secret: {env}")
with open(os.environ[env]) as file: with open(os.environ[env]) as file:
secret = file.read() secret = file.read()
os.environ[env.removesuffix('_FILE')] = secret os.environ[env.removesuffix('_FILE')] = secret
# logging.debug(f"Read secret value: {secret}") # logging.debug(f"Read secret value: {secret}")
@ -83,25 +85,35 @@ def get_backup_cmds():
services = client.services.list() services = client.services.list()
for s in services: for s in services:
labels = s.attrs['Spec']['Labels'] labels = s.attrs['Spec']['Labels']
mounts = s.attrs['Spec']['TaskTemplate']['ContainerSpec']['Mounts']
if (backup := labels.get('backupbot.backup')) and bool(backup): if (backup := labels.get('backupbot.backup')) and bool(backup):
stack_name = labels['com.docker.stack.namespace'] stack_name = labels['com.docker.stack.namespace']
if SERVICE and SERVICE != stack_name: # Remove this lines to backup only a specific service
continue # This will unfortenately decrease restice performance
# if SERVICE and SERVICE != stack_name:
# continue
backup_apps.add(stack_name) backup_apps.add(stack_name)
container = container_by_service.get(s.name) for mount in mounts:
if not container: if path := labels.get('backupbot.backup.path'):
path_ = Path(VOLUME_PATH) / f"{mount['Source']}/_data/{path}"
else:
path_ = Path(VOLUME_PATH) / f"{mount['Source']}"
logging.debug(
f"Added backup path {path_}")
backup_paths.add(path_)
if not (container := container_by_service.get(s.name)):
logging.error( logging.error(
f"Container {s.name} is not running, hooks can not be executed") f"Container {s.name} is not running, hooks can not be executed")
continue
if prehook := labels.get('backupbot.backup.pre-hook'): if prehook := labels.get('backupbot.backup.pre-hook'):
pre_commands[container] = prehook pre_commands[container] = prehook
if posthook := labels.get('backupbot.backup.post-hook'): if posthook := labels.get('backupbot.backup.post-hook'):
post_commands[container] = posthook 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) return pre_commands, post_commands, list(backup_paths), list(backup_apps)
def copy_secrets(apps): def copy_secrets(apps):
#TODO: check if it is deployed
rmtree(SECRET_PATH, ignore_errors=True) rmtree(SECRET_PATH, ignore_errors=True)
os.mkdir(SECRET_PATH) os.mkdir(SECRET_PATH)
client = docker.from_env() client = docker.from_env()
@ -119,6 +131,9 @@ def copy_secrets(apps):
container_id = container_by_service[s.name].id container_id = container_by_service[s.name].id
for sec in app_secs: for sec in app_secs:
src = f'/var/lib/docker/containers/{container_id}/mounts/secrets/{sec["SecretID"]}' src = f'/var/lib/docker/containers/{container_id}/mounts/secrets/{sec["SecretID"]}'
if not Path(src).exists():
logging.error(f"For the secret {sec['SecretName']} the file {src} does not exist for {s.name}")
continue
dst = SECRET_PATH + sec['SecretName'] dst = SECRET_PATH + sec['SecretName']
copyfile(src, dst) copyfile(src, dst)
@ -127,13 +142,16 @@ def run_commands(commands):
for container, command in commands.items(): for container, command in commands.items():
if not command: if not command:
continue continue
# Remove bash/sh wrapping
command = command.removeprefix('bash -c').removeprefix('sh -c')
# Remove quotes surrounding the command
if (len(command) >= 2 and command[0] == command[-1] and (command[0] == "'" or command[0] == '"')):
command[1:-1]
# Use bash's pipefail to return exit codes inside a pipe to prevent silent failure # 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}'" command = f"bash -c 'set -o pipefail;{command}'"
result = container.exec_run(command) logging.info(f"run command in {container.name}:")
logging.info(f"run command in {container.name}")
logging.info(command) logging.info(command)
result = container.exec_run(command)
if result.exit_code: if result.exit_code:
logging.error( logging.error(
f"Failed to run command {command} in {container.name}: {result.output.decode()}") f"Failed to run command {command} in {container.name}: {result.output.decode()}")
@ -142,9 +160,14 @@ def run_commands(commands):
def backup_volumes(backup_paths, apps, dry_run=False): def backup_volumes(backup_paths, apps, dry_run=False):
result = restic.backup(backup_paths, dry_run=dry_run, tags=apps) try:
print(result) result = restic.backup(backup_paths, dry_run=dry_run, tags=apps)
logging.info(result) print(result)
logging.info(result)
except ResticFailedError as error:
logging.error(f"Backup failed for {apps}. Could not Backup these paths: {backup_paths}")
logging.error(error)
exit(1)
@cli.command() @cli.command()
@ -163,9 +186,11 @@ def restore(snapshot, target, noninteractive):
if not noninteractive: if not noninteractive:
snapshot_date = datetime.fromisoformat(snapshots[0]['time']) snapshot_date = datetime.fromisoformat(snapshots[0]['time'])
delta = datetime.now(tz=timezone.utc) - snapshot_date 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 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: ") prompt = input("Type YES (uppercase) to continue: ")
if prompt != 'YES': if prompt != 'YES':
logging.error("Restore aborted") logging.error("Restore aborted")
@ -179,9 +204,16 @@ def restore(snapshot, target, noninteractive):
@cli.command() @cli.command()
def snapshots(): def snapshots():
snapshots = restic.snapshots() snapshots = restic.snapshots()
no_snapshots = True
for snap in snapshots: for snap in snapshots:
if not SERVICE or (tags := snap.get('tags')) and SERVICE in tags: if not SERVICE or (tags := snap.get('tags')) and SERVICE in tags:
print(snap['time'], snap['id']) print(snap['time'], snap['id'])
no_snapshots = False
if no_snapshots:
err_msg = "No Snapshots found"
if SERVICE:
err_msg += f' for app {SERVICE}'
logging.warning(err_msg)
@cli.command() @cli.command()
@ -201,7 +233,17 @@ def list_files(snapshot, path):
cmd.append(snapshot) cmd.append(snapshot)
if path: if path:
cmd.append(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}'
logging.error(err_msg)
exit(1)
else:
raise error
output = output.replace('}\n{', '}|{') output = output.replace('}\n{', '}|{')
results = list(map(json.loads, output.split('|'))) results = list(map(json.loads, output.split('|')))
return results return results
@ -213,59 +255,78 @@ def list_files(snapshot, path):
@click.option('volumes', '--volumes', '-v', is_flag=True) @click.option('volumes', '--volumes', '-v', is_flag=True)
@click.option('secrets', '--secrets', '-c', is_flag=True) @click.option('secrets', '--secrets', '-c', is_flag=True)
def download(snapshot, path, volumes, secrets): def download(snapshot, path, volumes, secrets):
if sum(map(bool, [path, volumes, secrets])) != 1: file_dumps = []
logging.error("Please specify exactly one of '--path', '--volumes', '--secrets'") if not any([path, volumes, secrets]):
exit(1) volumes = secrets = True
if path: if path:
path = path.removesuffix('/') path = path.removesuffix('/')
binary_output = dump(snapshot, path)
files = list_files(snapshot, path) files = list_files(snapshot, path)
filetype = [f.get('type') for f in files if f.get('path') == path][0] 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': if filetype == 'dir':
filename = filename + ".tar" filename = filename + ".tar"
output = dump(snapshot, path) tarinfo = tarfile.TarInfo(name=filename)
with open(filename, "wb") as file: tarinfo.size = len(binary_output)
file.write(output) file_dumps.append((binary_output, tarinfo))
print(filename) if volumes:
elif volumes:
if not SERVICE: if not SERVICE:
logging.error("Please specify '--host' when using '--volumes'") logging.error("Please specify '--host' when using '--volumes'")
exit(1) exit(1)
filename = f"/tmp/{SERVICE}.tar"
files = list_files(snapshot, VOLUME_PATH) files = list_files(snapshot, VOLUME_PATH)
for f in files[1:]: for f in files[1:]:
path = f[ 'path' ] path = f['path']
if SERVICE in path and f['type'] == 'dir': if Path(path).name.startswith(SERVICE) and f['type'] == 'dir':
content = dump(snapshot, path) binary_output = dump(snapshot, path)
# Concatenate tar files (extract with tar -xi) filename = f"{Path(path).name}.tar"
with open(filename, "ab") as file: tarinfo = tarfile.TarInfo(name=filename)
file.write(content) tarinfo.size = len(binary_output)
elif secrets: file_dumps.append((binary_output, tarinfo))
if secrets:
if not SERVICE: if not SERVICE:
logging.error("Please specify '--host' when using '--secrets'") logging.error("Please specify '--host' when using '--secrets'")
exit(1) exit(1)
filename = f"/tmp/SECRETS_{SERVICE}.json" filename = f"{SERVICE}.json"
files = list_files(snapshot, SECRET_PATH) files = list_files(snapshot, SECRET_PATH)
secrets = {} secrets = {}
for f in files[1:]: for f in files[1:]:
path = f[ 'path' ] path = f['path']
if SERVICE in path and f['type'] == 'file': if Path(path).name.startswith(SERVICE) and f['type'] == 'file':
secret = dump(snapshot, path).decode() secret = dump(snapshot, path).decode()
secret_name = path.removeprefix(f'{SECRET_PATH}{SERVICE}_') secret_name = path.removeprefix(f'{SECRET_PATH}{SERVICE}_')
secrets[secret_name] = secret secrets[secret_name] = secret
with open(filename, "w") as file: binary_output = json.dumps(secrets).encode()
json.dump(secrets, file) tarinfo = tarfile.TarInfo(name=filename)
print(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): def dump(snapshot, path):
cmd = restic.cat.base_command() + ['dump'] cmd = restic.cat.base_command() + ['dump']
if SERVICE: if SERVICE:
cmd = cmd + ['--tag', SERVICE] cmd = cmd + ['--tag', SERVICE]
cmd = cmd +[snapshot, path] cmd = cmd + [snapshot, path]
logging.debug(f"Dumping {path} from snapshot '{snapshot}'") print(f"Dumping {path} from snapshot '{snapshot}'")
output = subprocess.run(cmd, capture_output=True) output = subprocess.run(cmd, capture_output=True)
if output.returncode: if output.returncode:
logging.error(f"error while dumping {path} from snapshot '{snapshot}': {output.stderr}") logging.error(
f"error while dumping {path} from snapshot '{snapshot}': {output.stderr}")
exit(1) exit(1)
return output.stdout return output.stdout

View File

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

View File

@ -2,7 +2,7 @@
version: "3.8" version: "3.8"
services: services:
app: app:
image: docker:24.0.2-dind image: git.coopcloud.tech/coop-cloud/backup-bot-two:2.0.0
volumes: volumes:
- "/var/run/docker.sock:/var/run/docker.sock" - "/var/run/docker.sock:/var/run/docker.sock"
- "/var/lib/docker/volumes/:/var/lib/docker/volumes/" - "/var/lib/docker/volumes/:/var/lib/docker/volumes/"
@ -10,7 +10,7 @@ services:
- backups:/backups - backups:/backups
environment: environment:
- CRON_SCHEDULE - CRON_SCHEDULE
- RESTIC_REPO - RESTIC_REPOSITORY
- RESTIC_PASSWORD_FILE=/run/secrets/restic_password - RESTIC_PASSWORD_FILE=/run/secrets/restic_password
secrets: secrets:
- restic_password - restic_password
@ -23,9 +23,6 @@ services:
- source: entrypoint - source: entrypoint
target: /entrypoint.sh target: /entrypoint.sh
mode: 0555 mode: 0555
- source: backupbot
target: /usr/bin/backup
mode: 0555
entrypoint: ['/entrypoint.sh'] entrypoint: ['/entrypoint.sh']
healthcheck: healthcheck:
test: "pgrep crond" test: "pgrep crond"
@ -46,6 +43,3 @@ configs:
entrypoint: entrypoint:
name: ${STACK_NAME}_entrypoint_${ENTRYPOINT_VERSION} name: ${STACK_NAME}_entrypoint_${ENTRYPOINT_VERSION}
file: entrypoint.sh file: entrypoint.sh
backupbot:
name: ${STACK_NAME}_backupbot_${BACKUPBOT_VERSION}
file: backupbot.py

View File

@ -2,11 +2,6 @@
set -e -o pipefail 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
if [ -n "$SSH_HOST_KEY" ] if [ -n "$SSH_HOST_KEY" ]
then then
echo "$SSH_HOST_KEY" > /root/.ssh/known_hosts echo "$SSH_HOST_KEY" > /root/.ssh/known_hosts

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` ssh/sftp: `sftp:user@host:/repo-path`
S3: `s3:https://s3.example.com/bucket_name` S3: `s3:https://s3.example.com/bucket_name`