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

437 lines
14 KiB
Python
Raw Normal View History

2019-04-13 17:04:54 +00:00
import os
2019-12-10 06:57:37 +00:00
import logging
from pathlib import Path
2019-12-03 00:29:41 +00:00
from typing import List
2019-04-13 17:04:54 +00:00
from restic_compose_backup import enums, utils
from restic_compose_backup.config import config
2019-12-10 06:57:37 +00:00
logger = logging.getLogger(__name__)
2019-04-13 17:04:54 +00:00
VOLUME_TYPE_BIND = "bind"
VOLUME_TYPE_VOLUME = "volume"
2019-04-13 21:19:34 +00:00
2019-04-13 17:04:54 +00:00
class Container:
"""Represents a docker container"""
2019-12-03 00:29:41 +00:00
container_type = None
2019-04-13 17:04:54 +00:00
2019-11-15 13:23:56 +00:00
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')]
2019-04-16 22:09:36 +00:00
if not self._state:
raise ValueError('Container meta missing State')
if self._config is None:
raise ValueError('Container meta missing Config')
2019-04-16 22:09:36 +00:00
self._labels = self._config.get('Labels')
if self._labels is None:
2019-11-12 11:39:49 +00:00
raise ValueError('Container meta missing Config->Labels')
2019-04-16 22:09:36 +00:00
self._include = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_INCLUDE))
self._exclude = self._parse_pattern(self.get_label(enums.LABEL_VOLUMES_EXCLUDE))
2019-04-16 22:09:36 +00:00
@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:
2019-12-03 08:40:02 +00:00
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
2019-11-29 00:31:53 +00:00
@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
2019-11-15 13:23:56 +00:00
def image(self) -> str:
"""Image name"""
return self.get_config('Image')
2019-04-13 23:34:39 +00:00
2020-03-07 00:27:24 +00:00
@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='') or self.get_label('com.docker.swarm.service.name', default='')
2020-03-07 00:27:24 +00:00
@property
def backup_process_label(self) -> str:
"""str: The unique backup process label for this project"""
return f"{enums.LABEL_BACKUP_PROCESS}-{self.project_name}"
@property
def project_name(self) -> str:
"""str: Name of the compose setup"""
return self.get_label('com.docker.compose.project', default='')
@property
def stack_name(self) -> str:
"""str: Name of the stack is present"""
return self.get_label("com.docker.stack.namespace")
@property
def is_oneoff(self) -> bool:
"""Was this container started with run command?"""
return self.get_label('com.docker.compose.oneoff', default='False') == 'True'
@property
2019-11-29 04:37:02 +00:00
def environment(self) -> list:
"""All configured env vars for the container as a list"""
2019-12-04 23:38:09 +00:00
return self.get_config('Env')
2019-12-07 23:57:23 +00:00
def remove(self):
self._data.remove()
2019-11-29 04:37:02 +00:00
def get_config_env(self, name) -> str:
"""Get a config environment variable by name"""
# convert to dict and fetch env var by name
2019-12-05 10:09:36 +00:00
data = {i[0:i.find('=')]: i[i.find('=') + 1:] for i in self.environment}
2019-11-29 04:37:02 +00:00
return data.get(name)
2019-12-04 23:38:09 +00:00
def set_config_env(self, name, value):
"""Set an environment variable"""
env = self.environment
new_value = f'{name}={value}'
for i, entry in enumerate(env):
if f'{name}=' in entry:
env[i] = new_value
break
else:
env.append(new_value)
2019-11-12 11:39:49 +00:00
@property
2019-11-15 13:23:56 +00:00
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
2019-04-13 21:19:34 +00:00
@property
def backup_enabled(self) -> bool:
"""Is backup enabled for this container?"""
2019-04-29 02:26:55 +00:00
return any([
self.volume_backup_enabled,
2019-12-03 00:29:41 +00:00
self.database_backup_enabled,
2019-04-29 02:26:55 +00:00
])
@property
2019-11-15 13:23:56 +00:00
def volume_backup_enabled(self) -> bool:
2019-12-07 08:19:16 +00:00
"""bool: If the ``restic-compose-backup.volumes`` label is set"""
return utils.is_true(self.get_label(enums.LABEL_VOLUMES_ENABLED))
2019-04-29 02:26:55 +00:00
2019-12-03 00:29:41 +00:00
@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,
])
2019-04-29 02:26:55 +00:00
@property
2019-11-15 13:23:56 +00:00
def mysql_backup_enabled(self) -> bool:
2019-12-07 08:19:16 +00:00
"""bool: If the ``restic-compose-backup.mysql`` label is set"""
return utils.is_true(self.get_label(enums.LABEL_MYSQL_ENABLED))
2019-04-29 02:26:55 +00:00
2019-12-02 21:53:00 +00:00
@property
def mariadb_backup_enabled(self) -> bool:
2019-12-07 08:19:16 +00:00
"""bool: If the ``restic-compose-backup.mariadb`` label is set"""
return utils.is_true(self.get_label(enums.LABEL_MARIADB_ENABLED))
2019-12-02 21:53:00 +00:00
2019-04-29 02:26:55 +00:00
@property
2019-11-15 13:23:56 +00:00
def postgresql_backup_enabled(self) -> bool:
2019-12-07 08:19:16 +00:00
"""bool: If the ``restic-compose-backup.postgres`` label is set"""
return utils.is_true(self.get_label(enums.LABEL_POSTGRES_ENABLED))
2019-04-13 21:19:34 +00:00
@property
def is_backup_process_container(self) -> bool:
"""Is this container the running backup process?"""
return self.get_label(self.backup_process_label) == 'True'
2019-04-13 21:19:34 +00:00
@property
def is_running(self) -> bool:
"""bool: Is the container running?"""
return self._state.get('Running', False)
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)
2019-04-13 21:19:34 +00:00
def filter_mounts(self):
2019-12-05 10:09:36 +00:00
"""Get all mounts for this container matching include/exclude filters"""
2019-04-16 22:09:36 +00:00
filtered = []
# If exclude_bind_mounts is true, only volume mounts are kept in the list of mounts
exclude_bind_mounts = utils.is_true(config.exclude_bind_mounts)
mounts = list(filter(lambda m: not exclude_bind_mounts or m.type == "volume", self._mounts))
if not self.volume_backup_enabled:
return filtered
if self._include:
for mount in mounts:
for pattern in self._include:
if pattern in mount.source:
2019-04-16 22:09:36 +00:00
break
else:
continue
filtered.append(mount)
elif self._exclude:
for mount in mounts:
for pattern in self._exclude:
2019-04-17 00:44:48 +00:00
if pattern in mount.source:
break
else:
filtered.append(mount)
2019-04-16 22:09:36 +00:00
else:
return mounts
2019-04-16 22:09:36 +00:00
return filtered
2019-12-04 21:17:42 +00:00
def volumes_for_backup(self, source_prefix='/volumes', mode='ro'):
2019-11-15 13:23:56 +00:00
"""Get volumes configured for backup"""
mounts = self.filter_mounts()
volumes = {}
for mount in mounts:
volumes[mount.source] = {
'bind': self.get_volume_backup_destination(mount, source_prefix),
'mode': mode,
}
return volumes
def get_volume_backup_destination(self, mount, source_prefix) -> str:
"""Get the destination path for backups of the given mount"""
destination = Path(source_prefix)
if utils.is_true(config.include_project_name):
project_name = self.project_name
if project_name != '':
destination /= project_name
destination /= self.service_name
destination /= Path(utils.strip_root(mount.destination))
return str(destination)
2019-12-03 00:29:41 +00:00
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")
2019-12-03 02:45:49 +00:00
def backup(self):
"""Back up this service"""
raise NotImplementedError("Base container class don't implement this")
def backup_destination_path(self) -> str:
"""Return the path backups will be saved at"""
raise NotImplementedError("Base container class don't implement this")
2019-12-03 00:29:41 +00:00
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")
2019-11-29 04:37:02 +00:00
2019-12-03 00:29:41 +00:00
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(',')
2019-04-18 03:44:02 +00:00
def __eq__(self, other):
2019-12-03 00:29:41 +00:00
"""Compare container by id"""
2019-04-18 22:13:51 +00:00
if other is None:
return False
if not isinstance(other, Container):
return False
2019-04-18 03:44:02 +00:00
return self.id == other.id
def __repr__(self):
return str(self)
def __str__(self):
return "<Container {}>".format(self.name)
2019-04-13 17:04:54 +00:00
class Mount:
"""Represents a volume mount (volume or bind)"""
2019-04-13 17:04:54 +00:00
def __init__(self, data, container=None):
2019-04-14 18:55:35 +00:00
self._data = data
2019-04-13 17:04:54 +00:00
self._container = container
@property
2019-04-17 01:01:12 +00:00
def container(self) -> Container:
"""The container this mount belongs to"""
2019-04-13 17:04:54 +00:00
return self._container
@property
2019-04-17 01:01:12 +00:00
def type(self) -> str:
"""bind/volume"""
2019-04-14 18:55:35 +00:00
return self._data.get('Type')
2019-04-13 17:04:54 +00:00
@property
2019-04-17 01:01:12 +00:00
def name(self) -> str:
2019-11-12 11:39:49 +00:00
"""Name of the mount"""
2019-04-14 18:55:35 +00:00
return self._data.get('Name')
2019-04-13 17:04:54 +00:00
@property
2019-04-17 01:01:12 +00:00
def source(self) -> str:
"""Source of the mount. Volume name or path"""
2019-04-14 18:55:35 +00:00
return self._data.get('Source')
2019-04-13 17:04:54 +00:00
@property
2019-04-17 01:01:12 +00:00
def destination(self) -> str:
2019-11-12 11:39:49 +00:00
"""Destination path for the volume mount in the container"""
2019-04-14 18:55:35 +00:00
return self._data.get('Destination')
2019-04-13 17:04:54 +00:00
2019-04-17 01:01:12 +00:00
def __repr__(self) -> str:
2019-04-13 17:04:54 +00:00
return str(self)
2019-04-17 01:01:12 +00:00
def __str__(self) -> str:
2019-04-14 18:55:35 +00:00
return str(self._data)
2019-04-13 17:04:54 +00:00
def __hash__(self):
2019-11-12 11:39:49 +00:00
"""Uniqueness for a volume"""
2019-04-13 17:04:54 +00:00
if self.type == VOLUME_TYPE_VOLUME:
return hash(self.name)
elif self.type == VOLUME_TYPE_BIND:
return hash(self.source)
else:
2019-11-12 11:39:49 +00:00
raise ValueError("Unknown volume type: {}".format(self.type))
2019-04-13 17:04:54 +00:00
class RunningContainers:
def __init__(self):
all_containers = utils.list_containers()
2019-04-13 17:04:54 +00:00
self.containers = []
2019-04-15 14:16:06 +00:00
self.this_container = None
2019-04-17 01:01:12 +00:00
self.backup_process_container = None
self.stale_backup_process_containers = []
2019-04-13 17:04:54 +00:00
2019-04-15 14:16:06 +00:00
# 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)
2019-04-13 21:19:34 +00:00
2019-04-15 14:16:06 +00:00
if not self.this_container:
2019-04-13 17:04:54 +00:00
raise ValueError("Cannot find metadata for backup container")
# Gather all running containers in the current compose setup
2019-04-15 14:16:06 +00:00
for container_data in all_containers:
container = Container(container_data)
2019-12-07 23:32:39 +00:00
# Gather stale backup process containers
if (self.this_container.image == container.image
and not container.is_running
and container.is_backup_process_container):
self.stale_backup_process_containers.append(container)
2019-12-08 03:51:07 +00:00
# We only care about running containers after this point
if not container.is_running:
continue
# Detect running backup process container
if container.is_backup_process_container:
self.backup_process_container = container
# --- Determine what containers should be evaludated
# If not swarm mode we need to filter in compose project
if not config.swarm_mode:
if container.project_name != self.this_container.project_name:
continue
# Containers started manually are not included
if container.is_oneoff:
continue
# Do not include the backup process container
if container == self.backup_process_container:
continue
self.containers.append(container)
2019-04-15 14:16:06 +00:00
@property
def project_name(self) -> str:
"""str: Name of the compose project"""
return self.this_container.project_name
@property
def backup_process_label(self) -> str:
"""str: The backup process label for this project"""
return self.this_container.backup_process_label
@property
2019-04-17 01:01:12 +00:00
def backup_process_running(self) -> bool:
"""Is the backup process container running?"""
2019-04-17 01:01:12 +00:00
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]
2019-12-04 21:17:42 +00:00
def generate_backup_mounts(self, dest_prefix='/volumes') -> dict:
2019-11-29 00:31:53 +00:00
"""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
2019-04-17 01:01:12 +00:00
def get_service(self, name) -> Container:
2019-12-07 08:19:16 +00:00
"""Container: Get a service by name"""
2019-04-16 18:55:22 +00:00
for container in self.containers:
if container.service_name == name:
return container
return None