From 27e9a24905327d7f5b956766cd9ae8144618be5e Mon Sep 17 00:00:00 2001 From: Ethan Paul <24588726+enpaul@users.noreply.github.com> Date: Thu, 1 Feb 2024 02:31:33 -0500 Subject: [PATCH] Add logic for detecting stack and associated volumes --- stackup/__main__.py | 110 ++++++++++++++++++++++++++++++++++++++++++++ stackup/config.py | 41 +++++++++++++++++ stackup/errors.py | 10 ++++ 3 files changed, 161 insertions(+) create mode 100644 stackup/__main__.py create mode 100644 stackup/config.py create mode 100644 stackup/errors.py diff --git a/stackup/__main__.py b/stackup/__main__.py new file mode 100644 index 0000000..1ee25fb --- /dev/null +++ b/stackup/__main__.py @@ -0,0 +1,110 @@ +import logging +import socket +import sys +from typing import List + +import docker.models + +import stackup.__about__ +import stackup.config +import stackup.errors + + +def determine_volumes( + client: docker.DockerClient, + local_container: docker.models.containers.Container, + is_swarm: bool, +) -> List[docker.models.volumes.Volume]: + logger = logging.getLogger(__name__) + + if is_swarm: + stack_label = "com.docker.stack.namespace" + else: + stack_label = "com.docker.compose.project" + + stack = local_container.labels[stack_label] + logger.debug( + f"Identified local stack as '{stack}' from namespace label '{stack_label}' on local container {local_container.id}" + ) + + # Primary filter (via docker) is for volumes in the detected stack + # Secondary filter (via the list comp) is for volumes that have the label + # that enables them for stackup processing. The end result is that ``volumes`` + # is a list of volumes that are enabled for stackup processing in the current + # stack + volumes = [ + item + for item in client.volumes.list(filters={"label": f"{stack_label}={stack}"}) + if item.attrs["Labels"].get("stackup.enable") + ] + logger.info( + f"Identified {len(volumes)} in stack '{stack}' for backup: {', '.join([item.attrs['Name']] for item in volumes)}" + ) + + if not volumes: + raise stackup.errors.NoVolumesEnabled + + # Determine which (if any) volumes are missing from the current container + local_volumes = {item["Name"]: item for item in local_container.attrs["Mounts"]} + logger.debug( + f"Identified {len(local_volumes)} volumes mounted into local container {local_container.id}: {', '.join(local_volumes.keys())}" + ) + + missing = [ + item.attrs["Name"] + for item in volumes + if item.attrs["Name"] not in local_volumes + ] + if missing: + raise stackup.errors.EnabledVolumeNotMountedError( + f"One or more volumes enabled for backup in stack '{stack}' are not mounted in the current container ({local_container.id}): {', '.join(missing)}" + ) + + return volumes + + +def main() -> int: + config = stackup.config.StackupConfig.build() + + logging.basicConfig( + format="%(levelname)s: %(message)s", + level=config.log_level, + ) + logger = logging.getLogger(__name__) + logger.info( + f"Starting {stackup.__about__.__title__} v{stackup.__about__.__version__}" + ) + logger.debug(config) + + logger.debug("Loading Docker client from local environment") + client = docker.from_env() + + logger.debug(f"Connected to Docker daemon at {client.api.base_url}") + # Determine whether we're operating in swarm mode + try: + client.swarm.version + except TypeError: + is_swarm = False + logger.debug(f"Daemon at {client.api.base_url} is not bound to a swarm") + else: + is_swarm = True + logger.debug( + f"Daemon at {client.api.base_url} is bound to swarm {client.swarm.id}" + ) + + local_container = client.containers.get(socket.gethostname()) + logger.debug(f"Identified local container as {local_container.id}") + + try: + volumes = determine_volumes(client, local_container, is_swarm) + except stackup.errors.NoVolumesEnabled: + return 0 + except stackup.errors.StackupError as err: + logger.error(str(err)) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stackup/config.py b/stackup/config.py new file mode 100644 index 0000000..26718a8 --- /dev/null +++ b/stackup/config.py @@ -0,0 +1,41 @@ +import datetime +import enum +import os +import uuid +from dataclasses import dataclass + +import wonderwords + + +def _name_phrase(): + return "-".join( + wonderwords.RandomWord().random_words( + 4, + word_min_length=4, + word_max_length=12, + include_parts_of_speech=["nouns", "adjectives"], + ) + ) + + +def _name_timestamp(): + return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _name_uuid(): + return uuid.uuid4().hex + + +class Namers(enum.Enum): + PHRASE = _name_phrase + TIMESTAMP = _name_timestamp + UUID = _name_uuid + + +@dataclass +class StackupConfig: + log_level: str = "info" + + @classmethod + def build(cls): + return cls(log_level=os.getenv("STACKUP_LOG_LEVEL", cls.log_level)) diff --git a/stackup/errors.py b/stackup/errors.py new file mode 100644 index 0000000..10a5d79 --- /dev/null +++ b/stackup/errors.py @@ -0,0 +1,10 @@ +class StackupError(Exception): + """Base application exception""" + + +class NoVolumesEnabled(StackupError): + """Could not identify any volumes in the current stack that are enabled for backup""" + + +class EnabledVolumeNotMountedError(StackupError): + """One or more volumes that are enabled for backup are not mounted in the current container"""