Rename project

This commit is contained in:
Einar Forselv
2019-12-03 09:40:02 +01:00
parent 2a861b0519
commit fc5c6cc914
18 changed files with 59 additions and 57 deletions

View File

@@ -0,0 +1,59 @@
import logging
import os
import docker
from restic_compose_backup.config import Config
logger = logging.getLogger(__name__)
def run(image: str = None, command: str = None, volumes: dict = None,
environment: dict = None, labels: dict = None, source_container_id: str = None):
logger.info("Starting backup container")
config = Config()
client = docker.DockerClient(base_url=config.docker_base_url)
container = client.containers.run(
image,
command,
labels=labels,
# auto_remove=True, # We remove the container further down
detach=True,
environment=environment,
volumes=volumes,
network_mode=f'container:{source_container_id}', # Reuse original container's network stack.
working_dir=os.getcwd(),
tty=True,
)
logger.info("Backup process container: %s", container.name)
log_generator = container.logs(stdout=True, stderr=True, stream=True, follow=True)
def readlines(stream):
"""Read stream line by line"""
while True:
line = ""
while True:
try:
line += next(stream).decode()
if line.endswith('\n'):
break
except StopIteration:
break
if line:
yield line.rstrip()
else:
break
with open('backup.log', 'w') as fd:
for line in readlines(log_generator):
fd.write(line)
fd.write('\n')
logger.info(line)
container.reload()
logger.debug("Container ExitCode %s", container.attrs['State']['ExitCode'])
container.remove()
return container.attrs['State']['ExitCode']

View File

@@ -0,0 +1,139 @@
import argparse
import pprint
import logging
from restic_compose_backup import (
backup_runner,
log,
restic,
)
from restic_compose_backup.config import Config
from restic_compose_backup.containers import RunningContainers
logger = logging.getLogger(__name__)
def main():
"""CLI entrypoint"""
args = parse_args()
config = Config()
containers = RunningContainers()
if args.action == 'status':
status(config, containers)
elif args.action == 'backup':
backup(config, containers)
elif args.action == 'start-backup-process':
start_backup_process(config, containers)
def status(config, containers):
"""Outputs the backup config for the compose setup"""
logger.info("Backup config for compose project '%s'", containers.this_container.project_name)
logger.info("Current service: %s", containers.this_container.name)
# logger.info("Backup process: %s", containers.backup_process_container.name
# if containers.backup_process_container else 'Not Running')
logger.info("Backup running: %s", containers.backup_process_running)
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 len(backup_containers) == 0:
logger.info("No containers in the project has 'restic-compose-backup.enabled' label")
def backup(config, containers):
"""Request a backup to start"""
# Make sure we don't spawn multiple backup processes
if containers.backup_process_running:
raise ValueError("Backup process already running")
logger.info("Initializing repository")
# TODO: Errors when repo already exists
restic.init_repo(config.repository)
logger.info("Starting backup container..")
# 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('/backup')
volumes.update(mounts)
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={
"restic-compose-backup.backup_process": 'True',
"com.docker.compose.project": containers.this_container.project_name,
},
)
logger.info('Backup container exit code: %s', result)
# TODO: Alert
def start_backup_process(config, containers):
"""The actual backup process running inside the spawned container"""
if (not containers.backup_process_container
or containers.this_container == containers.backup_process_container is False):
logger.error(
"Cannot run backup process in this container. Use backup command instead. "
"This will spawn a new container with the necessary mounts."
)
return
status(config, containers)
logger.info("start-backup-process")
# Back up volumes
try:
vol_result = restic.backup_files(config.repository, source='/backup')
logger.info('Volume backup exit code: %s', vol_result)
# TODO: Alert
except Exception as ex:
logger.error(ex)
# TODO: Alert
# back 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.info('Exit code: %s', result)
# TODO: Alert
except Exception as ex:
logger.error(ex)
# TODO: Alert
def parse_args():
parser = argparse.ArgumentParser(prog='restic_compose_backup')
parser.add_argument(
'action',
choices=['status', 'backup', 'start-backup-process'],
)
return parser.parse_args()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,68 @@
import logging
from typing import List
from subprocess import Popen, PIPE
logger = logging.getLogger(__name__)
def test():
return run_command(['ls', '/backup'])
def ping_mysql(host, port, username, password) -> int:
"""Check if the mysql is up and can be reached"""
return run([
'mysqladmin',
'ping',
'--host',
host,
'--port',
port,
'--user',
username,
f'--password={password}',
])
def ping_mariadb(host, port, username, password) -> int:
"""Check if the mariadb is up and can be reached"""
return run([
'mysqladmin',
'ping',
'--host',
host,
'--port',
port,
'--user',
username,
f'--password={password}',
])
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.info('cmd: %s', ' '.join(cmd))
child = Popen(cmd, stdout=PIPE, stderr=PIPE)
stdoutdata, stderrdata = child.communicate()
if stdoutdata:
logger.info(stdoutdata.decode().strip())
logger.info('-' * 28)
if stderrdata:
logger.info('%s STDERR %s', '-' * 10, '-' * 10)
logger.info(stderrdata.decode().strip())
logger.info('-' * 28)
logger.info("returncode %s", child.returncode)
return child.returncode

View File

@@ -0,0 +1,19 @@
import os
class Config:
"""Bag for config values"""
def __init__(self, check=True):
self.repository = os.environ['RESTIC_REPOSITORY']
self.password = os.environ['RESTIC_PASSWORD']
self.docker_base_url = os.environ.get('DOCKER_BASE_URL') or "unix://tmp/docker.sock"
if check:
self.check()
def check(self):
if not self.repository:
raise ValueError("CONTAINER env var not set")
if not self.password:
raise ValueError("PASSWORD env var not set")

View File

@@ -0,0 +1,345 @@
import os
from pathlib import Path
from typing import List
from restic_compose_backup import utils
VOLUME_TYPE_BIND = "bind"
VOLUME_TYPE_VOLUME = "volume"
class Container:
"""Represents a docker container"""
container_type = None
def __init__(self, data: dict):
self._data = data
self._state = data.get('State')
self._config = data.get('Config')
self._mounts = [Mount(mnt, container=self) for mnt in data.get('Mounts')]
if not self._state:
raise ValueError('Container meta missing State')
if self._config is None:
raise ValueError('Container meta missing Config')
self._labels = self._config.get('Labels')
if self._labels is None:
raise ValueError('Container meta missing Config->Labels')
self._include = self._parse_pattern(self.get_label('restic-compose-backup.volumes.include'))
self._exclude = self._parse_pattern(self.get_label('restic-compose-backup.volumes.exclude'))
@property
def instance(self) -> 'Container':
"""Container: Get a service specific subclass instance"""
# TODO: Do this smarter in the future (simple registry)
if self.database_backup_enabled:
from restic_compose_backup import containers_db
if self.mariadb_backup_enabled:
return containers_db.MariadbContainer(self._data)
if self.mysql_backup_enabled:
return containers_db.MysqlContainer(self._data)
if self.postgresql_backup_enabled:
return containers_db.PostgresContainer(self._data)
else:
return self
@property
def id(self) -> str:
"""str: The id of the container"""
return self._data.get('Id')
@property
def hostname(self) -> str:
"""12 character hostname based on id"""
return self.id[:12]
@property
def image(self) -> str:
"""Image name"""
return self.get_config('Image')
@property
def environment(self) -> list:
"""All configured env vars for the container as a list"""
return self.get_config('Env', default=[])
def get_config_env(self, name) -> str:
"""Get a config environment variable by name"""
# convert to dict and fetch env var by name
data = {i[0:i.find('=')]: i[i.find('=')+1:] for i in self.environment}
return data.get(name)
@property
def volumes(self) -> dict:
"""
Return volumes for the container in the following format:
{'/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'},}
"""
volumes = {}
for mount in self._mounts:
volumes[mount.source] = {
'bind': mount.destination,
'mode': 'rw',
}
return volumes
@property
def backup_enabled(self) -> bool:
"""Is backup enabled for this container?"""
return any([
self.volume_backup_enabled,
self.database_backup_enabled,
])
@property
def volume_backup_enabled(self) -> bool:
return utils.is_true(self.get_label('restic-compose-backup.volumes'))
@property
def database_backup_enabled(self) -> bool:
"""bool: Is database backup enabled in any shape or form?"""
return any([
self.mysql_backup_enabled,
self.mariadb_backup_enabled,
self.postgresql_backup_enabled,
])
@property
def mysql_backup_enabled(self) -> bool:
return utils.is_true(self.get_label('restic-compose-backup.mysql'))
@property
def mariadb_backup_enabled(self) -> bool:
return utils.is_true(self.get_label('restic-compose-backup.mariadb'))
@property
def postgresql_backup_enabled(self) -> bool:
return utils.is_true(self.get_label('restic-compose-backup.postgres'))
@property
def is_backup_process_container(self) -> bool:
"""Is this container the running backup process?"""
return self.get_label('restic-compose-backup.backup_process') == 'True'
@property
def is_running(self) -> 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 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)
def get_label(self, name, default=None):
"""Get a label by name"""
return self._labels.get(name, None)
def filter_mounts(self):
"""Get all mounts for this container matching include/exclude filters"""
filtered = []
if self._include:
for mount in self._mounts:
for pattern in self._include:
if pattern in mount.source:
break
else:
continue
filtered.append(mount)
elif self._exclude:
for mount in self._mounts:
for pattern in self._exclude:
if pattern in mount.source:
break
else:
filtered.append(mount)
else:
return self._mounts
return filtered
def volumes_for_backup(self, source_prefix='/backup', mode='ro'):
"""Get volumes configured for backup"""
mounts = self.filter_mounts()
volumes = {}
for mount in mounts:
volumes[mount.source] = {
'bind': str(Path(source_prefix) / self.service_name / Path(utils.strip_root(mount.destination))),
'mode': mode,
}
return volumes
def get_credentials(self) -> dict:
"""dict: get credentials for the service"""
raise NotImplementedError("Base container class don't implement this")
def ping(self) -> bool:
"""Check the availability of the service"""
raise NotImplementedError("Base container class don't implement this")
def backup(self):
"""Back up this service"""
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")
def _parse_pattern(self, value: str) -> List[str]:
"""list: Safely parse include/exclude pattern from user"""
if not value:
return None
if type(value) is not str:
return None
value = value.strip()
if len(value) == 0:
return None
return value.split(',')
def __eq__(self, other):
"""Compare container by id"""
if other is None:
return False
if not isinstance(other, Container):
return False
return self.id == other.id
def __repr__(self):
return str(self)
def __str__(self):
return "<Container {}>".format(self.name)
class Mount:
"""Represents a volume mount (volume or bind)"""
def __init__(self, data, container=None):
self._data = data
self._container = container
@property
def container(self) -> Container:
"""The container this mount belongs to"""
return self._container
@property
def type(self) -> str:
"""bind/volume"""
return self._data.get('Type')
@property
def name(self) -> str:
"""Name of the mount"""
return self._data.get('Name')
@property
def source(self) -> str:
"""Source of the mount. Volume name or path"""
return self._data.get('Source')
@property
def destination(self) -> str:
"""Destination path for the volume mount in the container"""
return self._data.get('Destination')
def __repr__(self) -> str:
return str(self)
def __str__(self) -> str:
return str(self._data)
def __hash__(self):
"""Uniqueness for a volume"""
if self.type == VOLUME_TYPE_VOLUME:
return hash(self.name)
elif self.type == VOLUME_TYPE_BIND:
return hash(self.source)
else:
raise ValueError("Unknown volume type: {}".format(self.type))
class RunningContainers:
def __init__(self):
all_containers = utils.list_containers()
self.containers = []
self.this_container = None
self.backup_process_container = None
# Find the container we are running in.
# If we don't have this information we cannot continue
for container_data in all_containers:
if container_data.get('Id').startswith(os.environ['HOSTNAME']):
self.this_container = Container(container_data)
if not self.this_container:
raise ValueError("Cannot find metadata for backup container")
# Gather all containers in the current compose setup
for container_data in all_containers:
container = Container(container_data)
# Detect running backup process container
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.id != self.this_container.id:
self.containers.append(container)
@property
def backup_process_running(self) -> bool:
"""Is the backup process container running?"""
return self.backup_process_container is not None
def containers_for_backup(self):
"""Obtain all containers with backup enabled"""
return [container for container in self.containers if container.backup_enabled]
def generate_backup_mounts(self, dest_prefix='/backup') -> dict:
"""Generate mounts for backup for the entire compose setup"""
mounts = {}
for container in self.containers_for_backup():
if container.volume_backup_enabled:
mounts.update(container.volumes_for_backup(source_prefix=dest_prefix, mode='ro'))
return mounts
def get_service(self, name) -> Container:
for container in self.containers:
if container.service_name == name:
return container
return None

View File

@@ -0,0 +1,140 @@
from restic_compose_backup.containers import Container
from restic_compose_backup.config import Config
from restic_compose_backup import (
commands,
restic,
)
from restic_compose_backup import utils
class MariadbContainer(Container):
container_type = 'mariadb'
def get_credentials(self) -> dict:
"""dict: get credentials for the service"""
return {
'host': self.hostname,
'username': self.get_config_env('MYSQL_USER'),
'password': self.get_config_env('MYSQL_PASSWORD'),
'port': "3306",
}
def ping(self) -> bool:
"""Check the availability of the service"""
creds = self.get_credentials()
return commands.ping_mysql(
creds['host'],
creds['port'],
creds['username'],
creds['password'],
)
def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin"""
creds = self.get_credentials()
return [
"mysqldump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}",
f"--password={creds['password']}",
"--all-databases",
]
def backup(self):
config = Config()
return restic.backup_from_stdin(
config.repository,
f'/backup/{self.service_name}/all_databases.sql',
self.dump_command(),
)
class MysqlContainer(Container):
container_type = 'mysql'
def get_credentials(self) -> dict:
"""dict: get credentials for the service"""
return {
'host': self.hostname,
'username': self.get_config_env('MYSQL_USER'),
'password': self.get_config_env('MYSQL_PASSWORD'),
'port': "3306",
}
def ping(self) -> bool:
"""Check the availability of the service"""
creds = self.get_credentials()
return commands.ping_mysql(
creds['host'],
creds['port'],
creds['username'],
creds['password'],
)
def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin"""
creds = self.get_credentials()
return [
"mysqldump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}",
f"--password={creds['password']}",
"--all-databases",
]
def backup(self):
config = Config()
return restic.backup_from_stdin(
config.repository,
f'/backup/{self.service_name}/all_databases.sql',
self.dump_command(),
)
class PostgresContainer(Container):
container_type = 'postgres'
def get_credentials(self) -> dict:
"""dict: get credentials for the service"""
return {
'host': self.hostname,
'username': self.get_config_env('POSTGRES_USER'),
'password': self.get_config_env('POSTGRES_PASSWORD'),
'port': "5432",
'database': self.get_config_env('POSTGRES_DB'),
}
def ping(self) -> bool:
"""Check the availability of the service"""
creds = self.get_credentials()
return commands.ping_postgres(
creds['host'],
creds['port'],
creds['username'],
creds['password'],
)
def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin"""
# NOTE: Backs up a single database from POSTGRES_DB env var
creds = self.get_credentials()
return [
"pg_dump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--username={creds['username']}",
creds['database'],
]
def backup(self):
config = Config()
creds = self.get_credentials()
with utils.environment('PGPASSWORD', creds['password']):
return restic.backup_from_stdin(
config.repository,
f"/backup/{self.service_name}/{creds['database']}.sql",
self.dump_command(),
)

View File

@@ -0,0 +1,13 @@
import logging
import os
import sys
logger = logging.getLogger('restic_compose_backup')
HOSTNAME = os.environ['HOSTNAME']
level = logging.INFO
logger.setLevel(level)
ch = logging.StreamHandler(stream=sys.stdout)
ch.setLevel(level)
ch.setFormatter(logging.Formatter(f'%(asctime)s - {HOSTNAME} - %(name)s - %(levelname)s - %(message)s'))
logger.addHandler(ch)

View File

@@ -0,0 +1,37 @@
"""
"""
import smtplib
from email.mime.text import MIMEText
EMAIL_HOST = "smtp.gmail.com"
EMAIL_PORT = 465
EMAIL_HOST_USER = ""
EMAIL_HOST_PASSWORD = ""
EMAIL_SEND_TO = ['']
def main():
send_mail("Hello world!")
def send_mail(text):
msg = MIMEText(text)
msg['Subject'] = "Message from restic-compose-backup"
msg['From'] = EMAIL_HOST_USER
msg['To'] = ', '.join(EMAIL_SEND_TO)
try:
print("Connecting to {} port {}".format(EMAIL_HOST, EMAIL_PORT))
server = smtplib.SMTP_SSL(EMAIL_HOST, EMAIL_PORT)
server.ehlo()
server.login(EMAIL_HOST_USER, EMAIL_HOST_PASSWORD)
server.sendmail(EMAIL_HOST_USER, EMAIL_SEND_TO, msg.as_string())
print('Email Sent')
except Exception as e:
print(e)
finally:
server.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,72 @@
"""
Restic commands
"""
import logging
from typing import List
from subprocess import Popen, PIPE
from restic_compose_backup import commands
logger = logging.getLogger(__name__)
def init_repo(repository: str):
"""
Attempt to initialize the repository.
Doing this after the repository is initialized
"""
return commands.run(restic(repository, [
"init",
]))
def backup_files(repository: str, source='/backup'):
return commands.run(restic(repository, [
"--verbose",
"backup",
source,
]))
def backup_from_stdin(repository: str, filename: str, source_command: List[str]):
"""
Backs up from stdin running the source_command passed in.
It will appear in restic with the filename (including path) passed in.
"""
dest_command = restic(repository, [
'backup',
'--stdin',
'--stdin-filename',
filename,
])
# pipe source command into dest command
source_process = Popen(source_command, stdout=PIPE)
dest_process = Popen(dest_command, stdin=source_process.stdout)
dest_process.communicate()
# Ensure both processes exited with code 0
source_exit, dest_exit = source_process.poll(), dest_process.poll()
return 0 if (source_exit == 0 and dest_exit == 0) else 1
def snapshots(repository: str):
return commands.run(restic(repository, [
"snapshots",
]))
def check(repository: str):
return commands.run(restic(repository, [
"check",
]))
def restic(repository: str, args: List[str]):
"""Generate restic command"""
return [
"restic",
"--cache-dir",
"/restic_cache",
"-r",
repository,
] + args

View File

@@ -0,0 +1,54 @@
import os
from contextlib import contextmanager
import docker
from restic_compose_backup.config import Config
TRUE_VALUES = ['1', 'true', 'True', True, 1]
def list_containers():
"""
List all containers.
Returns:
List of raw container json data from the api
"""
config = Config()
client = docker.DockerClient(base_url=config.docker_base_url)
all_containers = client.containers.list()
client.close()
return [c.attrs for c in all_containers]
def is_true(value):
"""
Evaluates the truthfullness of a bool value in container labels
"""
return value in TRUE_VALUES
def strip_root(path):
"""
Removes the root slash in a path.
Example: /srv/data becomes srv/data
"""
path = path.strip()
if path.startswith('/'):
return path[1:]
return path
@contextmanager
def environment(name, value):
"""Tempset env var"""
old_val = os.environ.get(name)
os.environ[name] = value
try:
yield
finally:
if old_val is None:
del os.environ[name]
else:
os.environ[name] = old_val