restic-compose-backup/src/restic_compose_backup/cli.py

378 lines
12 KiB
Python
Raw Normal View History

2019-04-15 18:31:08 +02:00
import argparse
import os
2019-11-15 14:23:56 +01:00
import logging
2019-04-15 18:31:08 +02:00
2019-12-03 09:40:02 +01:00
from restic_compose_backup import (
2019-12-04 19:36:32 +01:00
alerts,
2019-12-03 09:40:02 +01:00
backup_runner,
log,
restic,
)
from restic_compose_backup.config import Config
from restic_compose_backup.containers import RunningContainers, Container
2019-12-08 06:38:56 +01:00
from restic_compose_backup import cron, utils
2019-04-13 19:04:54 +02:00
2019-11-15 14:23:56 +01:00
logger = logging.getLogger(__name__)
2019-04-13 19:04:54 +02:00
def main():
2019-11-15 14:23:56 +01:00
"""CLI entrypoint"""
2019-04-15 18:31:08 +02:00
args = parse_args()
config = Config()
log.setup(level=args.log_level or config.log_level)
2019-04-13 19:04:54 +02:00
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.no_cleanup:
config.skip_cleanup = True
2019-04-15 18:31:08 +02:00
if args.action == 'status':
2019-04-18 05:01:02 +02:00
status(config, containers)
2019-04-17 00:08:24 +02:00
2019-12-04 19:36:32 +01:00
elif args.action == 'snapshots':
2019-12-04 03:12:36 +01:00
snapshots(config, containers)
2019-04-18 05:01:02 +02:00
elif args.action == 'backup':
backup(config, containers)
2019-04-17 00:08:24 +02:00
2019-04-18 05:01:02 +02:00
elif args.action == 'start-backup-process':
start_backup_process(config, containers)
2019-04-13 19:04:54 +02:00
2019-12-04 22:03:49 +01:00
elif args.action == 'cleanup':
cleanup(config, containers)
2019-12-04 19:36:32 +01:00
elif args.action == 'alert':
alert(config, containers)
2019-12-06 07:35:14 +01:00
elif args.action == 'version':
import restic_compose_backup
print(restic_compose_backup.__version__)
2019-12-08 06:38:56 +01:00
elif args.action == "crontab":
crontab(config)
2020-03-08 18:36:29 +01:00
# Random test stuff here
elif args.action == "test":
nodes = utils.get_swarm_nodes()
print("Swarm nodes:")
for node in nodes:
addr = node.attrs['Status']['Addr']
state = node.attrs['Status']['State']
print(' - {} {} {}'.format(node.id, addr, state))
2019-04-17 04:38:15 +02:00
2019-04-18 05:01:02 +02:00
def status(config, containers):
2019-11-12 12:39:49 +01:00
"""Outputs the backup config for the compose setup"""
2019-12-04 19:36:32 +01:00
logger.info("Status for compose project '%s'", containers.project_name)
logger.info("Repository: '%s'", config.repository)
2019-12-04 01:12:26 +01:00
logger.info("Backup currently running?: %s", containers.backup_process_running)
logger.info("Include project name in backup path?: %s", utils.is_true(config.include_project_name))
logger.debug("Exclude bind mounts from backups?: %s", utils.is_true(config.exclude_bind_mounts))
logger.info("Checking docker availability")
utils.list_containers()
2019-12-08 00:57:23 +01:00
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)
2019-12-05 12:54:34 +01:00
# Start making snapshots
backup_containers = containers.containers_for_backup()
for container in backup_containers:
2019-11-29 01:32:09 +01:00
logger.info('service: %s', container.service_name)
if container.minecraft_backup_enabled:
instance = container.instance
ping = instance.ping()
logger.info(
' - %s (is_ready=%s):',
instance.container_type,
ping == 0
)
for mount in container.filter_mounts():
logger.info(
' - volume: %s -> %s',
mount.source,
container.get_volume_backup_destination(mount, '/minecraft'),
)
2019-12-03 03:04:49 +01:00
if container.volume_backup_enabled:
for mount in container.filter_mounts():
2020-05-28 00:13:27 +02:00
logger.info(
' - volume: %s -> %s',
mount.source,
container.get_volume_backup_destination(mount, '/volumes'),
)
2019-11-29 01:32:09 +01:00
2019-12-03 01:47:58 +01:00
if container.database_backup_enabled:
instance = container.instance
2019-12-03 03:04:49 +01:00
ping = instance.ping()
2020-05-28 00:13:27 +02:00
logger.info(
' - %s (is_ready=%s) -> %s',
instance.container_type,
ping == 0,
instance.backup_destination_path(),
)
2019-12-04 01:12:26 +01:00
if ping != 0:
2019-12-05 11:09:36 +01:00
logger.error("Database '%s' in service %s cannot be reached",
instance.container_type, container.service_name)
2019-12-02 22:53:00 +01:00
if len(backup_containers) == 0:
2019-12-05 12:30:50 +01:00
logger.info("No containers in the project has 'restic-compose-backup.*' label")
2019-04-18 05:01:02 +02:00
2019-12-04 01:12:26 +01:00
logger.info("-" * 67)
2019-04-18 05:01:02 +02:00
def backup(config, containers):
2019-11-15 14:23:56 +01:00
"""Request a backup to start"""
2019-04-18 05:01:02 +02:00
# 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")
2019-04-18 05:01:02 +02:00
# Map all volumes from the backup container into the backup process container
2019-11-15 14:23:56 +01:00
volumes = containers.this_container.volumes
# Map volumes from other containers we are backing up
2019-12-04 22:17:42 +01:00
mounts = containers.generate_backup_mounts('/volumes')
volumes.update(mounts)
mounts = containers.generate_minecraft_mounts('/minecraft')
volumes.update(mounts)
2019-12-10 07:57:37 +01:00
logger.debug('Starting backup container with image %s', containers.this_container.image)
2019-12-05 10:16:34 +01:00
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',
2019-12-05 10:16:34 +01:00
"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
2019-12-03 07:36:48 +01:00
logger.info('Backup container exit code: %s', result)
2019-04-14 01:35:14 +02:00
2019-12-04 21:24:10 +01:00
# 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',
)
else:
alerts.send(
subject="Backup successfully completed",
body=open('backup.log').read(),
alert_type='INFO'
)
2019-12-04 21:24:10 +01:00
2019-04-14 01:35:14 +02:00
def start_backup_process(config, containers):
2019-11-15 16:47:40 +01:00
"""The actual backup process running inside the spawned container"""
if not utils.is_true(os.environ.get('BACKUP_PROCESS_CONTAINER')):
2019-11-15 16:47:40 +01:00
logger.error(
"Cannot run backup process in this container. Use backup command instead. "
"This will spawn a new container with the necessary mounts."
)
2019-12-10 07:57:37 +01:00
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
2019-11-15 14:23:56 +01:00
# Did we actually get any volumes mounted?
2019-12-03 07:36:48 +01:00
try:
has_volumes = os.stat('/volumes') is not None
except FileNotFoundError:
logger.warning("Found no volumes to back up")
has_volumes = False
try:
has_minecraft_volumes = os.stat('/minecraft') is not None
except FileNotFoundError:
logger.warning("Found no minecraft servers to back up")
has_minecraft_volumes = False
# Warn if there is nothing to do
backup_containers = containers.containers_for_backup()
if len(backup_containers) == 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
if has_minecraft_volumes:
logger.info('Backing up minecraft servers')
for container in containers.containers_for_backup():
if container.minecraft_backup_enabled:
2020-11-16 12:59:59 +01:00
try:
result = backup_container_instance(container)
if result != 0:
logger.error('Backup command exited with non-zero code: %s', result)
2020-11-16 12:59:59 +01:00
errors = True
except Exception as ex:
logger.exception(ex)
errors = True
2019-11-12 12:39:49 +01:00
2019-12-03 04:22:24 +01:00
# back up databases
2019-12-06 08:21:06 +01:00
logger.info('Backing up databases')
2019-12-03 04:22:24 +01:00
for container in containers.containers_for_backup():
if container.database_backup_enabled:
2019-12-03 07:36:48 +01:00
try:
result = backup_container_instance(container)
2019-12-04 01:58:01 +01:00
if result != 0:
logger.error('Backup command exited with non-zero code: %s', result)
errors = True
2019-12-03 07:36:48 +01:00
except Exception as ex:
2019-12-05 10:16:34 +01:00
logger.exception(ex)
errors = True
if errors:
2019-12-06 08:21:06 +01:00
logger.error('Exit code: %s', errors)
2019-12-04 21:24:10 +01:00
exit(1)
2019-12-03 04:22:24 +01:00
# Only run cleanup if backup was successful
if not config.skip_cleanup:
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)
2019-12-04 23:25:15 +01:00
exit(1)
2019-12-03 04:22:24 +01:00
2019-12-06 08:21:06 +01:00
logger.info('Backup completed')
def backup_container_instance(container: Container) -> int:
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)
return result
2019-12-04 22:03:49 +01:00
2019-12-04 23:25:15 +01:00
def cleanup(config, containers):
"""Run forget / prune to minimize storage space"""
logger.info('Forget outdated snapshots')
forget_result = restic.forget(
config.repository,
2020-11-16 11:32:40 +01:00
config.keep_last,
config.keep_hourly,
2019-12-04 23:25:15 +01:00
config.keep_daily,
config.keep_weekly,
config.keep_monthly,
config.keep_yearly,
2020-11-16 11:32:40 +01:00
config.keep_tags,
config.filter_tags
2019-12-04 23:25:15 +01:00
)
logger.info('Prune stale data freeing storage space')
prune_result = restic.prune(config.repository)
return forget_result and prune_result
2019-12-04 22:03:49 +01:00
2019-12-05 11:09:36 +01:00
2019-12-04 22:03:49 +01:00
def snapshots(config, containers):
"""Display restic snapshots"""
stdout, stderr = restic.snapshots(config.repository, last=True)
for line in stdout.decode().split('\n'):
print(line)
2019-12-04 19:36:32 +01:00
def alert(config, containers):
"""Test alerts"""
logger.info("Testing alerts")
alerts.send(
subject="{}: Test Alert".format(containers.project_name),
body="Test message",
)
2019-12-04 19:36:32 +01:00
2019-12-08 06:38:56 +01:00
def crontab(config):
"""Generate the crontab"""
print(cron.generate_crontab(config))
2019-04-15 18:31:08 +02:00
def parse_args():
2019-12-03 09:40:02 +01:00
parser = argparse.ArgumentParser(prog='restic_compose_backup')
2019-04-15 18:31:08 +02:00
parser.add_argument(
'action',
2019-12-08 06:38:56 +01:00
choices=[
'status',
'snapshots',
'backup',
'start-backup-process',
'alert',
'cleanup',
'version',
'crontab',
2020-03-08 18:36:29 +01:00
'test',
2019-12-08 06:38:56 +01:00
],
2019-04-15 18:31:08 +02:00
)
2019-12-04 00:31:13 +01:00
parser.add_argument(
'--log-level',
default=None,
choices=list(log.LOG_LEVELS.keys()),
help="Log level"
)
parser.add_argument(
'--no-cleanup',
action='store_true'
)
2019-04-15 18:31:08 +02:00
return parser.parse_args()
2019-04-13 19:04:54 +02:00
if __name__ == '__main__':
main()