Compare commits

...

7 Commits

Author SHA1 Message Date
dannygroenewegen fc5c6989e8 fix: download without buffering volumes in RAM
Fetch each path with 'restic restore' into a temp dir, instead of
buffering whole volume dumps in memory, which crashed on large volumes.
Restore is also much faster than the sequential 'restic dump'.
The archive layout is unchanged.
2026-07-10 14:56:56 +02:00
moritz fa5078eb6e Merge pull request 'chore(deps): update docker docker tag to v29.4.3' (#87) from renovate/docker-29.x into main
Reviewed-on: coop-cloud/backup-bot-two#87
2026-05-12 12:27:35 +00:00
renovate-bot 3bbef4a0de chore(deps): update docker docker tag to v29.4.3 2026-05-12 12:27:22 +00:00
moritz f3924969e8 Merge pull request 'fix backup download instruction and some beautifying' (#90) from doc-download into main
Reviewed-on: coop-cloud/backup-bot-two#90
2026-05-12 12:26:44 +00:00
stevensting ff7c9c5cd8 fix backup download instruction and some beautifying 2026-05-12 12:26:07 +00:00
moritz 25f977c312 Merge pull request 'fix: Use actual CRON_SCHEDULE for swarm-cronjob instead of demo */5' (#88) from fix/3wc/swarm-cronjob-schedule into main
Reviewed-on: coop-cloud/backup-bot-two#88
Reviewed-by: decentral1se <decentral1se@noreply.git.coopcloud.tech>
2026-05-11 15:44:24 +00:00
3wordchant 6255edc834 fix: Use actual CRON_SCHEDULE for swarm-cronjob instead of demo */5 2026-04-11 16:59:25 -04:00
4 changed files with 100 additions and 79 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM docker:29.3.1-dind
FROM docker:29.4.3-dind
RUN apk add --upgrade --no-cache restic bash python3 py3-pip py3-click py3-docker-py py3-json-logger curl
+17 -15
View File
@@ -137,55 +137,57 @@ Enable basic auth in the env file, by uncommenting the following line:
## Usage
Run the cronjob that creates a backup, including the push notifications and docker logging:
### 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:
### Create a backup of all apps:
`abra app run <backupbot_name> app -- backup create`
> The apps to backup up need to be deployed
Create an individual backup:
### Create an individual backup:
`abra app run <backupbot_name> app -- backup --host <target_app_name> create`
Create a backup to a local repository:
### Create a backup to a local repository:
`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:
### Restore the latest snapshot of all including apps:
`abra app run <backupbot_name> app -- backup restore`
Restore a specific snapshot of an individual app:
### Restore a specific snapshot of an individual app:
`abra app run <backupbot_name> app -- backup --host <target_app_name> restore --snapshot <snapshot_id>`
Show all snapshots:
### Show all snapshots:
`abra app run <backupbot_name> app -- backup snapshots`
Show all snapshots containing a specific app:
### Show all snapshots containing a specific app:
`abra app run <backupbot_name> app -- backup --host <target_app_name> snapshots`
Show all files inside the latest snapshot (can be very verbose):
### Show all files inside the latest snapshot (can be very verbose):
`abra app run <backupbot_name> app -- backup ls`
Show specific files inside a selected snapshot:
### Show specific files inside a selected snapshot:
`abra app run <backupbot_name> app -- backup ls --snapshot <snapshot_id> /var/lib/docker/volumes/`
Download files from a snapshot:
### Download files from a snapshot:
```
filename=$(abra app run <backupbot_name> app -- backup download --snapshot <snapshot_id> --path <absolute_path>)
abra app cp <backupbot_name> app:$filename .
```
`abra app run <backupbot_name> app -- backup download --snapshot <snapshot_id> --path <absolute_path>`
extract the <path> from the output of the first command and use in the second.
`abra app cp <backupbot_name> app:<path> .`
## Run restic
+81 -60
View File
@@ -9,7 +9,6 @@ import logging
import docker
import restic
import tarfile
import io
from pythonjsonlogger import jsonlogger
from datetime import datetime, timezone
from restic.errors import ResticFailedError
@@ -19,6 +18,8 @@ from shutil import copyfile, rmtree
VOLUME_PATH = "/var/lib/docker/volumes/"
SECRET_PATH = "/secrets/"
SERVICE = "ALL"
BACKUP_FILE = "/tmp/backup.tar.gz"
DOWNLOAD_TMP_PATH = "/tmp/backup-download"
logger = logging.getLogger("backupbot")
logging.addLevelName(55, "SUMMARY")
@@ -573,53 +574,85 @@ def download(snapshot, path, volumes, secrets):
snapshot = latest_snapshot[0]["short_id"]
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 = Path(path).name
if filetype == "dir":
filename = filename + ".tar"
tarinfo = tarfile.TarInfo(name=filename)
tarinfo.size = len(binary_output)
file_dumps.append((binary_output, tarinfo))
if volumes:
if SERVICE == "ALL":
logger.error("Please specify '--host' when using '--volumes'")
exit(1)
files = list_files(snapshot, VOLUME_PATH)
for f in files[1:]:
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 SERVICE == "ALL":
logger.error("Please specify '--host' when using '--secrets'")
exit(1)
filename = f"{SERVICE}.json"
files = list_files(snapshot, SECRET_PATH)
secrets = {}
for f in files[1:]:
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
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}")
rmtree(DOWNLOAD_TMP_PATH, ignore_errors=True)
os.makedirs(DOWNLOAD_TMP_PATH)
restore_dir = os.path.join(DOWNLOAD_TMP_PATH, "restore")
try:
if path:
path = path.removesuffix("/")
files = list_files(snapshot, path)
filetype = [f.get("type") for f in files if f.get("path") == path][0]
filename = Path(path).name
print(f"Restoring {path} from snapshot '{snapshot}'")
restic_restore(snapshot_id=snapshot, include=[path], target_dir=restore_dir)
if filetype == "dir":
filename = filename + ".tar"
create_tar(restore_dir, path, os.path.join(DOWNLOAD_TMP_PATH, filename))
else:
os.replace(
f"{restore_dir}{path}", os.path.join(DOWNLOAD_TMP_PATH, filename)
)
rmtree(restore_dir)
file_dumps.append(filename)
if volumes:
if SERVICE == "ALL":
logger.error("Please specify '--host' when using '--volumes'")
exit(1)
files = list_files(snapshot, VOLUME_PATH)
for f in files[1:]:
path = f["path"]
if Path(path).name.startswith(SERVICE) and f["type"] == "dir":
filename = f"{Path(path).name}.tar"
print(f"Restoring {path} from snapshot '{snapshot}'")
restic_restore(snapshot_id=snapshot, include=[path], target_dir=restore_dir)
create_tar(restore_dir, path, os.path.join(DOWNLOAD_TMP_PATH, filename))
rmtree(restore_dir)
file_dumps.append(filename)
if secrets:
if SERVICE == "ALL":
logger.error("Please specify '--host' when using '--secrets'")
exit(1)
filename = f"{SERVICE}.json"
print(f"Restoring {SECRET_PATH} from snapshot '{snapshot}'")
restic_restore(
snapshot_id=snapshot,
include=[SECRET_PATH.removesuffix("/")],
target_dir=restore_dir,
)
secrets = read_secrets(f"{restore_dir}{SECRET_PATH}")
rmtree(restore_dir)
with open(os.path.join(DOWNLOAD_TMP_PATH, filename), "w") as file:
json.dump(secrets, file)
file_dumps.append(filename)
print(f"Writing files to {BACKUP_FILE}...")
with tarfile.open(BACKUP_FILE, "w:gz") as tar:
for filename in file_dumps:
tar.add(os.path.join(DOWNLOAD_TMP_PATH, filename), arcname=filename)
finally:
rmtree(DOWNLOAD_TMP_PATH, ignore_errors=True)
size = get_formatted_size(BACKUP_FILE)
print(f"Backup has been written to {BACKUP_FILE} with a size of {size}")
def create_tar(restore_dir, path, tar_file):
# The archive contains the children of the given directory,
# named by their full path without the leading slash.
with tarfile.open(tar_file, "w") as tar:
for child in sorted(os.listdir(f"{restore_dir}{path}")):
tar.add(
f"{restore_dir}{path}/{child}",
arcname=f"{path}/{child}".removeprefix("/"),
)
def read_secrets(secret_dir):
secrets = {}
for name in sorted(os.listdir(secret_dir)):
secret_path = os.path.join(secret_dir, name)
if name.startswith(SERVICE) and os.path.isfile(secret_path):
with open(secret_path) as file:
secrets[name.removeprefix(f"{SERVICE}_")] = file.read()
return secrets
def get_formatted_size(file_path):
@@ -632,18 +665,6 @@ def get_formatted_size(file_path):
return f"{round(file_size, 3)} {units[-1]}"
def dump(snapshot, path):
cmd = restic.cat.base_command() + ["dump"]
cmd = cmd + [snapshot, path]
print(f"Dumping {path} from snapshot '{snapshot}'")
output = subprocess.run(cmd, capture_output=True)
if output.returncode:
logger.error(
f"error while dumping {path} from snapshot '{snapshot}': {output.stderr}"
)
exit(1)
return output.stdout
if __name__ == "__main__":
cli()
+1 -3
View File
@@ -7,9 +7,7 @@ services:
replicas: 0
labels:
- "swarm.cronjob.enable=true"
# Note(3wc): every 5m, testing
- "swarm.cronjob.schedule=*/5 * * * *"
# Note(3wc): blank label to be picked up by `abra recipe sync`
- "swarm.cronjob.schedule=${CRON_SCHEDULE}"
restart_policy:
condition: none
entrypoint: [ "/usr/bin/backup.sh" ]