Add logic for detecting stack and associated volumes
This commit is contained in:
		
							
								
								
									
										110
									
								
								stackup/__main__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								stackup/__main__.py
									
									
									
									
									
										Normal file
									
								
							@@ -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())
 | 
				
			||||||
							
								
								
									
										41
									
								
								stackup/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								stackup/config.py
									
									
									
									
									
										Normal file
									
								
							@@ -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))
 | 
				
			||||||
							
								
								
									
										10
									
								
								stackup/errors.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								stackup/errors.py
									
									
									
									
									
										Normal file
									
								
							@@ -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"""
 | 
				
			||||||
		Reference in New Issue
	
	Block a user