├── app ├── utils │ ├── __init__.py │ ├── notifiers │ │ ├── base.py │ │ ├── __init__.py │ │ ├── telegram.py │ │ └── smtp.py │ ├── registries │ │ ├── __init__.py │ │ ├── generic.py │ │ ├── docker.py │ │ ├── ghcr.py │ │ └── auth.py │ ├── engines │ │ └── __init__.py │ ├── cleanup.py │ ├── scheduler.py │ ├── self_update.py │ └── scripts.py ├── cli │ ├── __init__.py │ ├── captn.sh │ └── captn-cli-completion.sh ├── __init__.py └── assets │ └── icons │ ├── favicon.png │ └── app-icon.svg ├── requirements.txt ├── .dockerignore ├── docker ├── entrypoint.sh └── DOCKERFILE ├── .gitignore ├── README.md ├── .dev └── scripts │ └── osx-dev-setup.sh └── docs ├── 01-Introduction.md ├── 02-CLI-Reference.md └── 04-Scripts.md /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # This file intentionally left empty to make utils a package 2 | -------------------------------------------------------------------------------- /app/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # CLI package for captn 2 | # Contains CLI-related scripts and utilities -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | __version__ = "0.8.3" 5 | -------------------------------------------------------------------------------- /app/assets/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/captn-io/captn/HEAD/app/assets/icons/favicon.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2025.4.26 2 | charset-normalizer==3.4.2 3 | croniter>=6.0.0 4 | docker==7.1.0 5 | idna==3.10 6 | packaging==25.0 7 | requests==2.32.4 8 | urllib3==2.5.0 9 | argcomplete>=3.6.3 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore Python cache 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | 7 | # Ignore Git 8 | .git/ 9 | .gitignore 10 | 11 | # macOS specific 12 | .DS_Store 13 | .AppleDouble 14 | .LSOverride 15 | 16 | # Docker 17 | Dockerfile 18 | docker-compose.yml 19 | *.env 20 | *.log 21 | docker/build/* 22 | 23 | # VSCode settings 24 | .vscode/ 25 | 26 | # Other 27 | *.swp 28 | *.swo 29 | *.bak 30 | *.tmp 31 | 32 | # captn config and logs 33 | /app/conf/* 34 | /app/logs/* -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "[ENTRYPOINT] captn version: ${VERSION}" 4 | echo "[ENTRYPOINT] Preparing container environment..." 5 | 6 | # Create necessary directories 7 | mkdir -p /app/{conf,logs} 8 | 9 | # Enable auto-completion for interactive shells 10 | if [ -f /etc/bash_completion.d/captn ]; then 11 | echo "[ENTRYPOINT] Auto-completion script found and will be available in interactive shells" 12 | source /etc/bash_completion.d/captn 13 | fi 14 | 15 | # Check if arguments are provided 16 | if [ $# -eq 0 ]; then 17 | # No arguments provided, start daemon mode 18 | echo "[ENTRYPOINT] Starting captn daemon with scheduler..." 19 | exec captn --daemon 20 | else 21 | # Arguments provided, execute the command directly 22 | echo "[ENTRYPOINT] Executing command: $@" 23 | exec captn "$@" 24 | fi 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Virtual environments 7 | .venv/ 8 | env/ 9 | venv/ 10 | 11 | # Distribution / packaging 12 | build/ 13 | dist/ 14 | *.egg-info/ 15 | .eggs/ 16 | 17 | # Installer logs 18 | pip-log.txt 19 | pip-delete-this-directory.txt 20 | 21 | # Unit test / coverage 22 | htmlcov/ 23 | .coverage 24 | .cache/ 25 | .tox/ 26 | nosetests.xml 27 | coverage.xml 28 | *.cover 29 | *.log 30 | 31 | # Pytest cache 32 | .pytest_cache/ 33 | 34 | # Environment variables / secrets 35 | .env 36 | .env.* 37 | 38 | # IDE configs 39 | .vscode/ 40 | .idea/ 41 | *.code-workspace 42 | 43 | # Podcast metadata cache (optional) 44 | *.rss 45 | *.xml 46 | *.json 47 | 48 | # Docker-related 49 | *.pid 50 | *.sock 51 | *.db 52 | .DS_Store 53 | docker-compose.override.yml 54 | 55 | # System files 56 | Thumbs.db 57 | ehthumbs.db 58 | 59 | # captn config and logs 60 | /app/logs/* 61 | /app/venv/* 62 | /app/conf/* 63 | -------------------------------------------------------------------------------- /app/utils/notifiers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Optional 3 | 4 | class NotificationCollector: 5 | """ 6 | Collects notification messages during execution and dispatches them at the end. 7 | """ 8 | def __init__(self): 9 | """ 10 | Initialize the notification collector. 11 | 12 | Creates a new collector instance with an empty message list. 13 | """ 14 | self.messages: List[str] = [] 15 | 16 | def add(self, message: str): 17 | """ 18 | Add a message to the collector. 19 | 20 | Parameters: 21 | message (str): Message to add to the collection 22 | """ 23 | self.messages.append(message) 24 | 25 | def clear(self): 26 | """ 27 | Clear all collected messages. 28 | """ 29 | self.messages.clear() 30 | 31 | def get_all(self) -> List[str]: 32 | """ 33 | Get all collected messages. 34 | 35 | Returns: 36 | List[str]: Copy of all collected messages 37 | """ 38 | return self.messages[:] 39 | 40 | class BaseNotifier(ABC): 41 | """ 42 | Abstract base class for all notifiers. 43 | """ 44 | def __init__(self, enabled: bool = True): 45 | """ 46 | Initialize the base notifier. 47 | 48 | Parameters: 49 | enabled (bool): Whether the notifier is enabled 50 | """ 51 | self.enabled = enabled 52 | 53 | @abstractmethod 54 | def send(self, messages: List[str]): 55 | """ 56 | Send notification messages. 57 | 58 | Parameters: 59 | messages (List[str]): List of messages to send 60 | """ 61 | pass 62 | -------------------------------------------------------------------------------- /app/utils/registries/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | 6 | from . import docker, ghcr 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def get_image_tags(imageName, imageUrl, registry, imageTagsUrl, imageTag): 12 | """ 13 | Retrieve available image tags from a container registry. 14 | 15 | This function provides a unified interface to get image tags from different 16 | registry types. It currently supports Docker Hub and GitHub Container Registry (GHCR). 17 | The returned tags are filtered to include only relevant updates and are sorted 18 | with the newest versions first. 19 | 20 | Parameters: 21 | imageName (str): Name of the image 22 | imageUrl (str): URL for the image API endpoint 23 | registry (str): Registry type (e.g., "docker.io", "ghcr.io") 24 | imageTagsUrl (str): URL for the tags API endpoint 25 | imageTag (str): Current image tag for filtering 26 | 27 | Returns: 28 | list: List of available image tags with metadata 29 | """ 30 | logger.debug(f"Retrieving available image tags from '{registry}'", extra={"indent": 2}) 31 | if registry in ["docker.io"]: 32 | tags = docker.get_image_tags(imageTagsUrl, imageTag) # filtered by similar tags, sorted and truncated so only the current tag and newer are listed 33 | elif registry in ["ghcr.io"]: 34 | tags = ghcr.get_image_tags(imageName, imageUrl, imageTagsUrl, imageTag) # filtered by similar tags, sorted and truncated so only the current tag and newer are listed 35 | 36 | logger.debug( 37 | f"A total of {len(tags)} image tags relevant for update processing have been retrieved from '{registry}'", 38 | extra={"indent": 2}, 39 | ) 40 | return tags 41 | -------------------------------------------------------------------------------- /app/cli/captn.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | LOCKFILE="/tmp/captn.lock" 3 | TIMEOUT=36000 # 10 hours in seconds 4 | 5 | # Function to print log with timestamp and log level 6 | log() { 7 | local level="$1" 8 | shift 9 | printf "%s %-7s %s\n" "$(date '+%Y-%m-%d %H:%M:%S.%3N')" "$level" "$*" 10 | } 11 | 12 | # Setup environment 13 | mkdir -p /app/{conf,logs} 14 | 15 | log "INFO" "Starting captn.io/captn" 16 | 17 | # Change directory, activate the python virtual environment and execute the script with timeout 18 | cd / || exit 1 19 | if [ ! -f /opt/venv/bin/activate ]; then 20 | log "ERROR" "Virtual environment not found at /opt/venv. Exiting." 21 | exit 1 22 | fi 23 | 24 | . /opt/venv/bin/activate 25 | 26 | # Check if this is daemon mode (--daemon flag) 27 | if [[ "$*" == *"--daemon"* ]]; then 28 | # Daemon mode - no lock required 29 | log "INFO" "Running in daemon mode" 30 | python -u -m app "$@" 31 | EXIT_CODE=$? 32 | else 33 | # Update execution mode - acquire lock 34 | log "INFO" "Running update execution - acquiring lock" 35 | 36 | # Open file descriptor for locking 37 | exec 200>"$LOCKFILE" 38 | 39 | # Try to acquire lock 40 | flock -n 200 || { 41 | log "ERROR" "Another update process is running. Lock file: $LOCKFILE" 42 | log "ERROR" "To remove the lock manually: rm -f $LOCKFILE" 43 | exit 1; 44 | } 45 | 46 | # Trap to ensure lock is released on exit 47 | trap 'rm -f "$LOCKFILE"; exit' INT TERM EXIT 48 | 49 | timeout $TIMEOUT python -u -m app "$@" 50 | EXIT_CODE=$? 51 | 52 | # Release lock and remove lockfile manually 53 | rm -f "$LOCKFILE" 54 | 55 | # Remove trap before exiting to avoid unnecessary deletion attempts 56 | trap - INT TERM EXIT 57 | fi 58 | 59 | if [ $EXIT_CODE -eq 124 ]; then 60 | log "ERROR" "Execution timed out after $TIMEOUT seconds (args: $*)." 61 | fi 62 | 63 | log "INFO" "Finished with exit code $EXIT_CODE" 64 | 65 | exit $EXIT_CODE 66 | -------------------------------------------------------------------------------- /docker/DOCKERFILE: -------------------------------------------------------------------------------- 1 | FROM alpine:3.22.2 2 | 3 | ARG VERSION=unknown 4 | ARG REVISION=unknown 5 | ARG CREATED=unknown 6 | 7 | ENV VERSION=${VERSION} 8 | 9 | # Common labels 10 | LABEL org.opencontainers.image.title="captn" \ 11 | org.opencontainers.image.description="Intelligent, rule-based container updater that automatically manages container updates using semantic versioning and registry metadata" \ 12 | org.opencontainers.image.vendor="captn.io" \ 13 | org.opencontainers.image.version=$VERSION \ 14 | org.opencontainers.image.url="https://github.com/captn-io/captn" \ 15 | org.opencontainers.image.source="https://github.com/captn-io/captn" \ 16 | org.opencontainers.image.documentation="https://github.com/captn-io/captn/blob/main/README.md" \ 17 | org.opencontainers.image.revision=$REVISION \ 18 | org.opencontainers.image.created=$CREATED \ 19 | org.opencontainers.image.licenses="AGPL-3.0" \ 20 | org.opencontainers.image.authors="captn.io " \ 21 | org.opencontainers.image.base.name="docker.io/library/alpine:3.22.2" \ 22 | maintainer="captn.io " 23 | 24 | # Set working directory 25 | WORKDIR /app 26 | 27 | # Install common system dependencies 28 | RUN apk add --no-cache \ 29 | coreutils \ 30 | bash \ 31 | bash-completion \ 32 | python3 \ 33 | py3-pip \ 34 | docker-cli \ 35 | curl \ 36 | tzdata \ 37 | gettext 38 | 39 | # Set bash as default shell (after bash is installed) 40 | SHELL ["/bin/bash", "-c"] 41 | 42 | # Create python virtual environment 43 | ENV VIRTUAL_ENV=/opt/venv 44 | RUN python3 -m venv $VIRTUAL_ENV 45 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 46 | 47 | # Install Python dependencies 48 | COPY requirements.txt . 49 | RUN pip install --no-cache-dir -r requirements.txt 50 | 51 | # Copy project files 52 | COPY app/ . 53 | 54 | # Copy required files from docker directory 55 | COPY docker/entrypoint.sh /app/entrypoint.sh 56 | 57 | # Make scripts executable 58 | RUN chmod +x /app/entrypoint.sh 59 | 60 | # Create captn symlink 61 | RUN ln -s /app/cli/captn.sh /usr/local/bin/captn 62 | 63 | # Setup auto-completion for Docker environment 64 | RUN mkdir -p /etc/bash_completion.d 65 | RUN cp /app/cli/captn-cli-completion.sh /etc/bash_completion.d/captn 66 | RUN chmod +x /etc/bash_completion.d/captn 67 | 68 | # Create a global completion script that works in containerized environments 69 | RUN echo 'source /etc/bash_completion.d/captn' >> /etc/bash.bashrc 70 | 71 | # Also add to .bashrc for interactive shells 72 | RUN echo 'source /etc/bash_completion.d/captn' >> /root/.bashrc 73 | 74 | # Create directories 75 | RUN mkdir -p /app/conf /app/logs /app/data /app/tmp 76 | 77 | # Set environment variables 78 | ENV PYTHONPATH=/app 79 | ENV PYTHONDONTWRITEBYTECODE=1 80 | ENV PYTHONUNBUFFERED=1 81 | 82 | # Production entrypoint and default command 83 | ENTRYPOINT ["/app/entrypoint.sh"] 84 | CMD [] 85 | -------------------------------------------------------------------------------- /app/cli/captn-cli-completion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # captn CLI auto-completion script for Docker environments 3 | # This script is designed to work inside Docker containers 4 | 5 | _captn_complete() { 6 | local cur prev opts 7 | COMPREPLY=() 8 | cur="${COMP_WORDS[COMP_CWORD]}" 9 | prev="${COMP_WORDS[COMP_CWORD-1]}" 10 | 11 | # echo "DEBUG: _captn_complete called, cur='$cur', prev='$prev'" >&2 12 | 13 | # Check if we're completing the first argument (after the command) 14 | if [[ ${COMP_CWORD} -eq 1 ]]; then 15 | # First argument - show main options 16 | opts="--help --version --dry-run --run --filter --log-level --clear-logs --daemon -h -v -t -r -l -c -d" 17 | COMPREPLY=($(compgen -W "${opts}" -- "${cur}")) 18 | return 0 19 | fi 20 | 21 | # Check if we already have certain flags to avoid suggesting them again 22 | local used_flags=() 23 | for ((i=1; i tag_regex: ^{regex}$", extra={"indent": 2}) 60 | 61 | return re.compile(f"^{regex}$") 62 | 63 | 64 | def filter_image_tags(tags, imageTag): 65 | """ 66 | Filter image tags based on a template pattern. 67 | 68 | This function filters a list of tags to only include those that match 69 | the pattern of the provided image tag template. 70 | 71 | Parameters: 72 | tags (list): List of tag objects to filter 73 | imageTag (str): Template tag to match against 74 | 75 | Returns: 76 | list: Filtered list of tags matching the template pattern 77 | """ 78 | pattern = generate_tag_regex(imageTag) 79 | logger.debug("Filtering retrieved tags", extra={"indent": 2}) 80 | return [tag for tag in tags if pattern.match(extract_tag_name(tag))] 81 | 82 | 83 | def sort_tags(tags): 84 | """ 85 | Sort a list of tags in descending order using normalized version comparison. 86 | 87 | This function sorts tags by first attempting to normalize them as version strings 88 | using the normalize_version() function from common.py. Tags that can be normalized 89 | as versions are sorted by their version tuple (major, minor, patch, build). 90 | Non-version tags fall back to string comparison. 91 | 92 | Parameters: 93 | tags (list): List of tag objects (dicts with 'name' field) or tag strings 94 | 95 | Returns: 96 | list: Sorted list of tags in descending order (newest versions first) 97 | """ 98 | def sort_key(tag): 99 | tag = extract_tag_name(tag) 100 | 101 | # Use normalize_version to normalize the version string 102 | normalized_version = normalize_version(tag) 103 | 104 | # If normalize_version returns valid version tuple, use it for sorting 105 | if normalized_version != (-1, -1, -1, -1): 106 | return (0, normalized_version) 107 | 108 | # Fallback: string comparison for non-version tags 109 | return (1, tag.lower()) 110 | 111 | return sorted(tags, key=sort_key, reverse=True) # reverse=True for descending order 112 | 113 | 114 | def truncate_tags(tags, imageTag): 115 | """ 116 | Truncate the list of sorted tags to only include tags from (and including) the given imageTag upward. 117 | 118 | This function filters the tag list to only include tags that are at or above 119 | the specified image tag in the version hierarchy. Assumes tags are already 120 | sorted in descending order. 121 | 122 | Parameters: 123 | tags (list): List of sorted tags in descending order 124 | imageTag (str): Tag to truncate from (inclusive) 125 | 126 | Returns: 127 | list: Truncated list of tags from the specified tag upward 128 | """ 129 | truncated = [] 130 | for tag in tags: 131 | truncated.append(tag) 132 | if extract_tag_name(tag) == imageTag: 133 | break 134 | return truncated 135 | -------------------------------------------------------------------------------- /app/utils/engines/__init__.py: -------------------------------------------------------------------------------- 1 | from . import docker 2 | 3 | 4 | def get_client(): 5 | """ 6 | Get the Docker client instance. 7 | 8 | This function provides a unified interface to get the appropriate 9 | container engine client. Currently supports Docker engine. 10 | 11 | Returns: 12 | Docker client instance or None if connection fails 13 | """ 14 | engine = "docker" 15 | 16 | if engine == "docker": 17 | return docker.get_client() 18 | 19 | 20 | def get_containers(filters, client): 21 | """ 22 | Get containers based on specified filters. 23 | 24 | This function retrieves containers from the container engine based on 25 | the provided filters (name, status, etc.). 26 | 27 | Parameters: 28 | filters: Container filters to apply 29 | client: Container engine client instance 30 | 31 | Returns: 32 | list: List of containers matching the filters 33 | """ 34 | engine = "docker" 35 | 36 | if engine == "docker": 37 | return docker.get_containers(filters, client) 38 | 39 | 40 | def get_local_image_metadata(image, container_inspect_data): 41 | """ 42 | Get metadata for a local container image. 43 | 44 | This function extracts metadata from a local container image including 45 | registry information, image name, and tags. 46 | 47 | Parameters: 48 | image: Container image object 49 | container_inspect_data: Container inspection data 50 | 51 | Returns: 52 | dict: Image metadata including registry, name, and reference information 53 | """ 54 | engine = "docker" 55 | 56 | if engine == "docker": 57 | return docker.get_local_image_metadata(image, container_inspect_data) 58 | 59 | 60 | def pull_image(client, image_reference, dry_run): 61 | """ 62 | Fetch a container image from the registry. 63 | 64 | This function downloads a container image from the registry to the local 65 | system for use in container updates. 66 | 67 | Parameters: 68 | client: Container engine client instance 69 | image_reference: Full image reference (e.g., "nginx:1.23.4") 70 | dry_run (bool): If True, only log what would be done without actually pulling 71 | 72 | Returns: 73 | Image object if successful, None otherwise 74 | """ 75 | engine = "docker" 76 | 77 | if engine == "docker": 78 | return docker.pull_image(client, image_reference, dry_run) 79 | 80 | 81 | def get_container_spec(client, container, container_inspect_data, image, image_inspect_data=None): 82 | """ 83 | Get container specification for recreation. 84 | 85 | This function extracts the container configuration needed to recreate 86 | a container with the same settings but a new image. 87 | 88 | Parameters: 89 | client: Container engine client instance 90 | container: Container object 91 | container_inspect_data: Container inspection data 92 | image: New image object 93 | image_inspect_data: Image inspection data (optional) 94 | 95 | Returns: 96 | dict: Container specification for recreation 97 | """ 98 | engine = "docker" 99 | 100 | if engine == "docker": 101 | return docker.get_container_spec( 102 | client, container, container_inspect_data, image, image_inspect_data 103 | ) 104 | 105 | 106 | def recreate_container(client, container, image, container_inspect_data, dry_run, image_inspect_data=None, notification_manager=None): 107 | """ 108 | Recreate a container with a new image. 109 | 110 | This function stops the existing container, creates a backup, and starts 111 | a new container with the same configuration but using the new image. 112 | 113 | Parameters: 114 | client: Container engine client instance 115 | container: Original container object 116 | image: New image object 117 | container_inspect_data: Container inspection data 118 | dry_run (bool): If True, only log what would be done without actually recreating 119 | image_inspect_data: Image inspection data (optional) 120 | 121 | Returns: 122 | Container object if successful, None otherwise 123 | """ 124 | engine = "docker" 125 | 126 | if engine == "docker": 127 | return docker.recreate_container(client, container, image, container_inspect_data, dry_run, image_inspect_data, notification_manager) 128 | 129 | 130 | def is_self_container(container_name, container_id): 131 | """ 132 | Check if a container is the self-update container. 133 | 134 | This function determines if the specified container is the captn 135 | application container itself, which requires special handling during updates. 136 | 137 | Parameters: 138 | container_name: Name of the container 139 | container_id: ID of the container 140 | 141 | Returns: 142 | bool: True if this is the self-update container, False otherwise 143 | """ 144 | engine = "docker" 145 | 146 | if engine == "docker": 147 | return docker.is_self_container(container_name, container_id) 148 | -------------------------------------------------------------------------------- /app/assets/icons/app-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | BoatColor 15 | 16 | 17 | BoatInverted 18 | 19 | Window 20 | 21 | 22 | BoatInverted 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/utils/cleanup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | from datetime import datetime 6 | 7 | from .common import parse_duration 8 | from .config import config 9 | 10 | 11 | def cleanup_backup_containers(client, dry_run=False): 12 | """ 13 | Remove backup containers created by captn. 14 | 15 | This function identifies and removes backup containers that have exceeded 16 | the minimum age threshold defined in the configuration. Backup containers 17 | are created during the update process and follow the naming pattern 18 | _bak_cu_. 19 | 20 | Parameters: 21 | client: Docker client instance 22 | dry_run (bool): If True, only log what would be done without actually removing 23 | 24 | Returns: 25 | int: Number of containers removed 26 | """ 27 | if not config.prune.removeOldContainers: 28 | logging.debug("Container cleanup disabled by configuration", extra={"indent": 2}) 29 | return 0 30 | 31 | logging.info(f"{'Would check and remove' if dry_run else 'Checking and removing'} backup containers", extra={"indent": 2}) 32 | 33 | removed_count = 0 34 | now = datetime.now() 35 | 36 | try: 37 | for container in client.containers.list(all=True, filters={"status": "exited"}): 38 | container_name = container.name 39 | if "_bak_cu_" in container_name: 40 | try: 41 | # Extract timestamp from backup container name 42 | date_str = container_name.split("_bak_cu_")[-1] 43 | container_time = datetime.strptime(date_str, "%Y%m%d-%H%M%S") 44 | age_hours = (now - container_time).total_seconds() / 3600 45 | 46 | min_age_hours = parse_duration(config.prune.minBackupAge, "h") 47 | 48 | if age_hours >= min_age_hours: 49 | logging.debug(f"{'Would remove' if dry_run else 'Removing'} container '{container_name}'", extra={"indent": 4}) 50 | if not dry_run: 51 | container.remove() 52 | removed_count += 1 53 | 54 | except Exception as e: 55 | logging.warning(f"Could not parse or evaluate container '{container_name}' for pruning: {e}", extra={"indent": 4}) 56 | 57 | except Exception as e: 58 | logging.error(f"Container cleanup failed: {e}", extra={"indent": 4}) 59 | 60 | return removed_count 61 | 62 | 63 | def cleanup_unused_images(client, dry_run=False): 64 | """ 65 | Remove unused Docker images. 66 | 67 | This function removes Docker images that are no longer referenced by any 68 | containers. It uses Docker's built-in prune functionality to identify and 69 | remove unused images based on the configuration settings. 70 | 71 | Parameters: 72 | client: Docker client instance 73 | dry_run (bool): If True, only log what would be done without actually removing 74 | 75 | Returns: 76 | dict: Prune results from Docker API containing information about removed images 77 | """ 78 | if not config.prune.removeUnusedImages: 79 | logging.debug("Image cleanup disabled by configuration", extra={"indent": 2}) 80 | return {} 81 | 82 | logging.info(f"{'Would remove' if dry_run else 'Removing'} unused images", extra={"indent": 2}) 83 | 84 | try: 85 | # Get current images before pruning 86 | images_before = client.images.list() 87 | logging.debug(f"Images before pruning: {len(images_before)}", extra={"indent": 4}) 88 | 89 | if not dry_run: 90 | result = client.images.prune( 91 | filters={ 92 | "dangling": False, # Remove all unused images 93 | "until": "24h", # Remove images older than 24h 94 | } 95 | ) 96 | logging.debug(f"Image prune result: {result}", extra={"indent": 4}) 97 | 98 | # Get images after pruning 99 | images_after = client.images.list() 100 | logging.debug(f"Images after pruning: {len(images_after)} (removed {len(images_before) - len(images_after)})", extra={"indent": 4}) 101 | 102 | return result 103 | else: 104 | return {} 105 | 106 | except Exception as e: 107 | logging.error(f"Image cleanup failed: {e}", extra={"indent": 4}) 108 | return {} 109 | 110 | 111 | def perform_cleanup(client, dry_run=False): 112 | """ 113 | Perform all cleanup operations based on configuration. 114 | 115 | This function orchestrates all cleanup operations including removal of old 116 | backup containers and pruning of unused images. It respects the configuration 117 | settings for each cleanup operation and provides a summary of what was performed. 118 | 119 | Parameters: 120 | client: Docker client instance 121 | dry_run (bool): If True, only log what would be done without actually removing 122 | 123 | Returns: 124 | dict: Summary of cleanup operations performed with counts and error information 125 | """ 126 | if not config.prune.removeUnusedImages and not config.prune.removeOldContainers: 127 | logging.debug("All cleanup operations are disabled by configuration", extra={"indent": 0}) 128 | return {} 129 | 130 | logging.info(f"{'Would perform' if dry_run else 'Performing'} cleanup operations", extra={"indent": 0}) 131 | 132 | summary = {"containers_removed": 0, "images_pruned": False, "errors": []} 133 | 134 | try: 135 | # Cleanup backup containers 136 | summary["containers_removed"] = cleanup_backup_containers(client, dry_run) 137 | 138 | # Cleanup unused images 139 | image_result = cleanup_unused_images(client, dry_run) 140 | summary["images_pruned"] = bool(image_result) 141 | 142 | except Exception as e: 143 | error_msg = f"Cleanup operation failed: {e}" 144 | logging.error(error_msg, extra={"indent": 2}) 145 | summary["errors"].append(error_msg) 146 | 147 | return summary 148 | -------------------------------------------------------------------------------- /app/utils/notifiers/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Dict, Any, List, Optional 4 | from .base import NotificationCollector 5 | from .telegram import TelegramNotifier 6 | from .smtp import SMTPNotifier 7 | from ..config import config 8 | from ..common import get_docker_host_hostname 9 | 10 | class NotificationManager: 11 | """ 12 | Manages all notification channels and provides a unified interface for sending notifications. 13 | """ 14 | 15 | def __init__(self): 16 | self.collector = NotificationCollector() 17 | self.notifiers = [] 18 | self.update_stats = { 19 | "containers_processed": 0, 20 | "containers_updated": 0, 21 | "containers_failed": 0, 22 | "containers_skipped": 0, 23 | "update_details": [], 24 | "errors": [], 25 | "warnings": [], 26 | "start_time": None, 27 | "end_time": None 28 | } 29 | 30 | self._setup_notifiers() 31 | 32 | def _setup_notifiers(self): 33 | """Initialize configured notifiers.""" 34 | if not config.notifiers.enabled: 35 | logging.debug("Notifications are disabled globally") 36 | return 37 | 38 | # Setup Telegram notifier 39 | if hasattr(config, "notifiers") and hasattr(config.notifiers, "telegram"): 40 | telegram_config = config.notifiers.telegram 41 | if telegram_config.enabled and telegram_config.token and telegram_config.chatId: 42 | telegram_notifier = TelegramNotifier( 43 | token=telegram_config.token, 44 | chatId=telegram_config.chatId, 45 | enabled=telegram_config.enabled 46 | ) 47 | self.notifiers.append(telegram_notifier) 48 | logging.debug("Telegram notifier initialized") 49 | elif telegram_config.enabled: 50 | logging.warning("Telegram notifications enabled but token or chatId not configured") 51 | 52 | # Setup SMTP notifier 53 | if hasattr(config, "notifiers") and hasattr(config.notifiers, "email"): 54 | email_config = config.notifiers.email 55 | if email_config.enabled and email_config.smtpServer and email_config.fromAddr and email_config.toAddr: 56 | smtp_notifier = SMTPNotifier( 57 | smtp_server=email_config.smtpServer, 58 | smtp_port=int(email_config.smtpPort), 59 | username=email_config.username, 60 | password=email_config.password, 61 | from_addr=email_config.fromAddr, 62 | to_addr=email_config.toAddr, 63 | enabled=email_config.enabled, 64 | timeout=int(email_config.timeout) 65 | ) 66 | self.notifiers.append(smtp_notifier) 67 | logging.debug("SMTP notifier initialized") 68 | elif email_config.enabled: 69 | logging.warning("Email notifications enabled but smtpServer, fromAddr, or toAddr not configured") 70 | 71 | def add_update_detail(self, container_name: str, old_version: str, new_version: str, update_type: str, duration: float = None, status: str = "succeeded"): 72 | """Add an update to the statistics.""" 73 | self.update_stats["update_details"].append({ 74 | "container_name": container_name, 75 | "old_version": old_version, 76 | "new_version": new_version, 77 | "update_type": update_type, 78 | "duration": duration, 79 | "status": status 80 | }) 81 | 82 | # Only increment containers_updated for successful updates 83 | if status == "succeeded": 84 | self.update_stats["containers_updated"] += 1 85 | 86 | def add_error(self, error_message: str): 87 | """Add an error to the statistics.""" 88 | self.update_stats["errors"].append(error_message) 89 | self.update_stats["containers_failed"] += 1 90 | 91 | def add_warning(self, warning_message: str): 92 | """Add a warning to the statistics.""" 93 | self.update_stats["warnings"].append(warning_message) 94 | 95 | def increment_processed(self): 96 | """Increment the processed containers counter.""" 97 | self.update_stats["containers_processed"] += 1 98 | 99 | def increment_skipped(self): 100 | """Increment the skipped containers counter.""" 101 | self.update_stats["containers_skipped"] += 1 102 | 103 | def set_start_time(self): 104 | """Set the start time of the update process.""" 105 | self.update_stats["start_time"] = datetime.now() 106 | 107 | def set_end_time(self): 108 | """Set the end time of the update process.""" 109 | self.update_stats["end_time"] = datetime.now() 110 | 111 | def send_update_report(self, dry_run: bool = False): 112 | """Send update report to all configured notifiers.""" 113 | if not self.notifiers: 114 | logging.debug("No notifiers configured, skipping report") 115 | return 116 | 117 | # Set end time before sending report 118 | self.set_end_time() 119 | 120 | # Prepare update data 121 | update_data = { 122 | "hostname": get_docker_host_hostname(), 123 | "timestamp": datetime.now(), 124 | "dry_run": dry_run, 125 | **self.update_stats 126 | } 127 | 128 | # Send to each notifier 129 | for notifier in self.notifiers: 130 | try: 131 | if isinstance(notifier, TelegramNotifier): 132 | message = notifier.format_update_report(update_data) 133 | notifier.send([message]) 134 | elif isinstance(notifier, SMTPNotifier): 135 | message = notifier.format_update_report(update_data) 136 | notifier.send([message]) 137 | else: 138 | # Generic fallback for other notifiers 139 | notifier.send([f"Update report: {update_data}"]) 140 | 141 | except Exception as e: 142 | logging.error(f"Failed to send notification via {type(notifier).__name__}: {e}") 143 | 144 | def reset_stats(self): 145 | """Reset update statistics.""" 146 | self.update_stats = { 147 | "containers_processed": 0, 148 | "containers_updated": 0, 149 | "containers_failed": 0, 150 | "containers_skipped": 0, 151 | "update_details": [], 152 | "errors": [], 153 | "warnings": [], 154 | "start_time": None, 155 | "end_time": None 156 | } 157 | 158 | # Global notification manager instance 159 | notification_manager = NotificationManager() 160 | -------------------------------------------------------------------------------- /app/utils/registries/docker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import logging 6 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 7 | from typing import Optional 8 | 9 | import requests 10 | 11 | from ..config import config 12 | from . import generic 13 | from .auth import get_credentials 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def update_url_with_page_size(url: str, page_size: int = config.docker.pageSize): 19 | """ 20 | Ensure the URL includes the desired page_size parameter. 21 | 22 | This function modifies a URL to include or update the page_size parameter 23 | for Docker Hub API requests, ensuring consistent pagination behavior. 24 | 25 | Parameters: 26 | url (str): The original URL 27 | page_size (int): Desired page size for the request 28 | 29 | Returns: 30 | str: Modified URL with page_size parameter 31 | """ 32 | parts = urlparse(url) 33 | query = parse_qs(parts.query) 34 | query["page_size"] = [str(page_size)] 35 | new_query = urlencode(query, doseq=True) 36 | return urlunparse(parts._replace(query=new_query)) 37 | 38 | 39 | def get_dockerhub_jwt(repository_name: Optional[str] = None): 40 | """ 41 | Get a JWT token for Docker Hub authentication. 42 | 43 | This function authenticates with Docker Hub using configured credentials 44 | and returns a JWT token for subsequent API requests. 45 | 46 | Parameters: 47 | repository_name (Optional[str]): Name of the repository for authentication 48 | 49 | Returns: 50 | Optional[str]: JWT token if authentication successful, None otherwise 51 | """ 52 | logger.debug(f"func_params:\n{json.dumps({k: v for k, v in locals().items()}, indent=4)}", extra={"indent": 2}) 53 | creds = get_credentials(config.docker.apiUrl, repository_name) 54 | if not creds: 55 | logger.debug(f"No credentials found for repository: {repository_name}", extra={"indent": 2}) 56 | return None 57 | 58 | username = creds.get("username") 59 | password = creds.get("password") or creds.get("token") 60 | if not username or not password: 61 | logger.error(f"Incomplete credentials for repository: {repository_name} - username: {bool(username)}, password: {bool(password)}", extra={"indent": 2}) 62 | return None 63 | 64 | # Log which credentials are being used (without exposing the actual values) 65 | if repository_name: 66 | logging.info(f"Using repository-specific credentials for: {repository_name}", extra={"indent": 2}) 67 | else: 68 | logging.info(f"Using registry-level credentials for Docker Hub", extra={"indent": 2}) 69 | 70 | try: 71 | resp = requests.post( 72 | "https://hub.docker.com/v2/users/login/", 73 | json={"username": username, "password": password}, 74 | timeout=10 75 | ) 76 | resp.raise_for_status() 77 | token = resp.json().get("token") 78 | if token: 79 | logger.debug("Successfully obtained Docker Hub JWT token", extra={"indent": 4}) 80 | return token 81 | else: 82 | logger.error("No token received from Docker Hub login", extra={"indent": 4}) 83 | except Exception as e: 84 | logger.error(f"Docker Hub login failed: {e}", extra={"indent": 4}) 85 | return None 86 | 87 | 88 | def get_image_tags(imageTagsUrl, imageTag, max_pages=config.docker.pageCrawlLimit, page_size=config.docker.pageSize): 89 | """ 90 | Retrieve and process available image tags for a Docker Hub image. 91 | 92 | This function fetches image tags from Docker Hub API with authentication support, 93 | filters them based on the current image tag, and returns a sorted list of 94 | relevant tags for update evaluation. 95 | 96 | Parameters: 97 | imageTagsUrl (str): URL endpoint for fetching image tags 98 | imageTag (str): Current image tag for filtering 99 | max_pages (int): Maximum number of pages to crawl 100 | page_size (int): Number of tags per page 101 | 102 | Returns: 103 | List[Dict]: List of filtered and sorted image tag metadata 104 | """ 105 | tags = [] 106 | page_count = 0 107 | 108 | logger.debug(f"func_params:\n{json.dumps({k: v for k, v in locals().items()}, indent=4)}", extra={"indent": 4}) 109 | 110 | # Extract repository name from the URL for auth 111 | # URL format: https://registry.hub.docker.com/v2/repositories/captnio/captn/tags 112 | try: 113 | repository_name = imageTagsUrl.split("/repositories/")[1].split("/tags")[0] 114 | logger.debug(f"Extracted repository name from URL: {repository_name}", extra={"indent": 2}) 115 | except (IndexError, AttributeError): 116 | # Fallback: try to extract from imageTag if it contains the full repository name 117 | repository_name = imageTag.split(':')[0] if ':' in imageTag else imageTag 118 | logger.debug(f"Using fallback repository name from imageTag: {repository_name}", extra={"indent": 2}) 119 | 120 | # Auth: Try JWT if credentials exist, else anonymous 121 | jwt_token = get_dockerhub_jwt(repository_name) 122 | if jwt_token: 123 | headers = {"Authorization": f"JWT {jwt_token}"} 124 | logger.debug(f"Using Docker Hub JWT authentication", extra={"indent": 2}) 125 | else: 126 | headers = {} 127 | logger.debug(f"No authentication configured for Docker Hub (anonymous)", extra={"indent": 2}) 128 | 129 | while imageTagsUrl: 130 | if max_pages is not None and page_count >= max_pages: 131 | break 132 | 133 | imageTagsUrl = update_url_with_page_size(imageTagsUrl, page_size=page_size) 134 | 135 | try: 136 | logger.debug(f"Making request to: {imageTagsUrl}", extra={"indent": 2}) 137 | logger.debug(f"Headers: {headers}", extra={"indent": 2}) 138 | response = requests.get(imageTagsUrl, headers=headers, timeout=30) 139 | response.raise_for_status() 140 | data = response.json() 141 | 142 | if "results" in data: 143 | tags.extend(data["results"]) 144 | 145 | imageTagsUrl = data.get("next") 146 | page_count += 1 147 | except requests.exceptions.RequestException as e: 148 | logger.error(f"Error fetching image tags from {imageTagsUrl}: {e}", extra={"indent": 2}) 149 | break 150 | 151 | logger.debug(f"-> tags:\n{json.dumps(tags, indent=4)}", extra={"indent": 2}) 152 | tags = generic.filter_image_tags(tags, imageTag) 153 | logger.debug(f"-> filtered_tags:\n{json.dumps(tags, indent=4)}", extra={"indent": 2}) 154 | tags = generic.sort_tags(tags) 155 | logger.debug(f"-> sorted_filtered_tags:\n{json.dumps(tags, indent=4)}", extra={"indent": 2}) 156 | tags = generic.truncate_tags(tags, imageTag) 157 | logger.debug(f"-> truncated_tags:\n{json.dumps(tags, indent=4)}", extra={"indent": 2}) 158 | return tags 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | captn logo 3 |

captn

4 |

Intelligent Container Updater

5 |
6 | 7 | --- 8 | 9 |

10 | captn is an intelligent, rule-based container updater that automatically manages container updates using semantic versioning and registry metadata.
11 | Keep your containers up-to-date with confidence and control. 12 |

13 | 14 |
15 | 16 | [![Docker Pulls](https://img.shields.io/docker/pulls/captnio/captn)](https://hub.docker.com/r/captnio/captn) 17 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/captn-io/captn)](https://github.com/captn-io/captn/releases) 18 | [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) 19 | 20 |
21 | 22 | ## ✨ Features 23 | 24 | - 🎯 **Rule-Driven Updates** - Define custom update policies for different containers 25 | - 🌐 **Multi-Registry Support** - Docker Hub, GitHub Container Registry 26 | - 📈 **Progressive Upgrades** - Apply multiple updates in sequence 27 | - 👀 **Dry-Run Mode** - Preview changes before applying them 28 | - 📰 **Notifications** - Get notified about update status and results via Telegram and E-Mail 29 | - 🧹 **Automatic Cleanup** - Remove unused images and old backup containers 30 | - 📊 **Comprehensive Logging** - Detailed logging with configurable levels 31 | - ⏮️ **Rollback Support** - Automatic rollback on container startup and custom post-script failures 32 | - ⏰ **Scheduled Execution** - Built-in scheduler with cron expression support 33 | 34 | ## 🚧 Development Status 35 | 36 | > **Note**: This project is currently in an **early and active development phase**. While captn is functional and ready for testing, we're continuously improving and adding new features. 37 | 38 | > **⚠️ Docker Compose**: captn has been developed and tested exclusively with containers created via `docker run` commands. **Docker Compose support is currently untested** and may not work as expected. If you're using Docker Compose and encounter issues, please report them so we may improve compatibility if possible. 39 | 40 | **We welcome testers and contributors!** If you'd like to help shape captn, we're especially looking for contributions in: 41 | 42 | - **📝 Documentation** - Help improve guides, examples, and explanations 43 | - **🧪 Testing** - Test captn in different environments and report issues 44 | - **🏗️ Repository Optimization** - Create wiki pages, issue templates, and improve project structure 45 | 46 | Your feedback and contributions are highly valued as we work towards a stable release! 47 | 48 | ## 🚀 Quick Start 49 | 50 | ### 1. Create Directories 51 | 52 | ```bash 53 | # Create directories for configuration and log files 54 | mkdir -p ~/captn/{conf,logs} 55 | ``` 56 | 57 | ### 2. Run captn Container 58 | 59 | ```bash 60 | # Run captn container with proper volume mounts 61 | docker run -d \ 62 | --name captn \ 63 | --restart unless-stopped \ 64 | -e TZ=Europe/Berlin \ 65 | -v /etc/localtime:/etc/localtime:ro \ 66 | -v /var/run/docker.sock:/var/run/docker.sock \ 67 | -v ~/captn/conf:/app/conf \ 68 | -v ~/captn/logs:/app/logs \ 69 | captnio/captn:0.8.3 70 | ``` 71 | 72 | ### 3. First Run (Dry-Run Mode) 73 | 74 | By default, captn runs in normal mode but all containers are assigned to the `default` rule which **does not allow any updates at all** for safety reasons. To get a preview of what captn would do, you can: 75 | 76 | 1. **Assign containers to different rules** in the configuration file 77 | 2. **Run in dry-run mode** to see what would be updated: 78 | 79 | ```bash 80 | # Preview what would be updated 81 | docker exec captn captn --dry-run 82 | 83 | # Run actual updates (after reviewing dry-run results) 84 | docker exec captn captn 85 | ``` 86 | 87 | ## 🔧 Advanced Features 88 | 89 | ### Pre/Post Scripts 90 | 91 | captn supports executing custom scripts before and after updates: 92 | 93 | - **Pre-scripts**: Run before container updates (backups, health checks) 94 | - **Post-scripts**: Run after successful updates (verification, notifications, customizations, post-update or cleanup tasks) 95 | - **Container-specific**: Scripts can be tailored to specific containers 96 | - **Failure handling**: Configure whether to continue or skip/rollback on script failures 97 | 98 | ### Notifications 99 | 100 | Get updates about captn's activities: 101 | 102 | - Update status and results 103 | - Error notifications 104 | - Summary reports 105 | 106 | #### Currently supported notification methods 107 | 108 | - **Telegram**: Quick update summaries via Telegram Bot API 109 | - **E-Mail**: SMTP-based email notifications with detailed HTML reports 110 | 111 | ## 📚 Documentation 112 | 113 | For detailed configuration options, advanced usage, and troubleshooting, please refer to: 114 | 115 | - **[Introduction & Getting Started](https://github.com/captn-io/captn/blob/main/docs/01-Introduction.md)** - Overview and concepts 116 | - **[CLI Reference](https://github.com/captn-io/captn/blob/main/docs/02-CLI-Reference.md)** - Command-line usage 117 | - **[Configuration Reference](https://github.com/captn-io/captn/blob/main/docs/03-Configuration.md)** - All configuration options 118 | - **[Scripts Guide](https://github.com/captn-io/captn/blob/main/docs/04-Scripts.md)** - Pre/post-update scripts 119 | 120 | ## 🤝 Contributing 121 | 122 | We welcome contributions! captn is in active development and we're particularly interested in: 123 | 124 | - **Documentation improvements** - Enhance existing docs, add examples, create tutorials 125 | - **Testing and bug reports** - Help us identify and fix issues 126 | - **Repository infrastructure** - Help create wiki pages, issue templates, PR templates, and improve project organization 127 | - **Code contributions** - New features, optimizations, and bug fixes 128 | 129 | Feel free to open an issue to discuss your ideas or submit a pull request directly. Every contribution, no matter how small, is appreciated! 130 | 131 | ## 📄 License 132 | 133 | This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)** - see the [LICENSE](https://github.com/captn-io/captn/blob/main/LICENSE) file for details. 134 | 135 | ## 💬 Support 136 | 137 | - **Issues**: [GitHub Issues](https://github.com/captn-io/captn/issues) 138 | - **Discussions**: [GitHub Discussions](https://github.com/captn-io/captn/discussions) 139 | - **Documentation**: [Docs](https://github.com/captn-io/captn/blob/main/docs/01-Introduction.md) 140 | 141 | --- 142 | 143 | **⚠️ Important**: Always test with `--dry-run` first to understand what captn will do before running actual updates. 144 | 145 | 146 |
147 |

Brewed with ❤️ and loads of 🍺

148 |

149 | GitHub • 150 | Docker Hub • 151 | Issues 152 |

153 |
154 | -------------------------------------------------------------------------------- /.dev/scripts/osx-dev-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Colors for output 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | YELLOW='\033[1;33m' 7 | BLUE='\033[0;34m' 8 | NC='\033[0m' # No Color 9 | 10 | print_status() { 11 | echo -e "${BLUE}[INFO]${NC} $1" 12 | } 13 | 14 | print_success() { 15 | echo -e "${GREEN}[SUCCESS]${NC} $1" 16 | } 17 | 18 | print_warning() { 19 | echo -e "${YELLOW}[WARNING]${NC} $1" 20 | } 21 | 22 | print_error() { 23 | echo -e "${RED}[ERROR]${NC} $1" 24 | } 25 | 26 | # Check if Docker is running 27 | print_status "Checking Docker status..." 28 | if ! docker info >/dev/null 2>&1; then 29 | print_warning "Docker is not running." 30 | 31 | if [[ "$(uname)" == "Darwin" ]]; then 32 | print_status "Starting Docker Desktop..." 33 | open -a "Docker" 34 | 35 | print_status "Waiting for Docker Desktop to start..." 36 | for i in {1..60}; do 37 | if docker info >/dev/null 2>&1; then 38 | echo "" 39 | print_success "Docker Desktop is now running." 40 | break 41 | else 42 | echo -n "." 43 | sleep 2 44 | fi 45 | done 46 | 47 | if ! docker info >/dev/null 2>&1; then 48 | echo "" 49 | print_error "Docker failed to start. Please start Docker Desktop manually." 50 | exit 1 51 | fi 52 | else 53 | print_error "Please start Docker and try again." 54 | exit 1 55 | fi 56 | else 57 | print_success "Docker is running." 58 | fi 59 | 60 | # Variables 61 | CURRENT_VERSION=$(python3 -c "import app; print(app.__version__)" 2>/dev/null || echo "unknown") 62 | DEV_IMAGE="captnio/captn:$CURRENT_VERSION" 63 | CONTAINER_NAME="captn-dev" 64 | DOCKERFILE_PATH="docker/DOCKERFILE" 65 | 66 | # Check if we need to build/rebuild the development image 67 | print_status "Checking development image..." 68 | 69 | BUILD_NEEDED=false 70 | 71 | if ! docker image inspect $DEV_IMAGE >/dev/null 2>&1; then 72 | print_status "Development image not found." 73 | BUILD_NEEDED=true 74 | else 75 | # Check if any relevant files are newer than the image 76 | IMAGE_DATE=$(docker image inspect $DEV_IMAGE --format '{{.Created}}') 77 | 78 | # Files that should trigger a rebuild 79 | CHECK_FILES=( 80 | "docker/DOCKERFILE" 81 | "requirements.txt" 82 | "docker/entrypoint.sh" 83 | "app/__init__.py" 84 | ) 85 | 86 | for file in "${CHECK_FILES[@]}"; do 87 | if [[ -f "$file" ]]; then 88 | if [[ "$(uname)" == "Darwin" ]]; then 89 | FILE_DATE=$(stat -f "%Sm" -t "%Y-%m-%dT%H:%M:%SZ" "$file" 2>/dev/null) 90 | else 91 | FILE_DATE=$(date -r "$file" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) 92 | fi 93 | 94 | if [[ "$FILE_DATE" > "$IMAGE_DATE" ]]; then 95 | print_status "$file is newer than image. Rebuild needed." 96 | BUILD_NEEDED=true 97 | break 98 | fi 99 | fi 100 | done 101 | 102 | if [[ "$BUILD_NEEDED" == false ]]; then 103 | print_success "Development image is up to date." 104 | fi 105 | fi 106 | 107 | # Build development image if needed 108 | if [[ "$BUILD_NEEDED" == true ]]; then 109 | print_status "Building development image (native architecture)..." 110 | print_status "Using version: $CURRENT_VERSION" 111 | 112 | docker build \ 113 | -f $DOCKERFILE_PATH \ 114 | -t $DEV_IMAGE \ 115 | --build-arg VERSION="$CURRENT_VERSION" \ 116 | --build-arg REVISION="$(git rev-parse HEAD 2>/dev/null || echo 'unknown')" \ 117 | --build-arg CREATED="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ 118 | . || { 119 | print_error "Failed to build development image" 120 | exit 1 121 | } 122 | print_success "Development image built successfully." 123 | fi 124 | 125 | # Stop and remove existing development container if running 126 | if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then 127 | print_status "Stopping running development container..." 128 | docker stop $CONTAINER_NAME >/dev/null 129 | fi 130 | 131 | if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then 132 | print_status "Removing existing development container..." 133 | docker rm $CONTAINER_NAME >/dev/null 134 | fi 135 | 136 | # Get current user info for proper file permissions 137 | if [[ "$(uname)" == "Darwin" ]]; then 138 | USER_ID=$(id -u) 139 | GROUP_ID=$(id -g) 140 | else 141 | USER_ID=$(id -u) 142 | GROUP_ID=$(id -g) 143 | fi 144 | 145 | # Start development container 146 | print_status "Starting development container..." 147 | print_status "Architecture: $(uname -m)" 148 | print_status "User ID: $USER_ID:$GROUP_ID" 149 | 150 | docker run -it --rm \ 151 | --name $CONTAINER_NAME \ 152 | -v /var/run/docker.sock:/var/run/docker.sock \ 153 | -v "$(pwd)/app":/app \ 154 | -v captn-dev-cache:/root/.cache \ 155 | -v captn-dev-pip:/opt/venv/lib/python3.11/site-packages \ 156 | -w /app \ 157 | -e PYTHONPATH=/app \ 158 | -e DEVELOPMENT=true \ 159 | -e HOST_UID=$USER_ID \ 160 | -e HOST_GID=$GROUP_ID \ 161 | --entrypoint /bin/bash \ 162 | $DEV_IMAGE \ 163 | -c ' 164 | echo "======================================" 165 | echo -e "\033[0;32mcaptn Development Container\033[0m" 166 | echo "======================================" 167 | echo "Image: captnio/captn:dev" 168 | echo "Version: $CURRENT_VERSION" 169 | echo "Architecture: $(uname -m)" 170 | echo "Working Directory: $(pwd)" 171 | echo "Python: $(python3 --version)" 172 | echo "" 173 | echo "Available commands:" 174 | echo " captn - Run captn (main command)" 175 | echo " python -m app - Run via Python module" 176 | echo " pytest - Run tests" 177 | echo " black app/ - Format code" 178 | echo " flake8 app/ - Lint code" 179 | echo " mypy app/ - Type checking" 180 | echo " cu, dcu - Legacy aliases" 181 | echo "" 182 | echo "Configuration:" 183 | echo " $(ls -la conf/ 2>/dev/null | head -3 | tail -1 | cut -c1-10) /app/conf/" 184 | echo "" 185 | echo "Quick start:" 186 | echo " captn --version" 187 | echo " captn --help" 188 | echo " captn --dry-run" 189 | echo "" 190 | echo "Note: This container uses the SAME base as production!" 191 | echo "Changes to files persist on the host system." 192 | echo "======================================" 193 | echo "" 194 | 195 | # Verify captn is working 196 | if command -v captn >/dev/null 2>&1; then 197 | echo "✅ captn command available" 198 | captn --version 2>/dev/null || echo "⚠️ captn version check failed" 199 | else 200 | echo "⚠️ captn command not found" 201 | fi 202 | 203 | echo "" 204 | echo "Starting interactive bash shell..." 205 | echo "" 206 | 207 | exec bash 208 | ' 209 | 210 | print_success "Development container session ended." -------------------------------------------------------------------------------- /app/utils/scheduler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import time 6 | import threading 7 | import subprocess 8 | import sys 9 | from datetime import datetime, timedelta 10 | from croniter import croniter 11 | 12 | from .config import config 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class CaptnScheduler: 18 | """ 19 | A scheduler that runs captn at specified intervals using cron expressions. 20 | 21 | This class provides a thread-safe scheduler that can run captn automatically 22 | based on cron expressions. It supports runtime configuration changes and 23 | graceful shutdown handling. 24 | """ 25 | 26 | def __init__(self): 27 | """ 28 | Initialize the scheduler. 29 | 30 | Sets up the internal state for managing the scheduler thread and 31 | configuration changes. 32 | """ 33 | self.running = False 34 | self.thread = None 35 | self.current_schedule = None 36 | self._lock = threading.Lock() 37 | 38 | def start(self): 39 | """ 40 | Start the scheduler in a background thread. 41 | 42 | This method creates and starts a daemon thread that will run the 43 | scheduler loop. The scheduler will continue running until stopped. 44 | """ 45 | if self.running: 46 | logger.warning("Scheduler is already running") 47 | return 48 | 49 | self.running = True 50 | self.thread = threading.Thread(target=self.run_scheduler, daemon=True) 51 | self.thread.start() 52 | logger.info("captn scheduler started") 53 | 54 | def stop(self): 55 | """ 56 | Stop the scheduler. 57 | 58 | This method gracefully stops the scheduler by setting the running flag 59 | to False and waiting for the scheduler thread to complete. 60 | """ 61 | with self._lock: 62 | self.running = False 63 | if self.thread: 64 | self.thread.join(timeout=5) 65 | logger.info("captn scheduler stopped") 66 | 67 | def run_scheduler(self): 68 | """ 69 | Main scheduler loop. 70 | 71 | This method contains the main scheduling logic that calculates the next 72 | run time based on the cron expression and executes captn when appropriate. 73 | It also handles configuration reloading and schedule changes. 74 | """ 75 | while self.running: 76 | try: 77 | # Reload configuration to detect changes 78 | config.reload() 79 | 80 | # Get current cron expression from config 81 | cron_expression = getattr(config.general, 'cronSchedule', '30 2 * * *') 82 | 83 | # Check if schedule has changed 84 | if cron_expression != self.current_schedule: 85 | logger.info(f"Scheduler schedule changed to: {cron_expression}") 86 | self.current_schedule = cron_expression 87 | 88 | # Calculate next run time 89 | now = datetime.now() 90 | try: 91 | cron = croniter(cron_expression, now) 92 | next_run = cron.get_next(datetime) 93 | 94 | # Calculate sleep time 95 | sleep_seconds = (next_run - now).total_seconds() 96 | 97 | if sleep_seconds > 0: 98 | logger.info(f"Next captn run scheduled for: {next_run.strftime('%Y-%m-%d %H:%M:%S')}") 99 | 100 | # Sleep in smaller intervals to allow for graceful shutdown and config changes 101 | while sleep_seconds > 0 and self.running: 102 | # Check for config changes every 10 seconds 103 | sleep_interval = min(sleep_seconds, 10) 104 | time.sleep(sleep_interval) 105 | sleep_seconds -= sleep_interval 106 | 107 | # Reload config and check if schedule changed during sleep 108 | config.reload() 109 | current_cron = getattr(config.general, 'cronSchedule', '30 2 * * *') 110 | if current_cron != self.current_schedule: 111 | logger.info(f"Schedule changed during sleep to: {current_cron}") 112 | self.current_schedule = current_cron 113 | break # Exit sleep loop to recalculate 114 | 115 | if self.running: 116 | # Execute captn 117 | logger.info("Executing scheduled captn run") 118 | self.execute_captn() 119 | 120 | except ValueError as e: 121 | logger.error(f"Invalid cron expression '{cron_expression}': {e}") 122 | # Sleep for 5 minutes before retrying 123 | time.sleep(300) 124 | 125 | except Exception as e: 126 | logger.error(f"Error in scheduler loop: {e}") 127 | # Sleep for 1 minute before retrying 128 | time.sleep(60) 129 | 130 | def execute_captn(self): 131 | """ 132 | Execute the captn command using subprocess to avoid signal handler issues. 133 | 134 | This method runs captn as a separate subprocess to avoid issues with 135 | signal handlers in threaded environments. It uses a timeout to prevent 136 | the scheduler from hanging indefinitely. 137 | """ 138 | try: 139 | # Use subprocess to run captn in a separate process 140 | # This avoids signal handler issues in threads 141 | logger.info("Starting captn execution") 142 | 143 | # Run captn using the shell script to ensure proper locking 144 | result = subprocess.run([ 145 | '/app/cli/captn.sh' 146 | ], timeout=18000) # 5 hours timeout 147 | 148 | if result.returncode == 0: 149 | logger.info("captn execution completed successfully") 150 | else: 151 | logger.error(f"captn execution failed with return code {result.returncode}") 152 | 153 | except subprocess.TimeoutExpired: 154 | logger.error("captn execution timed out after 5 hours") 155 | except Exception as e: 156 | logger.error(f"Error executing captn: {e}") 157 | 158 | def get_next_run(self): 159 | """ 160 | Get the next scheduled run time. 161 | 162 | This method calculates the next time captn will run based on the 163 | current cron schedule configuration. 164 | 165 | Returns: 166 | datetime or None: Next scheduled run time, or None if calculation fails 167 | """ 168 | try: 169 | cron_expression = getattr(config.general, 'cronSchedule', '30 2 * * *') 170 | cron = croniter(cron_expression, datetime.now()) 171 | return cron.get_next(datetime) 172 | except Exception as e: 173 | logger.error(f"Error calculating next run time: {e}") 174 | return None 175 | 176 | 177 | # Global scheduler instance 178 | _scheduler = None 179 | 180 | 181 | def get_scheduler(): 182 | """ 183 | Get the global scheduler instance. 184 | 185 | This function implements a singleton pattern for the scheduler, 186 | ensuring only one scheduler instance exists throughout the application. 187 | 188 | Returns: 189 | CaptnScheduler: The global scheduler instance 190 | """ 191 | global _scheduler 192 | if _scheduler is None: 193 | _scheduler = CaptnScheduler() 194 | return _scheduler 195 | 196 | 197 | def start_scheduler(): 198 | """ 199 | Start the global scheduler. 200 | 201 | This function starts the global scheduler instance, which will begin 202 | running captn according to the configured cron schedule. 203 | """ 204 | scheduler = get_scheduler() 205 | scheduler.start() 206 | 207 | 208 | def stop_scheduler(): 209 | """ 210 | Stop the global scheduler. 211 | 212 | This function gracefully stops the global scheduler and cleans up 213 | the scheduler instance. 214 | """ 215 | global _scheduler 216 | if _scheduler: 217 | _scheduler.stop() 218 | _scheduler = None 219 | 220 | 221 | def is_scheduler_running(): 222 | """ 223 | Check if the scheduler is running. 224 | 225 | Returns: 226 | bool: True if the scheduler is currently running, False otherwise 227 | """ 228 | return _scheduler is not None and _scheduler.running -------------------------------------------------------------------------------- /app/utils/self_update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import os 6 | 7 | from docker.types import Mount 8 | from app.utils.config import config 9 | 10 | 11 | def is_self_update_helper(): 12 | """ 13 | Check if this captn instance is running as a self-update helper. 14 | 15 | This function checks for the ROLE environment variable to determine 16 | if this instance should act as a self-update helper container. 17 | 18 | Returns: 19 | bool: True if this is a self-update helper, False otherwise 20 | """ 21 | role = os.environ.get("ROLE") 22 | return role == "SELFUPDATEHELPER" 23 | 24 | 25 | def should_skip_daemon_mode(): 26 | """ 27 | Determine if captn should skip daemon mode based on its role. 28 | 29 | Self-update helper containers should not start the daemon mode 30 | as they are meant to perform a specific update task and then exit. 31 | 32 | Returns: 33 | bool: True if daemon mode should be skipped, False otherwise 34 | """ 35 | return is_self_update_helper() 36 | 37 | 38 | def complete_self_update(client, dry_run): 39 | """ 40 | Legacy function for backward compatibility. 41 | 42 | This function is kept for backward compatibility but is no longer needed 43 | with the new ROLE-based self-update approach. 44 | 45 | Parameters: 46 | client: Docker client instance 47 | dry_run (bool): If True, only log what would be done 48 | """ 49 | # This function is no longer needed with the new approach 50 | # but kept for backward compatibility 51 | pass 52 | 53 | 54 | def create_self_update_helper_container(client, container_name, new_image_reference, dry_run): 55 | """ 56 | Create a helper container to perform the self-update. 57 | 58 | This function creates a temporary helper container that will perform the 59 | actual self-update operation. The helper container runs the new image 60 | with the ROLE=SELFUPDATEHELPER environment variable and will execute 61 | captn to update the original container, then exit. 62 | 63 | Parameters: 64 | client: Docker client instance 65 | container_name: Name of the container to update 66 | new_image_reference: New image reference to use 67 | dry_run: If True, only log what would be done 68 | 69 | Returns: 70 | Container object if successful, None otherwise 71 | """ 72 | helper_name = f"{container_name}_self_update_helper" 73 | 74 | try: 75 | # Prepare container configuration with only the Docker socket mount 76 | mounts = [ 77 | Mount(type="bind", source="/var/run/docker.sock", target="/var/run/docker.sock") 78 | ] 79 | 80 | # Environment variables for the helper container 81 | environment = { 82 | "ROLE": "SELFUPDATEHELPER", 83 | "TARGET_CONTAINER": container_name, 84 | } 85 | 86 | # Get removeHelperContainer setting from config 87 | remove_helper = getattr(config.selfUpdate, "removeHelperContainer", False) 88 | 89 | # Create helper container 90 | action = "Would create" if dry_run else "Creating" 91 | logging.info(f"{action} helper container '{helper_name}'", extra={"indent": 2}) 92 | 93 | if not dry_run: 94 | container = client.containers.run( 95 | image=new_image_reference, 96 | name=helper_name, 97 | mounts=mounts, 98 | environment=environment, 99 | privileged=False, 100 | remove=remove_helper, 101 | detach=True, 102 | command=[f"{'--dry-run' if dry_run else '--run'}", "--filter", f"name={container_name}", "--log-level", "debug"], 103 | ) 104 | 105 | logging.info(f"Helper container '{helper_name}' created with ID: {container.short_id}", extra={"indent": 4}) 106 | return container 107 | else: 108 | logging.info(f"Would create helper container", extra={"indent": 4}) 109 | return None 110 | 111 | except Exception as e: 112 | logging.error(f"Failed to create helper container '{helper_name}': {e}", extra={"indent": 4}) 113 | return None 114 | 115 | 116 | def execute_self_update_from_helper(client, target_container_name, dry_run): 117 | """ 118 | Execute self-update from within a helper container. 119 | 120 | This function is called when captn is running as a self-update helper 121 | (ROLE=SELFUPDATEHELPER). It performs the actual update of the target 122 | container and then exits the helper container. 123 | 124 | Parameters: 125 | client: Docker client instance 126 | target_container_name: Name of the container to update 127 | dry_run: If True, only log what would be done 128 | """ 129 | if not is_self_update_helper(): 130 | logging.warning("execute_self_update_from_helper called but ROLE is not SELFUPDATEHELPER", extra={"indent": 0}) 131 | return 132 | 133 | logging.info(f"Executing self-update for container '{target_container_name}' from helper", extra={"indent": 0}) 134 | 135 | try: 136 | # Get the target container 137 | target_container = None 138 | try: 139 | target_container = client.containers.get(target_container_name) 140 | except Exception as e: 141 | logging.error(f"Target container '{target_container_name}' not found: {e}", extra={"indent": 2}) 142 | return 143 | 144 | # Get container inspection data 145 | container_inspect_data = client.api.inspect_container(target_container.id) 146 | 147 | # Get image inspect data for environment filtering 148 | current_image_id = container_inspect_data.get("Image") 149 | image_inspect_data = client.api.inspect_image(current_image_id) 150 | 151 | # The helper container is running the new image, so we need to get the new image reference 152 | # from the helper container's image 153 | try: 154 | helper_container_id = os.environ.get("HOSTNAME", "") 155 | helper_inspect = client.api.inspect_container(helper_container_id) 156 | helper_image_id = helper_inspect.get("Image") 157 | helper_image = client.images.get(helper_image_id) 158 | new_image_reference = helper_image.tags[0] if helper_image.tags else helper_image.id 159 | except Exception as e: 160 | logging.error(f"Could not get helper image reference: {e}", extra={"indent": 2}) 161 | return 162 | 163 | # Recreate the container with the new image 164 | new_container = recreate_container( 165 | client, 166 | target_container, 167 | new_image_reference, 168 | container_inspect_data, 169 | dry_run, 170 | image_inspect_data 171 | ) 172 | 173 | if new_container: 174 | logging.info(f"Successfully updated container '{target_container_name}'", extra={"indent": 2}) 175 | else: 176 | logging.error(f"Failed to update container '{target_container_name}'", extra={"indent": 2}) 177 | 178 | except Exception as e: 179 | logging.error(f"Error during self-update execution: {e}", extra={"indent": 2}) 180 | finally: 181 | # Helper container should exit after completing the update 182 | if not dry_run: 183 | logging.info("Self-update helper container exiting", extra={"indent": 0}) 184 | # Exit the process to stop the helper container 185 | os._exit(0) 186 | 187 | 188 | def trigger_self_update_from_producer(client, container_name, new_image_reference, dry_run): 189 | """ 190 | Trigger a self-update process by creating a helper container. 191 | 192 | This function initiates the self-update process for the captn container by creating 193 | a temporary helper container that will handle the actual update process. The helper 194 | container runs independently and can safely update the main captn container without 195 | being affected by the update itself. 196 | 197 | The function creates a helper container with the necessary environment variables 198 | and configuration to perform the self-update. If the helper container creation 199 | fails, it attempts to clean up any partially created resources. 200 | 201 | Args: 202 | client: Docker client instance for container operations 203 | container_name (str): Name of the container to be updated (typically 'captn') 204 | new_image_reference (str): Full image reference for the new version to update to 205 | dry_run (bool): If True, simulate the update process without making actual changes 206 | 207 | Returns: 208 | None: This function doesn't return a value, but logs the progress and results 209 | 210 | Raises: 211 | Exception: Any exceptions during helper container creation are caught and logged, 212 | but not re-raised to prevent disruption of the main update process 213 | 214 | Note: 215 | This function is called at the end of the main update cycle to handle self-updates 216 | separately from other container updates, ensuring that the captn container can 217 | be updated without interrupting the update process itself. 218 | """ 219 | try: 220 | helper_container = create_self_update_helper_container( 221 | client, container_name, new_image_reference, dry_run 222 | ) 223 | if helper_container: 224 | logging.info(f"Update process for '{container_name}' is now handled by '{helper_container.name}'", extra={"indent": 2}) 225 | except Exception as e: 226 | logging.error("Failed to create helper container", extra={"indent": 2}) 227 | try: 228 | helper_container.remove(force=True) 229 | logging.info("Helper container cleaned up", extra={"indent": 2}) 230 | except Exception as e: 231 | logging.warning(f"Could not clean up helper container: {e}", extra={"indent": 2}) 232 | 233 | 234 | def recreate_container(client, container, image, container_inspect_data, dry_run, image_inspect_data=None): 235 | """ 236 | Import and call the recreate_container function from engines.docker. 237 | 238 | This function is a wrapper that imports and calls the actual recreate_container 239 | function from the engines.docker module, providing a consistent interface 240 | for self-update operations. 241 | 242 | Parameters: 243 | client: Docker client instance 244 | container: Container object to recreate 245 | image: New image reference 246 | container_inspect_data: Original container inspection data 247 | dry_run (bool): If True, only log what would be done 248 | image_inspect_data: Image inspection data for environment filtering (optional) 249 | 250 | Returns: 251 | Container object if successful, None otherwise 252 | """ 253 | from .engines.docker import recreate_container as _recreate_container 254 | return _recreate_container(client, container, image, container_inspect_data, dry_run, image_inspect_data) 255 | -------------------------------------------------------------------------------- /docs/01-Introduction.md: -------------------------------------------------------------------------------- 1 | # captn Documentation 2 | 3 |
4 | captn logo 5 |

Intelligent Container Updater

6 |
7 | 8 | --- 9 | 10 | ## Welcome to captn 11 | 12 | **captn** is an intelligent, rule-based container updater that automatically manages Docker container updates using semantic versioning and registry metadata. It provides a safe and controlled way to keep your containers up-to-date with configurable update policies and comprehensive verification mechanisms. 13 | 14 | ## What is captn? 15 | 16 | captn is designed to automate the tedious task of keeping Docker containers updated while maintaining full control over what gets updated and when. Unlike simple "update everything" approaches, captn uses sophisticated rules to determine which updates are safe and appropriate for each container. 17 | 18 | ### Key Concepts 19 | 20 | #### 1. Rule-Based Updates 21 | captn uses configurable rules that define which types of updates are allowed for each container. For example: 22 | - Production databases might only allow patch updates 23 | - Development containers might accept all updates 24 | - Security-critical services might only accept security and patch updates 25 | 26 | #### 2. Semantic Versioning Support 27 | captn understands semantic versioning and can differentiate between: 28 | - **Major updates** (e.g., 1.x.x → 2.x.x): Breaking changes 29 | - **Minor updates** (e.g., 1.1.x → 1.2.x): New features 30 | - **Patch updates** (e.g., 1.1.1 → 1.1.2): Bug fixes 31 | - **Build updates** (e.g., 1.1.1-1 → 1.1.1-2): Build variations 32 | - **Digest updates**: Same tag, different image digest 33 | 34 | #### 3. Progressive Upgrades 35 | When multiple versions are available, captn can apply them in different ways based on the `progressiveUpgrade` setting in your rule configuration: 36 | - **Progressive mode (`progressiveUpgrade: true`)**: All available updates are applied sequentially in a single captn run (e.g., 1.0 → 1.1 → 1.2 → 2.0), with verification after each step 37 | - **Single-step mode (`progressiveUpgrade: false`)**: Only the next available update is applied per captn run (e.g., 1.0 → 1.1), with remaining updates applied in subsequent runs 38 | 39 | #### 4. Verification & Rollback 40 | After each update, captn verifies that the container starts successfully and remains stable. If an update fails, it automatically rolls back to the previous version. 41 | 42 | ## Core Features 43 | 44 | For a complete feature overview, see the [README](https://github.com/captn-io/captn#features). 45 | 46 | Key capabilities that make captn powerful: 47 | - **Rule-Driven Updates**: Granular policies with conditional requirements and lag policies 48 | - **Multi-Registry Support**: Docker Hub, GHCR, and private registries 49 | - **Progressive Upgrades**: Sequential version updates with verification at each step 50 | - **Safety Mechanisms**: Dry-run mode, automatic rollback, and verification periods 51 | - **Notifications**: Real-time updates via Telegram and detailed email reports 52 | - **Automation**: Built-in scheduler with cron support and container-specific scripts 53 | 54 | ## How captn Works 55 | 56 | ### Update Workflow 57 | 58 | 1. **Discovery**: captn scans your Docker environment for containers 59 | 2. **Filter**: Applies any filters you've specified (by name, status, etc.) 60 | 3. **Rule Evaluation**: For each container: 61 | - Determines which update rule applies 62 | - Fetches available image versions from the registry 63 | - Compares local version with remote versions 64 | - Determines update type (major, minor, patch, etc.) 65 | 4. **Pre-Script Execution**: Runs container-specific pre-update scripts 66 | 5. **Update**: Pulls new image and recreates the container 67 | 6. **Verification**: Monitors container stability for configured duration 68 | 7. **Post-Script Execution**: Runs container-specific post-update scripts 69 | 8. **Cleanup**: Removes old images and backup containers if configured 70 | 9. **Notification**: Sends update report via configured channels 71 | 72 | ### Safety Mechanisms 73 | 74 | captn includes multiple safety mechanisms: 75 | 76 | - **Dry-run mode**: Test without making changes 77 | - **Backup containers**: Old containers are renamed and kept as backups 78 | - **Verification period**: New containers must remain stable 79 | - **Automatic rollback**: Failed updates are automatically reverted 80 | - **Script timeouts**: Scripts have configurable execution timeouts 81 | - **Minimal image age**: Only update images older than a specified age 82 | - **Self-update protection**: Special handling for updating captn itself 83 | 84 | ## Documentation Structure 85 | 86 | This documentation is organized into the following sections: 87 | 88 | ### [CLI Reference](02-CLI-Reference.md) 89 | Complete command-line interface documentation including all parameters, filters, and usage examples. 90 | 91 | ### [Configuration](03-Configuration.md) 92 | Comprehensive configuration reference covering all settings, sections, and options with detailed explanations and examples. 93 | 94 | ### [Pre/Post-Scripts](04-Scripts.md) 95 | Guide to using pre-update and post-update scripts with practical examples and best practices. 96 | 97 | ## Quick Start 98 | 99 | For installation instructions, see the [Quick Start section in the README](https://github.com/captn-io/captn#-quick-start). 100 | 101 | After installation, configure update rules in `~/captn/conf/captn.cfg`: 102 | 103 | ```ini 104 | [assignments] 105 | # Assign containers to update rules 106 | nginx = permissive 107 | postgres = conservative 108 | redis = patch_only 109 | ``` 110 | 111 | **Important:** Always test with `--dry-run` first: 112 | ```bash 113 | docker exec captn captn --dry-run 114 | ``` 115 | 116 | ## Use Cases 117 | 118 | ### Development Environments 119 | Keep development containers automatically updated with the latest features: 120 | ```ini 121 | [assignments] 122 | dev-* = permissive 123 | ``` 124 | 125 | ### Production Environments 126 | Conservative updates with thorough verification: 127 | ```ini 128 | [assignments] 129 | prod-web = patch_only 130 | prod-db = conservative 131 | prod-cache = security_only 132 | ``` 133 | 134 | ### Mixed Environments 135 | Different rules for different services: 136 | ```ini 137 | [assignments] 138 | # Web servers: minor and patch updates 139 | nginx = ci_cd 140 | apache = ci_cd 141 | 142 | # Databases: only patch updates 143 | postgres = patch_only 144 | mysql = patch_only 145 | 146 | # Caches: all updates allowed 147 | redis = permissive 148 | memcached = permissive 149 | 150 | # Critical services: security updates only 151 | auth-service = security_only 152 | payment-service = security_only 153 | ``` 154 | 155 | ## Best Practices 156 | 157 | ### 1. Review Release Notes After Updates 158 | **captn automates updates, but does not replace your responsibility.** 159 | 160 | Even after successful automated updates by captn, always review the release notes of the updated image versions. Breaking changes, deprecated features, or new configuration requirements may have been introduced that are critical for continued operation. 161 | 162 | ### 2. Test Each Container Individually 163 | **Not all containers are equal.** 164 | 165 | Each image and container should be evaluated individually for automated updates: 166 | - Some applications handle updates seamlessly 167 | - Others require manual intervention or configuration changes 168 | 169 | Start with non-critical containers and gradually expand to more critical services. 170 | 171 | ### 3. Implement a Backup Strategy 172 | **captn does not replace your backup strategy.** 173 | 174 | Automated updates increase the importance of regular backups. Consider what needs to be backed up! 175 | 176 | **Backup considerations:** 177 | - **Test your backups regularly** - verify they can actually be restored 178 | - **Know your recovery procedure** - document and practice the restore process 179 | - **Monitor backup success** - ensure backups are completing successfully 180 | 181 | Remember: A backup that hasn't been tested is not a backup. 182 | 183 | ### 4. Start with Dry-Run 184 | Always test your configuration with `--dry-run` before applying updates: 185 | ```bash 186 | docker exec captn captn --dry-run 187 | ``` 188 | 189 | ### 5. Use Conservative Rules Initially 190 | Start with conservative update rules and gradually make them more permissive as you gain confidence: 191 | ```ini 192 | [assignments] 193 | # Start with patch_only or conservative - or a custom rule 194 | myapp = patch_only 195 | ``` 196 | 197 | ### 6. Schedule Updates During Low-Traffic Periods 198 | Configure the cron schedule to run during maintenance windows: 199 | ```ini 200 | [general] 201 | cronSchedule = 0 3 * * * # 3:00 AM daily 202 | ``` 203 | 204 | ### 7. Keep Backup Containers 205 | Configure cleanup policies to retain recent backups: 206 | ```ini 207 | [prune] 208 | removeOldContainers = true 209 | minBackupAge = 48h 210 | minBackupsToKeep = 1 211 | ``` 212 | 213 | ## Common Scenarios 214 | 215 | ### Update a Single Container 216 | ```bash 217 | docker exec captn captn --filter name=traefik 218 | ``` 219 | 220 | ### Update Multiple Specific Containers 221 | ```bash 222 | docker exec captn captn --filter name=immich-* 223 | ``` 224 | 225 | ## Troubleshooting 226 | 227 | ### Container Not Being Updated 228 | 229 | 1. **Check logs**: 230 | ```bash 231 | docker exec captn captn --dry-run --log-level debug --clear-logs --filter name=mycontainer 232 | ``` 233 | 234 | 2. **Check assigned rule**: 235 | - Verify rule assignment in `captn.cfg` 236 | - Check rule allows the available update type 237 | - Verify minimum image age requirement 238 | 239 | ### Update Failed or Rolled Back 240 | 241 | 1. **Check logs**: 242 | ```bash 243 | cat ~/captn/logs/captn.log 244 | ``` 245 | 246 | 2. **Check verification settings**: 247 | ```ini 248 | [updateVerification] 249 | maxWait = 480s 250 | stableTime = 15s 251 | ``` 252 | 253 | 3. **Try again in debug**: 254 | ```bash 255 | docker exec captn captn --log-level debug --clear-logs --filter name=mycontainer 256 | ``` 257 | 258 | 4. **Inspect container logs**: 259 | ```bash 260 | docker logs mycontainer 261 | ``` 262 | 263 | ### Script Execution Failures 264 | 265 | 1. **Verify script exists and is executable**: 266 | ```bash 267 | ls -la ~/captn/conf/scripts/ 268 | chmod +x ~/captn/conf/scripts/*.sh 269 | ``` 270 | 271 | 2. **Test script manually**: 272 | ```bash 273 | # Set environment variables 274 | export CAPTN_CONTAINER_NAME=mycontainer 275 | export CAPTN_SCRIPT_TYPE=pre 276 | export CAPTN_DRY_RUN=false 277 | 278 | # Run script 279 | ~/captn/conf/scripts/mycontainer_pre.sh 280 | ``` 281 | 282 | 3. **Check script timeout**: 283 | ```ini 284 | [preScripts] 285 | timeout = 10m # Increase if needed 286 | ``` 287 | 288 | ## Getting Help 289 | 290 | - **Documentation**: Read the detailed [Configuration](03-Configuration.md), [CLI Reference](02-CLI-Reference.md), and [Scripts](04-Scripts.md) documentation 291 | - **Logs**: Check captn's logs with debug level enabled for detailed information 292 | - **Dry-Run**: Use dry-run mode to understand what captn would do 293 | - **GitHub Issues**: Report bugs or request features at [GitHub Issues](https://github.com/captn-io/captn/issues) 294 | - **GitHub Discussions**: Ask questions at [GitHub Discussions](https://github.com/captn-io/captn/discussions) 295 | 296 | --- 297 | 298 | **Next Steps**: 299 | - [CLI Reference →](02-CLI-Reference.md) 300 | - [Configuration Guide →](03-Configuration.md) 301 | - [Scripts Guide →](04-Scripts.md) 302 | 303 | --- 304 | 305 |
306 |

Brewed with ❤️ and loads of 🍺

307 |
308 | -------------------------------------------------------------------------------- /app/utils/registries/ghcr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import logging 6 | import re 7 | from typing import Any, Dict, List 8 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 9 | 10 | import requests 11 | 12 | from ..config import config 13 | from . import generic 14 | from .auth import get_auth_headers, is_authenticated 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def update_url_with_page_size(url: str, page_size: int = config.ghcr.pageSize) -> str: 20 | """ 21 | Ensure the URL includes or overrides the 'n' query parameter (page size). 22 | 23 | This function modifies a URL to include or update the 'n' parameter 24 | for GHCR API requests, ensuring consistent pagination behavior. 25 | 26 | Parameters: 27 | url (str): The original URL 28 | page_size (int): Desired page size for the request 29 | 30 | Returns: 31 | str: Modified URL with 'n' parameter 32 | """ 33 | parts = urlparse(url) 34 | query = parse_qs(parts.query) 35 | query["n"] = [str(page_size)] 36 | new_query = urlencode(query, doseq=True) 37 | return urlunparse(parts._replace(query=new_query)) 38 | 39 | 40 | def fetch_ghcr_tag_details(imageUrl: str, tags: List[str], token: str) -> List[Dict]: 41 | """ 42 | Retrieve detailed metadata for each tag in a GitHub Container Registry (GHCR) image. 43 | 44 | This function performs the following operations for each tag: 45 | - Sends a GET request to fetch the tag's manifest from GHCR. 46 | - Determines the media type (multi-arch index or single-arch manifest). 47 | - Extracts metadata such as digest, created timestamp, and architecture/os/platform info. 48 | - Falls back to config blob, annotations, and manifest history for 'created' timestamp. 49 | - Handles both multi-arch and single-arch images. 50 | - Handles request failures gracefully by returning minimal tag metadata. 51 | 52 | Parameters: 53 | imageUrl (str): GHCR base URL 54 | tags (List[str]): Tag names to inspect 55 | token (str): Bearer token 56 | 57 | Returns: 58 | List[Dict]: Detailed tag metadata 59 | """ 60 | headers = { 61 | "Authorization": f"Bearer {token}", 62 | "Accept": ( 63 | "application/vnd.oci.image.index.v1+json," 64 | "application/vnd.docker.distribution.manifest.list.v2+json," 65 | "application/vnd.docker.distribution.manifest.v2+json" 66 | ), 67 | } 68 | 69 | detailed_tags = [] 70 | 71 | for tag in tags: 72 | url = f"{imageUrl}/manifests/{tag}" 73 | tag_info = {} 74 | created = None 75 | 76 | try: 77 | response = requests.get(url, headers=headers, timeout=30) 78 | response.raise_for_status() 79 | manifest = response.json() 80 | media_type = manifest.get("mediaType") 81 | 82 | logger.debug( f"Raw manifest data for tag {tag}: {json.dumps(manifest, indent=4)}", extra={"indent": 4}, ) 83 | header_digest = response.headers.get("Docker-Content-Digest") 84 | 85 | # Fallbacks for created 86 | created = manifest.get("annotations", {}).get( "org.opencontainers.image.created" ) 87 | if not created: 88 | created = manifest.get("created") 89 | 90 | if ( 91 | not created 92 | and media_type == "application/vnd.docker.distribution.manifest.v2+json" 93 | ): 94 | config_digest = manifest.get("config", {}).get("digest") 95 | if config_digest: 96 | config_url = f"{imageUrl}/blobs/{config_digest}" 97 | config_response = requests.get( 98 | config_url, headers=headers, timeout=30 99 | ) 100 | if config_response.ok: 101 | config_data = config_response.json() 102 | created = config_data.get("created") 103 | 104 | if not created: 105 | for entry in manifest.get("history", []): 106 | v1 = json.loads(entry.get("v1Compatibility", "{}")) 107 | created = v1.get("created") 108 | if created: 109 | break 110 | 111 | # digest = manifest.get("config", {}).get("digest") or manifest.get("digest") 112 | 113 | tag_info = { 114 | "creator": None, 115 | "id": None, 116 | "images": [], 117 | "last_updated": created, 118 | "last_updater": None, 119 | "last_updater_username": None, 120 | "name": tag, 121 | "repository": None, 122 | "full_size": None, 123 | "v2": None, 124 | "tag_status": None, 125 | "tag_last_pulled": None, 126 | "tag_last_pushed": created, 127 | "media_type": media_type, 128 | "content_type": None, 129 | "digest": header_digest, 130 | } 131 | 132 | if media_type in [ 133 | "application/vnd.oci.image.index.v1+json", 134 | "application/vnd.docker.distribution.manifest.list.v2+json", 135 | ]: 136 | for image in manifest.get("manifests", []): 137 | platform = image.get("platform", {}) 138 | tag_info["images"].append( 139 | { 140 | "architecture": platform.get("architecture"), 141 | "features": None, 142 | "variant": platform.get("variant"), 143 | "digest": image.get("digest"), 144 | "os": platform.get("os"), 145 | "os_features": None, 146 | "os_version": None, 147 | "size": image.get("size"), 148 | "status": None, 149 | "last_pulled": None, 150 | "last_pushed": created, 151 | } 152 | ) 153 | 154 | elif media_type == "application/vnd.docker.distribution.manifest.v2+json": 155 | config_digest = manifest.get("config", {}).get("digest") 156 | tag_info["images"].append( 157 | { 158 | "architecture": None, 159 | "features": None, 160 | "variant": None, 161 | "digest": config_digest, 162 | "os": None, 163 | "os_features": None, 164 | "os_version": None, 165 | "size": None, 166 | "status": None, 167 | "last_pulled": None, 168 | "last_pushed": created, 169 | } 170 | ) 171 | 172 | except requests.RequestException as e: 173 | logger.warning( f"Failed to fetch manifest for tag {tag}: {e}", extra={"indent": 4} ) 174 | tag_info = { 175 | "creator": None, 176 | "id": None, 177 | "images": [], 178 | "last_updated": None, 179 | "last_updater": None, 180 | "last_updater_username": None, 181 | "name": tag, 182 | "repository": None, 183 | "full_size": None, 184 | "v2": None, 185 | "tag_status": None, 186 | "tag_last_pulled": None, 187 | "tag_last_pushed": None, 188 | "media_type": None, 189 | "content_type": "image", 190 | "digest": None, 191 | } 192 | 193 | detailed_tags.append(tag_info) 194 | 195 | return detailed_tags 196 | 197 | 198 | def get_image_tags(imageName: str, imageUrl: str, imageTagsUrl: str, imageTag: str, max_pages=config.ghcr.pageCrawlLimit) -> List[Any]: 199 | """ 200 | Retrieve and process available image tags for a GHCR (GitHub Container Registry) image. 201 | 202 | This function performs the following steps: 203 | 1. Authenticates with GHCR using configured credentials or anonymous access. 204 | 2. Fetches paginated tag lists using the GHCR tag listing API. 205 | 3. Filters the returned tags to retain only those relevant to the current imageTag. 206 | 4. Sorts and truncates the tag list to keep only the current and newer versions. 207 | 5. Fetches additional metadata for each remaining tag (e.g., digests). 208 | 209 | Parameters: 210 | imageName (str): The name of the image (e.g., "myorg/myapp"). 211 | imageUrl (str): The full registry URL for this image (e.g., "https://ghcr.io/v2/myorg/myapp"). 212 | imageTagsUrl (str): URL endpoint used to list tags (typically ends with /tags/list). 213 | imageTag (str): The current tag of the running image (used for filtering and comparison). 214 | max_pages (int): Maximum number of pagination requests to perform (default from config). 215 | 216 | Returns: 217 | List[Dict]: A list of dictionaries representing relevant tag metadata, 218 | filtered, sorted, and truncated for update evaluation. 219 | """ 220 | tags: List[str] = [] 221 | page_size = 100 222 | next_url = update_url_with_page_size(imageTagsUrl, page_size) 223 | 224 | # Get authentication headers for GHCR 225 | auth_headers = get_auth_headers(config.ghcr.apiUrl, imageName) 226 | 227 | # Auth - use configured token if available, otherwise fall back to anonymous 228 | if auth_headers: 229 | logger.debug(f"Using configured authentication for GHCR") 230 | headers = auth_headers 231 | else: 232 | logger.debug(f"No authentication configured for GHCR, using anonymous access") 233 | try: 234 | token_response = requests.get("https://ghcr.io/token", params={"scope": f"repository:{imageName}:pull"}, timeout=10) 235 | token_response.raise_for_status() 236 | token = token_response.json().get("token") 237 | if not token: 238 | raise ValueError("No token received") 239 | headers = {"Authorization": f"Bearer {token}"} 240 | except (requests.RequestException, ValueError) as e: 241 | logger.error(f"Failed to retrieve auth token: {e}", extra={"indent": 4}) 242 | return tags 243 | 244 | for _ in range(max_pages): 245 | if not next_url: 246 | break 247 | 248 | try: 249 | response = requests.get(next_url, headers=headers, timeout=10) 250 | response.raise_for_status() 251 | data = response.json() 252 | tags.extend(data.get("tags", [])) 253 | 254 | # Ensure next page retains page_size 255 | link_header = response.headers.get("Link", "") 256 | match = re.search(r'<([^>]+)>;\s*rel="next"', link_header) 257 | if match: 258 | next_url = update_url_with_page_size(f"https://ghcr.io{match.group(1)}", page_size) 259 | else: 260 | next_url = None 261 | except requests.RequestException as e: 262 | logger.error(f"Error fetching tags from {next_url}: {e}", extra={"indent": 4}) 263 | break 264 | 265 | logger.debug(f"tags:\n{json.dumps(tags, indent=4)}", extra={"indent": 4}) 266 | filtered_tags = generic.filter_image_tags(tags, imageTag) 267 | logger.debug(f"filtered_tags:\n{json.dumps(filtered_tags, indent=4)}", extra={"indent": 4}) 268 | sorted_tags = generic.sort_tags(filtered_tags) 269 | logger.debug(f"sorted_filtered_tags:\n{json.dumps(sorted_tags, indent=4)}", extra={"indent": 4}) 270 | truncated_tags = generic.truncate_tags(sorted_tags, imageTag) 271 | logger.debug(f"truncated_tags:\n{json.dumps(truncated_tags, indent=4)}", extra={"indent": 4}) 272 | detailed_tags = fetch_ghcr_tag_details(imageUrl, truncated_tags, token) 273 | logger.debug(f"detailed_tags:\n{json.dumps(detailed_tags, indent=4)}", extra={"indent": 4}) 274 | return detailed_tags 275 | -------------------------------------------------------------------------------- /app/utils/scripts.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import os 6 | import subprocess 7 | import time 8 | from typing import Dict, Optional, Tuple 9 | 10 | from .config import config 11 | from .common import parse_duration 12 | 13 | 14 | def execute_pre_script(container_name: str, dry_run: bool = False) -> Tuple[bool, str]: 15 | """ 16 | Execute the pre-update script for a container. 17 | 18 | This function executes the pre-update script associated with a specific container. 19 | Pre-scripts are executed before container updates and can perform tasks like 20 | backups, health checks, or other preparatory actions. 21 | 22 | Parameters: 23 | container_name (str): Name of the container to execute the script for 24 | dry_run (bool): If True, only log what would be done without actually executing 25 | 26 | Returns: 27 | Tuple[bool, str]: (success, output) - success indicates if script executed successfully, 28 | output contains the script output or error message 29 | """ 30 | return _execute_script("pre", container_name, dry_run) 31 | 32 | 33 | def execute_post_script(container_name: str, dry_run: bool = False) -> Tuple[bool, str]: 34 | """ 35 | Execute the post-update script for a container. 36 | 37 | This function executes the post-update script associated with a specific container. 38 | Post-scripts are executed after successful container updates and can perform 39 | tasks like health checks, notifications, or cleanup actions. 40 | 41 | Parameters: 42 | container_name (str): Name of the container to execute the script for 43 | dry_run (bool): If True, only log what would be done without actually executing 44 | 45 | Returns: 46 | Tuple[bool, str]: (success, output) - success indicates if script executed successfully, 47 | output contains the script output or error message 48 | """ 49 | return _execute_script("post", container_name, dry_run) 50 | 51 | 52 | def _execute_script(script_type: str, container_name: str, dry_run: bool = False) -> Tuple[bool, str]: 53 | """ 54 | Execute a script with timeout and logging. 55 | 56 | This internal function handles the actual execution of pre/post scripts with 57 | comprehensive error handling, timeout management, and logging integration. 58 | 59 | Parameters: 60 | script_type (str): Type of script ("pre" or "post") 61 | container_name (str): Name of the container 62 | dry_run (bool): If True, only log what would be done without actually executing 63 | 64 | Returns: 65 | Tuple[bool, str]: (success, output) - success indicates if script executed successfully, 66 | output contains the script output or error message 67 | """ 68 | script_config = _get_script_config(script_type) 69 | 70 | if not script_config.get("enabled", False): 71 | logging.debug(f"{script_type.capitalize()}-script execution is disabled", extra={"indent": 4}) 72 | return True, "Script execution disabled" 73 | 74 | script_path = _get_script_path(script_type, container_name) 75 | if not script_path or not os.path.exists(script_path): 76 | logging.debug(f"No {script_type}-script found at '{script_path}'", extra={"indent": 4}) 77 | return True, f"No {script_type}-script found" 78 | 79 | if dry_run: 80 | logging.info(f"Would execute {script_type}-script: '{script_path}'", extra={"indent": 4}) 81 | return True, f"Would execute {script_type}-script (dry-run)" 82 | 83 | timeout_str = script_config.get("timeout", "5m") 84 | timeout = int(parse_duration(timeout_str, "s")) 85 | logging.info(f"Executing {script_type}-script: '{script_path}' (timeout: {timeout}s)", extra={"indent": 4}) 86 | 87 | try: 88 | env_vars = _prepare_environment(container_name, script_type) 89 | result = _run_script_with_timeout(script_path, env_vars, timeout) 90 | 91 | if result["success"]: 92 | logging.info(f"{script_type.capitalize()}-script completed successfully", extra={"indent": 6}) 93 | else: 94 | logging.error(f"{script_type.capitalize()}-script failed: {result['error']}", extra={"indent": 6}) 95 | 96 | return result["success"], result["output"] or result["error"] 97 | 98 | except Exception as e: 99 | error_msg = f"Failed to execute {script_type}-script: {e}" 100 | logging.error(error_msg, extra={"indent": 6}) 101 | return False, error_msg 102 | 103 | 104 | def _get_script_config(script_type: str) -> Dict: 105 | """ 106 | Get configuration for script execution. 107 | 108 | This function retrieves the configuration settings for a specific script type 109 | (pre or post) from the application configuration. 110 | 111 | Parameters: 112 | script_type (str): Type of script ("pre" or "post") 113 | 114 | Returns: 115 | Dict: Configuration dictionary containing script settings 116 | """ 117 | config_key = f"{script_type}Scripts" 118 | if hasattr(config, config_key): 119 | config_section = getattr(config, config_key) 120 | result = {} 121 | if hasattr(config_section, '_values'): 122 | for key, value in config_section._values.items(): 123 | result[key] = config_section.auto_cast(value) 124 | return result 125 | return {} 126 | 127 | 128 | def _get_script_path(script_type: str, container_name: str) -> Optional[str]: 129 | """ 130 | Get the path to the script for a container. 131 | 132 | This function determines the path to the script file for a specific container 133 | and script type. It first looks for container-specific scripts, then falls back 134 | to generic scripts. 135 | 136 | Parameters: 137 | script_type (str): Type of script ("pre" or "post") 138 | container_name (str): Name of the container 139 | 140 | Returns: 141 | Optional[str]: Path to the script file, or None if no script found 142 | """ 143 | script_config = _get_script_config(script_type) 144 | scripts_dir = script_config.get("scriptsDirectory", "/app/conf/scripts") 145 | 146 | # Try container-specific script first 147 | container_script = os.path.join(scripts_dir, f"{container_name}_{script_type}.sh") 148 | if os.path.exists(container_script): 149 | return container_script 150 | 151 | # Try generic script 152 | generic_script = os.path.join(scripts_dir, f"{script_type}.sh") 153 | if os.path.exists(generic_script): 154 | return generic_script 155 | 156 | return None 157 | 158 | 159 | def _prepare_environment(container_name: str, script_type: str) -> Dict[str, str]: 160 | """ 161 | Prepare environment variables for script execution. 162 | 163 | This function creates a comprehensive environment for script execution by 164 | combining system environment variables with captn-specific variables. 165 | 166 | Parameters: 167 | container_name (str): Name of the container 168 | script_type (str): Type of script ("pre" or "post") 169 | 170 | Returns: 171 | Dict[str, str]: Environment variables dictionary for script execution 172 | """ 173 | env = os.environ.copy() 174 | env.update({ 175 | "CAPTN_CONTAINER_NAME": container_name, 176 | "CAPTN_SCRIPT_TYPE": script_type, 177 | "CAPTN_DRY_RUN": str(config.general.dryRun).lower(), 178 | "CAPTN_LOG_LEVEL": config.logging.level, 179 | "CAPTN_CONFIG_DIR": "/app/conf", 180 | "CAPTN_SCRIPTS_DIR": _get_script_config(script_type).get("scriptsDirectory", "/app/conf/scripts"), 181 | }) 182 | return env 183 | 184 | 185 | def _run_script_with_timeout(script_path: str, env_vars: Dict[str, str], timeout: int) -> Dict: 186 | """ 187 | Run a script with timeout and capture output. 188 | 189 | This function executes a script with a specified timeout, capturing both 190 | stdout and stderr output. It handles timeout termination and provides 191 | comprehensive error reporting. 192 | 193 | Parameters: 194 | script_path (str): Path to the script file to execute 195 | env_vars (Dict[str, str]): Environment variables for script execution 196 | timeout (int): Timeout in seconds 197 | 198 | Returns: 199 | Dict: Dictionary containing success status, output, and error information 200 | """ 201 | process = None 202 | 203 | try: 204 | os.chmod(script_path, 0o755) 205 | logging.info(f"Starting script: '{script_path}'", extra={"indent": 8}) 206 | 207 | process = subprocess.Popen( 208 | [script_path], 209 | stdout=subprocess.PIPE, 210 | stderr=subprocess.STDOUT, 211 | env=env_vars, 212 | text=True, 213 | bufsize=1, 214 | universal_newlines=True 215 | ) 216 | 217 | logging.info(f"Script process started with PID: {process.pid}", extra={"indent": 8}) 218 | logging.info(f"Waiting for script completion (timeout: {timeout}s)...", extra={"indent": 8}) 219 | 220 | stdout_lines = [] 221 | start_time = time.time() 222 | 223 | # Simple approach: read output line by line until process completes 224 | while True: 225 | # Check timeout 226 | if time.time() - start_time > timeout: 227 | logging.error(f"Script execution timed out after {timeout} seconds", extra={"indent": 8}) 228 | process.terminate() 229 | time.sleep(2) 230 | if process.poll() is None: 231 | process.kill() 232 | return { 233 | "success": False, 234 | "output": '\n'.join(stdout_lines), 235 | "error": f"Script execution timed out after {timeout} seconds" 236 | } 237 | 238 | # Try to read a line 239 | line = process.stdout.readline() 240 | 241 | if line: 242 | # Got output 243 | line = line.rstrip() 244 | stdout_lines.append(line) 245 | logging.info(f"| {line}", extra={"indent": 10}) 246 | elif process.poll() is not None: 247 | # No more output and process is done 248 | break 249 | else: 250 | # No output but process still running, wait a bit 251 | time.sleep(0.1) 252 | 253 | stdout = '\n'.join(stdout_lines) 254 | logging.info(f"Script completed with return code: {process.returncode}", extra={"indent": 8}) 255 | 256 | return { 257 | "success": process.returncode == 0, 258 | "output": stdout, 259 | "error": None if process.returncode == 0 else f"Script exited with code {process.returncode}" 260 | } 261 | 262 | except Exception as e: 263 | if process: 264 | try: 265 | process.terminate() 266 | time.sleep(2) 267 | if process.poll() is None: 268 | process.kill() 269 | except Exception: 270 | pass 271 | 272 | return { 273 | "success": False, 274 | "output": "", 275 | "error": str(e) 276 | } 277 | 278 | 279 | def should_continue_on_pre_failure() -> bool: 280 | """ 281 | Check if the update process should continue if pre-script fails. 282 | 283 | This function determines whether the container update process should continue 284 | even if the pre-script fails, based on the configuration settings. 285 | 286 | Returns: 287 | bool: True if the update should continue despite pre-script failure, False otherwise 288 | """ 289 | pre_config = _get_script_config("pre") 290 | return pre_config.get("continueOnFailure", False) 291 | 292 | 293 | def should_rollback_on_post_failure() -> bool: 294 | """ 295 | Check if the update process should rollback if post-script fails. 296 | 297 | This function determines whether the container should be rolled back to its 298 | previous state if the post-script fails, based on the configuration settings. 299 | 300 | Returns: 301 | bool: True if the container should be rolled back on post-script failure, False otherwise 302 | """ 303 | post_config = _get_script_config("post") 304 | return post_config.get("rollbackOnFailure", True) -------------------------------------------------------------------------------- /app/utils/registries/auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import logging 6 | import os 7 | from typing import Dict, Optional 8 | from urllib.parse import urlparse 9 | from ..config import config 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class RegistryAuthManager: 15 | """ 16 | Manages authentication credentials for different container registries and repositories. 17 | 18 | This class provides a comprehensive authentication system for container registries, 19 | supporting both registry-level and repository-specific credentials. It handles 20 | credential loading, validation, and retrieval with proper fallback mechanisms. 21 | 22 | Supports multiple authentication levels: 23 | - Registry-level: Credentials for entire registries (e.g., "https://registry.hub.docker.com/v2") 24 | - Repository-level: Specific credentials for individual repositories (e.g., "captnio/captn") 25 | 26 | Credentials file format: 27 | { 28 | "registries": { 29 | "https://registry.hub.docker.com/v2": { 30 | "username": "default_user", 31 | "password": "default_password" 32 | } 33 | }, 34 | "repositories": { 35 | "captnio/captn": { 36 | "username": "specific_user", 37 | "password": "specific_password" 38 | }, 39 | "myorg/private-repo": { 40 | "token": "specific_token" 41 | } 42 | } 43 | } 44 | """ 45 | 46 | def __init__(self): 47 | self._registry_credentials = {} 48 | self._repository_credentials = {} 49 | self.load_credentials() 50 | 51 | def load_credentials(self): 52 | """ 53 | Load credentials from the configured JSON file. 54 | 55 | This method reads the credentials file specified in the configuration 56 | and loads both registry-level and repository-specific credentials. 57 | It handles various error conditions gracefully and provides appropriate logging. 58 | """ 59 | self._registry_credentials = {} 60 | self._repository_credentials = {} 61 | if not config.registryAuth.enabled: 62 | logger.debug("Registry authentication is disabled", extra={"indent": 2}) 63 | return 64 | 65 | credentials_file = config.registryAuth.credentialsFile 66 | if not os.path.exists(credentials_file): 67 | logger.warning(f"Credentials file not found: {credentials_file}", extra={"indent": 2}) 68 | return 69 | 70 | try: 71 | with open(credentials_file, 'r') as f: 72 | data = json.load(f) 73 | 74 | if not isinstance(data, dict): 75 | logger.error(f"Invalid credentials file format: expected dict, got {type(data)}", extra={"indent": 2}) 76 | return 77 | 78 | self._registry_credentials = data.get("registries", {}) or {} 79 | self._repository_credentials = data.get("repositories", {}) or {} 80 | logger.debug(f"Loaded {len(self._registry_credentials)} registry and {len(self._repository_credentials)} repository credentials", extra={"indent": 2}) 81 | 82 | except (json.JSONDecodeError, IOError) as e: 83 | logger.error(f"Failed to load credentials from {credentials_file}: {e}", extra={"indent": 2}) 84 | self._registry_credentials = {} 85 | self._repository_credentials = {} 86 | 87 | def get_credentials(self, registry_url: str, repository_name: Optional[str] = None) -> Optional[Dict[str, str]]: 88 | """ 89 | Get credentials for a specific registry and optionally a specific repository. 90 | 91 | This method retrieves authentication credentials using a priority-based approach. 92 | It first checks for repository-specific credentials, then falls back to 93 | registry-level credentials if no repository-specific ones are found. 94 | 95 | Priority order: 96 | 1. Repository-specific credentials (if repository_name provided) 97 | 2. Registry-level credentials (fallback) 98 | 3. None (no credentials found - anonymous access) 99 | 100 | Args: 101 | registry_url: The registry URL (e.g., "https://registry.hub.docker.com/v2") 102 | repository_name: Optional repository name (e.g., "captnio/captn") 103 | 104 | Returns: 105 | Dictionary containing credentials or None if not found 106 | """ 107 | # logger.debug(f"func_params:\n{json.dumps({k: v for k, v in locals().items()}, indent=4)}", extra={"indent": 2}) 108 | 109 | if not config.registryAuth.enabled: 110 | return None 111 | 112 | # First, try repository-specific credentials 113 | if repository_name and repository_name in self._repository_credentials: 114 | logger.debug(f"Found repository-specific credentials for: {repository_name}", extra={"indent": 2}) 115 | return self._repository_credentials[repository_name] 116 | 117 | # Fall back to registry-level credentials 118 | normalized_url = self.normalize_registry_url(registry_url) 119 | 120 | # Try exact match first 121 | if normalized_url in self._registry_credentials: 122 | logging.info(f"Using registry-level credentials for: {registry_url}", extra={"indent": 2}) 123 | return self._registry_credentials[normalized_url] 124 | 125 | # Try partial matches for subdomains 126 | for url, creds in self._registry_credentials.items(): 127 | if self.urls_match(normalized_url, url): 128 | logger.debug(f"Using registry-level credentials (partial match) for: {registry_url}", extra={"indent": 2}) 129 | return creds 130 | 131 | logger.debug(f"No credentials found for registry: {registry_url}, repository: {repository_name}", extra={"indent": 2}) 132 | return None 133 | 134 | def normalize_registry_url(self, url: str) -> str: 135 | """ 136 | Normalize registry URL for consistent matching. 137 | 138 | This method standardizes registry URLs by removing trailing slashes 139 | and normalizing the scheme to ensure consistent credential matching. 140 | 141 | Parameters: 142 | url (str): Registry URL to normalize 143 | 144 | Returns: 145 | str: Normalized registry URL 146 | """ 147 | parsed = urlparse(url) 148 | # Remove trailing slashes and normalize scheme 149 | normalized = f"{parsed.scheme}://{parsed.netloc.rstrip('/')}" 150 | if parsed.path and parsed.path != '/': 151 | normalized += parsed.path.rstrip('/') 152 | return normalized 153 | 154 | def urls_match(self, url1: str, url2: str) -> bool: 155 | """ 156 | Check if two registry URLs match (handles subdomains). 157 | 158 | This method compares two registry URLs to determine if they match, 159 | including support for subdomain matching (e.g., "registry.example.com" 160 | would match "example.com"). 161 | 162 | Parameters: 163 | url1 (str): First registry URL 164 | url2 (str): Second registry URL 165 | 166 | Returns: 167 | bool: True if the URLs match, False otherwise 168 | """ 169 | parsed1 = urlparse(url1) 170 | parsed2 = urlparse(url2) 171 | 172 | # Check if one is a subdomain of the other 173 | domain1 = parsed1.netloc.split('.') 174 | domain2 = parsed2.netloc.split('.') 175 | 176 | # Check if one domain ends with the other 177 | if len(domain1) >= len(domain2): 178 | return domain1[-len(domain2):] == domain2 179 | else: 180 | return domain2[-len(domain1):] == domain1 181 | 182 | def get_auth_headers(self, registry_url: str, repository_name: Optional[str] = None) -> Dict[str, str]: 183 | """ 184 | Get authentication headers for a registry and optionally a specific repository. 185 | 186 | This method generates appropriate authentication headers based on the 187 | registry type and available credentials. It supports different authentication 188 | methods for different registry types (e.g., Bearer tokens for GHCR, Basic auth for Docker Hub). 189 | 190 | Parameters: 191 | registry_url (str): The registry URL 192 | repository_name (str, optional): Optional repository name 193 | 194 | Returns: 195 | dict: Dictionary containing authentication headers 196 | """ 197 | credentials = self.get_credentials(registry_url, repository_name) 198 | if not credentials: 199 | return {} 200 | 201 | # Determine registry type and create appropriate headers 202 | if "ghcr.io" in registry_url or "github.com" in registry_url: 203 | # GHCR uses Bearer token 204 | token = credentials.get("token") 205 | if token: 206 | return {"Authorization": f"Bearer {token}"} 207 | else: 208 | # Docker Hub and other registries use Basic auth 209 | username = credentials.get("username") 210 | password = credentials.get("password") or credentials.get("token") 211 | if username and password: 212 | import base64 213 | auth_string = base64.b64encode(f"{username}:{password}".encode()).decode() 214 | return {"Authorization": f"Basic {auth_string}"} 215 | 216 | return {} 217 | 218 | def is_authenticated(self, registry_url: str, repository_name: Optional[str] = None) -> bool: 219 | """ 220 | Check if we have valid credentials for a registry and optionally a specific repository. 221 | 222 | This method validates that appropriate credentials exist for the specified 223 | registry and repository combination, checking for the required credential 224 | types based on the registry type. 225 | 226 | Parameters: 227 | registry_url (str): The registry URL 228 | repository_name (str, optional): Optional repository name 229 | 230 | Returns: 231 | bool: True if valid credentials exist, False otherwise 232 | """ 233 | credentials = self.get_credentials(registry_url, repository_name) 234 | if not credentials: 235 | return False 236 | 237 | # Validate credentials based on registry type 238 | if "ghcr.io" in registry_url or "github.com" in registry_url: 239 | return "token" in credentials 240 | else: 241 | return "username" in credentials and ("password" in credentials or "token" in credentials) 242 | 243 | def list_registries(self) -> list: 244 | """ 245 | List all configured registries. 246 | 247 | Returns: 248 | list: List of registry URLs that have configured credentials 249 | """ 250 | return list(self._registry_credentials.keys()) 251 | 252 | def list_repositories(self) -> list: 253 | """ 254 | List all configured repositories. 255 | 256 | Returns: 257 | list: List of repository names that have configured credentials 258 | """ 259 | return list(self._repository_credentials.keys()) 260 | 261 | 262 | # Global instance 263 | auth_manager = RegistryAuthManager() 264 | 265 | 266 | def get_auth_headers(registry_url: str, repository_name: Optional[str] = None) -> Dict[str, str]: 267 | """ 268 | Convenience function to get auth headers for a registry and optionally a repository. 269 | 270 | Parameters: 271 | registry_url (str): The registry URL 272 | repository_name (str, optional): Optional repository name 273 | 274 | Returns: 275 | dict: Dictionary containing authentication headers 276 | """ 277 | return auth_manager.get_auth_headers(registry_url, repository_name) 278 | 279 | 280 | def is_authenticated(registry_url: str, repository_name: Optional[str] = None) -> bool: 281 | """ 282 | Convenience function to check if a registry and optionally a repository is authenticated. 283 | 284 | Parameters: 285 | registry_url (str): The registry URL 286 | repository_name (str, optional): Optional repository name 287 | 288 | Returns: 289 | bool: True if authenticated, False otherwise 290 | """ 291 | return auth_manager.is_authenticated(registry_url, repository_name) 292 | 293 | 294 | def get_credentials(registry_url: str, repository_name: Optional[str] = None) -> Optional[Dict[str, str]]: 295 | """ 296 | Convenience function to get credentials for a registry and optionally a repository. 297 | 298 | Parameters: 299 | registry_url (str): The registry URL 300 | repository_name (str, optional): Optional repository name 301 | 302 | Returns: 303 | dict or None: Credentials dictionary or None if not found 304 | """ 305 | return auth_manager.get_credentials(registry_url, repository_name) -------------------------------------------------------------------------------- /docs/02-CLI-Reference.md: -------------------------------------------------------------------------------- 1 | # CLI Reference 2 | 3 | This document provides a complete reference for the captn command-line interface (CLI). 4 | 5 | ## Table of Contents 6 | 7 | - [CLI Reference](#cli-reference) 8 | - [Table of Contents](#table-of-contents) 9 | - [Basic Usage](#basic-usage) 10 | - [Command Syntax](#command-syntax) 11 | - [Options](#options) 12 | - [`--version`, `-v`](#--version--v) 13 | - [`--run`, `-r`](#--run--r) 14 | - [`--dry-run`, `-t`](#--dry-run--t) 15 | - [`--filter`](#--filter) 16 | - [`--log-level`, `-l`](#--log-level--l) 17 | - [`--clear-logs`, `-c`](#--clear-logs--c) 18 | - [`--daemon`, `-d`](#--daemon--d) 19 | - [Filters](#filters) 20 | - [Name Filters](#name-filters) 21 | - [Multiple Name Filters](#multiple-name-filters) 22 | - [Examples](#examples) 23 | - [Basic Operations](#basic-operations) 24 | - [Filtering Examples](#filtering-examples) 25 | - [Logging Examples](#logging-examples) 26 | - [Combined Examples](#combined-examples) 27 | - [Testing and Troubleshooting](#testing-and-troubleshooting) 28 | - [Exit Codes](#exit-codes) 29 | - [Environment Variables](#environment-variables) 30 | - [`TZ`](#tz) 31 | - [Docker Socket](#docker-socket) 32 | - [Best Practices](#best-practices) 33 | - [1. Always Test with Dry-Run](#1-always-test-with-dry-run) 34 | - [2. Use Filters Strategically](#2-use-filters-strategically) 35 | - [3. Monitor with Debug Logging](#3-monitor-with-debug-logging) 36 | - [4. Clear Logs for Debugging](#4-clear-logs-for-debugging) 37 | - [5. Schedule Updates Appropriately](#5-schedule-updates-appropriately) 38 | - [Troubleshooting](#troubleshooting) 39 | - [Container Update Failed](#container-update-failed) 40 | - [Quick Reference Card](#quick-reference-card) 41 | 42 | ## Basic Usage 43 | 44 | The basic syntax for running captn: 45 | 46 | ```bash 47 | captn [OPTIONS] 48 | ``` 49 | 50 | When running captn inside a Docker container: 51 | 52 | ```bash 53 | docker exec captn captn [OPTIONS] 54 | ``` 55 | 56 | ## Command Syntax 57 | 58 | ``` 59 | captn [--version] [--run] [--dry-run] [--filter FILTER [FILTER ...]] 60 | [--log-level {debug,info,warning,error,critical}] 61 | [--clear-logs] [--daemon] 62 | ``` 63 | 64 | ## Options 65 | 66 | ### `--version`, `-v` 67 | 68 | Display the current version of captn and exit. 69 | 70 | **Usage:** 71 | ```bash 72 | captn --version 73 | captn -v 74 | ``` 75 | 76 | **Example Output:** 77 | ``` 78 | 0.8.3 79 | ``` 80 | 81 | --- 82 | 83 | ### `--run`, `-r` 84 | 85 | Force actual execution without dry-run mode, overriding the configuration file setting. 86 | 87 | **Usage:** 88 | ```bash 89 | captn --run 90 | captn -r 91 | ``` 92 | 93 | **Details:** 94 | - Overrides the `dryRun` setting in the configuration file 95 | - Useful when `dryRun = true` is set in config but you want to run actual updates 96 | - Takes precedence over both config file and `--dry-run` flag 97 | 98 | **Example:** 99 | ```bash 100 | # Configuration has dryRun = true 101 | # Force actual execution: 102 | captn --run 103 | ``` 104 | 105 | --- 106 | 107 | ### `--dry-run`, `-t` 108 | 109 | Run captn in dry-run mode to preview what it would do without making actual changes. 110 | 111 | **Usage:** 112 | ```bash 113 | captn --dry-run 114 | captn -t 115 | ``` 116 | 117 | **Details:** 118 | - No actual changes are made to containers or images 119 | - Shows what updates would be applied 120 | - Useful for testing configuration changes 121 | - Helps understand update behavior before applying 122 | - All logs are marked with `[DRY_RUN]` 123 | 124 | **Example:** 125 | ```bash 126 | # Preview updates for all containers 127 | captn --dry-run 128 | 129 | # Preview updates for specific container 130 | captn --dry-run --filter name=nginx 131 | ``` 132 | 133 | **Output Example:** 134 | ``` 135 | 2025-01-15 10:30:15 INFO [DRY_RUN] Processing container 'nginx' 136 | 2025-01-15 10:30:16 INFO [DRY_RUN] Would process patch update for 'nginx' (1.21.0 -> 1.21.1) 137 | 2025-01-15 10:30:16 INFO [DRY_RUN] Would pull new image: nginx:1.21.1 138 | 2025-01-15 10:30:16 INFO [DRY_RUN] Would recreate container 'nginx' with updated image 139 | ``` 140 | 141 | --- 142 | 143 | ### `--filter` 144 | 145 | Filter the list of containers to process based on container names. 146 | 147 | **Usage:** 148 | ```bash 149 | captn --filter name= [name= ...] 150 | ``` 151 | 152 | **Supported Filter Types:** 153 | - `name=`: Filter by container name 154 | 155 | **Details:** 156 | - Multiple name filters can be specified 157 | - Multiple name filters are combined with OR logic 158 | - See [Filters](#filters) section for detailed information 159 | 160 | **Examples:** 161 | ```bash 162 | # Single name filter 163 | captn --filter name=nginx 164 | 165 | # Multiple name filters (OR logic) 166 | captn --filter name=nginx name=redis 167 | 168 | # Wildcard patterns 169 | captn --filter name=web-* name=api-* 170 | ``` 171 | 172 | --- 173 | 174 | ### `--log-level`, `-l` 175 | 176 | Set the logging verbosity level. 177 | 178 | **Usage:** 179 | ```bash 180 | captn --log-level LEVEL 181 | captn -l LEVEL 182 | ``` 183 | 184 | **Valid Levels:** 185 | - `debug`: Most verbose, shows all details including function calls 186 | - `info`: Standard information level (recommended, default) 187 | - `warning`: Only warnings and errors 188 | - `error`: Only errors 189 | - `critical`: Only critical errors 190 | 191 | **Details:** 192 | - Overrides the `level` setting in `[logging]` section of config 193 | - Affects both console and file logging 194 | - DEBUG level includes file locations and function names 195 | - DEBUG level increases log file size and rotation settings 196 | 197 | **Examples:** 198 | ```bash 199 | # Standard operation (default) 200 | captn --log-level info 201 | 202 | # Detailed debugging 203 | captn --log-level debug 204 | 205 | # Only errors 206 | captn --log-level error 207 | 208 | # Using short form 209 | captn -l debug 210 | ``` 211 | 212 | **Debug Output Example:** 213 | ``` 214 | 2025-01-15 10:30:15 DEBUG [app.__main__.main] Processing container 'nginx' 215 | 2025-01-15 10:30:15 DEBUG [app.utils.engines.docker.get_containers] Found 5 containers 216 | ``` 217 | 218 | **Info Output Example:** 219 | ``` 220 | 2025-01-15 10:30:15 INFO Processing container 'nginx' 221 | 2025-01-15 10:30:16 INFO Processing patch update for 'nginx' (1.21.0 -> 1.21.1) 222 | ``` 223 | 224 | --- 225 | 226 | ### `--clear-logs`, `-c` 227 | 228 | Delete all log files before starting the update process. 229 | 230 | **Usage:** 231 | ```bash 232 | captn --clear-logs 233 | captn -c 234 | ``` 235 | 236 | **Details:** 237 | - Removes all `captn.log*` files from the logs directory 238 | - Removes all `container_comparison_*.json` files 239 | - Useful for starting fresh log analysis 240 | - Deleted files are reported in the log output 241 | - Can be combined with other options 242 | 243 | **Files Deleted:** 244 | - `captn.log` - Current log file 245 | - `captn.log.1`, `captn.log.2`, etc. - Rotated log files 246 | - `container_comparison_*.json` - Container comparison files 247 | 248 | **Examples:** 249 | ```bash 250 | # Clear logs and run updates 251 | captn --clear-logs 252 | 253 | # Clear logs and do dry-run 254 | captn --clear-logs --dry-run 255 | 256 | # Clear logs with debug level 257 | captn --clear-logs --log-level debug 258 | ``` 259 | 260 | **Output:** 261 | ``` 262 | 2025-01-15 10:30:15 INFO Deleted log file: captn.log 263 | 2025-01-15 10:30:15 INFO Deleted log file: captn.log.1 264 | 2025-01-15 10:30:15 INFO Deleted comparison file: container_comparison_nginx_20250115_103015.json 265 | 2025-01-15 10:30:15 INFO Successfully deleted 3 file(s) 266 | ``` 267 | 268 | --- 269 | 270 | ### `--daemon`, `-d` 271 | 272 | Run captn as a daemon with scheduled execution based on the `cronSchedule` configuration. 273 | 274 | **Usage:** 275 | ```bash 276 | captn --daemon 277 | captn -d 278 | ``` 279 | 280 | **Details:** 281 | - **Note:** This parameter is used by default in the captn Docker image and typically does not need to be specified manually by users 282 | - Runs continuously in the foreground 283 | - Executes updates according to `cronSchedule` in configuration 284 | - Handles graceful shutdown on SIGTERM and SIGINT signals 285 | - The official captn Docker image is pre-configured to run in daemon mode 286 | - Only needs to be specified explicitly when running captn outside of the container or for manual execution 287 | 288 | **Configuration:** 289 | ```ini 290 | [general] 291 | cronSchedule = 30 2 * * * # Daily at 2:30 AM 292 | ``` 293 | 294 | **Cron Schedule Format:** 295 | ``` 296 | ┌───────────── minute (0 - 59) 297 | │ ┌───────────── hour (0 - 23) 298 | │ │ ┌───────────── day of month (1 - 31) 299 | │ │ │ ┌───────────── month (1 - 12) 300 | │ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday) 301 | │ │ │ │ │ 302 | │ │ │ │ │ 303 | * * * * * 304 | ``` 305 | 306 | **Common Cron Schedules:** 307 | ``` 308 | 30 2 * * * # Daily at 2:30 AM 309 | 0 */6 * * * # Every 6 hours 310 | */30 * * * * # Every 30 minutes 311 | 0 2 * * 0 # Weekly on Sunday at 2:00 AM 312 | 0 2 1 * * # Monthly on the 1st at 2:00 AM 313 | ``` 314 | 315 | **Docker Usage:** 316 | ```bash 317 | # Run captn in Docker (daemon mode is default) 318 | docker run -d \ 319 | --name captn \ 320 | --restart unless-stopped \ 321 | -v /var/run/docker.sock:/var/run/docker.sock \ 322 | -v ~/captn/conf:/app/conf \ 323 | -v ~/captn/logs:/app/logs \ 324 | captnio/captn:0.8.3 325 | ``` 326 | 327 | **Note:** The captn Docker image runs in daemon mode by default. The `--daemon` flag is already configured internally and does not need to be specified. 328 | 329 | **Output:** 330 | ``` 331 | 2025-01-15 10:30:15 INFO Starting captn scheduler 332 | 2025-01-15 10:30:15 INFO Cron schedule: 30 2 * * * 333 | 2025-01-15 10:30:15 INFO Next run: 2025-01-16 02:30:00 334 | 2025-01-15 10:30:15 INFO Scheduler started, waiting for scheduled execution... 335 | ``` 336 | 337 | --- 338 | 339 | ## Filters 340 | 341 | Filters allow you to selectively process only certain containers. Multiple filters can be combined to create complex selection criteria. 342 | 343 | ### Name Filters 344 | 345 | Filter containers by their name. 346 | 347 | **Syntax:** 348 | ```bash 349 | --filter name= 350 | ``` 351 | 352 | **Pattern Matching:** 353 | - **Exact match**: No wildcards → exact name match 354 | - `name=nginx` matches only "nginx" 355 | - **Pattern match**: With wildcards → pattern matching 356 | - `*` matches zero or more characters 357 | - `?` matches exactly one character 358 | - `[abc]` matches any character in the brackets 359 | 360 | **Examples:** 361 | 362 | **Exact Name Match:** 363 | ```bash 364 | # Match only container named "nginx" 365 | captn --filter name=nginx 366 | ``` 367 | 368 | **Wildcard Patterns:** 369 | ```bash 370 | # Match all containers starting with "web-" 371 | captn --filter name=web-* 372 | 373 | # Match all containers ending with "-api" 374 | captn --filter name=*-api 375 | 376 | # Match containers containing "cloud" 377 | captn --filter name=*cloud* 378 | 379 | # Match specific pattern (e.g., cloud-01, cloud-02) 380 | captn --filter name=cloud-0? 381 | 382 | # Match using character class 383 | captn --filter name=web-[123] 384 | ``` 385 | 386 | **Multiple Name Filters (OR Logic):** 387 | ```bash 388 | # Match nginx OR redis OR postgres 389 | captn --filter name=nginx name=redis name=postgres 390 | 391 | # Match web-* OR api-* containers 392 | captn --filter name=web-* name=api-* 393 | 394 | # Complex pattern combination 395 | captn --filter name=prod-* name=staging-* name=dev-* 396 | ``` 397 | 398 | ### Multiple Name Filters 399 | 400 | Multiple name filters can be combined using OR logic to select different containers. 401 | 402 | **Logic:** 403 | - Multiple `name` filters are combined with **OR** logic 404 | - A container is selected if it matches **any** of the name patterns 405 | 406 | **Examples:** 407 | 408 | ```bash 409 | # Match nginx OR redis OR postgres 410 | captn --filter name=nginx name=redis name=postgres 411 | 412 | # Match web-* OR api-* containers 413 | captn --filter name=web-* name=api-* 414 | 415 | # Match multiple production patterns 416 | captn --filter name=prod-web-* name=prod-api-* name=prod-db-* 417 | ``` 418 | 419 | **Complex Example:** 420 | ```bash 421 | # Process production web and api containers 422 | captn --filter name=prod-web-* name=prod-api-* 423 | 424 | # This matches: 425 | # ✓ prod-web-1 426 | # ✓ prod-web-2 427 | # ✓ prod-api-1 428 | # ✗ dev-web-1 (wrong name) 429 | # ✗ prod-db-1 (wrong name) 430 | ``` 431 | 432 | --- 433 | 434 | ## Examples 435 | 436 | ### Basic Operations 437 | 438 | **Check Version:** 439 | ```bash 440 | captn --version 441 | ``` 442 | 443 | **Dry-Run (Preview Changes):** 444 | ```bash 445 | captn --dry-run 446 | ``` 447 | 448 | **Run Actual Updates:** 449 | ```bash 450 | captn --run 451 | ``` 452 | 453 | ### Filtering Examples 454 | 455 | **Update Single Container:** 456 | ```bash 457 | captn --filter name=nginx 458 | ``` 459 | 460 | **Update Multiple Specific Containers:** 461 | ```bash 462 | captn --filter name=nginx name=redis name=postgres 463 | ``` 464 | 465 | **Update All Production Containers:** 466 | ```bash 467 | captn --filter name=prod-* 468 | ``` 469 | 470 | **Update Web and API Containers:** 471 | ```bash 472 | captn --filter name=web-* name=api-* 473 | ``` 474 | 475 | ### Logging Examples 476 | 477 | **Standard Logging:** 478 | ```bash 479 | captn --log-level info 480 | ``` 481 | 482 | **Debug Mode:** 483 | ```bash 484 | captn --log-level debug --filter name=nginx 485 | ``` 486 | 487 | **Clear Logs Before Run:** 488 | ```bash 489 | captn --clear-logs --log-level debug 490 | ``` 491 | 492 | ### Combined Examples 493 | 494 | **Dry-Run with Debug Logging:** 495 | ```bash 496 | captn --dry-run --log-level debug 497 | ``` 498 | 499 | **Clear Logs and Run Updates:** 500 | ```bash 501 | captn --clear-logs --run 502 | ``` 503 | 504 | **Update Specific Containers with Debug:** 505 | ```bash 506 | captn --filter name=prod-* --log-level debug 507 | ``` 508 | 509 | **Dry-Run for Production Containers:** 510 | ```bash 511 | captn --dry-run --filter name=prod-* --log-level info 512 | ``` 513 | 514 | **Force Run with Specific Container:** 515 | ```bash 516 | captn --run --filter name=nginx --clear-logs 517 | ``` 518 | 519 | ### Testing and Troubleshooting 520 | 521 | **Test Configuration:** 522 | ```bash 523 | # Preview what would happen 524 | captn --dry-run --log-level debug 525 | ``` 526 | 527 | **Test Specific Container:** 528 | ```bash 529 | # Debug single container updates 530 | captn --dry-run --filter name=nginx --log-level debug 531 | ``` 532 | 533 | **Test with Fresh Logs:** 534 | ```bash 535 | # Clear old logs and test 536 | captn --clear-logs --dry-run --log-level debug 537 | ``` 538 | 539 | **Verbose Update:** 540 | ```bash 541 | # Run with maximum logging 542 | captn --run --log-level debug --clear-logs 543 | ``` 544 | 545 | --- 546 | 547 | ## Exit Codes 548 | 549 | captn uses standard Unix exit codes: 550 | 551 | | Code | Meaning | 552 | | ---- | ----------------------------------------------- | 553 | | 0 | Success - All operations completed successfully | 554 | | 1 | Error - General error occurred during execution | 555 | 556 | **Note:** Exit codes are only relevant for non-daemon mode. In daemon mode, captn runs continuously and doesn't exit unless stopped or an unrecoverable error occurs. 557 | 558 | --- 559 | 560 | ## Environment Variables 561 | 562 | captn respects the following environment variables: 563 | 564 | ### `TZ` 565 | 566 | Set the timezone for log timestamps and scheduled execution. 567 | 568 | **Example:** 569 | ```bash 570 | docker run -d \ 571 | --name captn \ 572 | -e TZ=Europe/Berlin \ 573 | -v /var/run/docker.sock:/var/run/docker.sock \ 574 | -v ~/captn/conf:/app/conf \ 575 | captnio/captn:0.8.3 576 | ``` 577 | 578 | ### Docker Socket 579 | 580 | captn requires access to the Docker socket to manage containers. 581 | 582 | **Default:** `/var/run/docker.sock` 583 | 584 | **Example:** 585 | ```bash 586 | docker run -d \ 587 | --name captn \ 588 | -v /var/run/docker.sock:/var/run/docker.sock \ 589 | captnio/captn:0.8.3 590 | ``` 591 | 592 | --- 593 | 594 | ## Best Practices 595 | 596 | ### 1. Always Test with Dry-Run 597 | 598 | Before applying updates, especially in production: 599 | 600 | ```bash 601 | # Test configuration 602 | captn --dry-run 603 | 604 | # Test specific containers 605 | captn --dry-run --filter name=prod-* 606 | 607 | # Test with debug output 608 | captn --dry-run --log-level debug 609 | ``` 610 | 611 | ### 2. Use Filters Strategically 612 | 613 | Start with specific filters and expand gradually: 614 | 615 | ```bash 616 | # Start with single container 617 | captn --dry-run --filter name=test-nginx 618 | 619 | # Expand to group 620 | captn --dry-run --filter name=test-* 621 | 622 | # Eventually apply to multiple containers 623 | captn --run --filter name=prod-* 624 | ``` 625 | 626 | ### 3. Monitor with Debug Logging 627 | 628 | Use debug logging to understand behavior: 629 | 630 | ```bash 631 | captn --log-level debug --filter name=problematic-container 632 | ``` 633 | 634 | ### 4. Clear Logs for Debugging 635 | 636 | Start with fresh logs when troubleshooting: 637 | 638 | ```bash 639 | captn --clear-logs --log-level debug --dry-run 640 | ``` 641 | 642 | ### 5. Schedule Updates Appropriately 643 | 644 | Set cron schedules during low-traffic periods: 645 | 646 | ```ini 647 | [general] 648 | # Run at 3 AM daily (low traffic) 649 | cronSchedule = 0 3 * * * 650 | 651 | # Or weekly on Sunday at 2 AM 652 | cronSchedule = 0 2 * * 0 653 | ``` 654 | 655 | --- 656 | 657 | ## Troubleshooting 658 | 659 | ### Container Update Failed 660 | 661 | **Check logs:** 662 | ```bash 663 | # View detailed logs 664 | docker logs captn 665 | 666 | # Or check log files 667 | tail ~/captn/logs/captn.log 668 | ``` 669 | 670 | **Debug specific container by trying it again with a cleared log file:** 671 | ```bash 672 | captn --clear-logs --log-level debug --filter name=failed-container 673 | ``` 674 | 675 | --- 676 | 677 | ## Quick Reference Card 678 | 679 | ```bash 680 | # Version 681 | captn --version 682 | 683 | # Dry-run (preview) 684 | captn --dry-run 685 | 686 | # Actual run 687 | captn --run 688 | 689 | # Filter by name 690 | captn --filter name=nginx 691 | 692 | # Multiple name filters 693 | captn --filter name=web-* name=api-* 694 | 695 | # Debug logging 696 | captn --log-level debug 697 | 698 | # Clear logs 699 | captn --clear-logs 700 | 701 | # Common combinations 702 | captn --dry-run --log-level debug --filter name=nginx 703 | captn --run --filter name=prod-* --log-level info 704 | captn --clear-logs --dry-run --log-level debug 705 | captn --filter name=web-* name=api-* --log-level debug 706 | ``` 707 | 708 | --- 709 | 710 | **Next:** [Configuration Guide →](03-Configuration.md) 711 | 712 | **Previous:** [← Introduction](01-Introduction.md) 713 | 714 | -------------------------------------------------------------------------------- /app/utils/notifiers/telegram.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | import json 4 | import os 5 | from .base import BaseNotifier 6 | from typing import List, Dict, Any 7 | from datetime import datetime 8 | 9 | TELEGRAM_MAX_LENGTH = 4096 10 | 11 | class TelegramNotifier(BaseNotifier): 12 | def __init__(self, token: str, chatId: str, enabled: bool = True): 13 | """ 14 | Initialize the Telegram notifier. 15 | 16 | Parameters: 17 | token (str): Telegram bot token 18 | chatId (str): Telegram chat ID to send messages to 19 | enabled (bool): Whether the notifier is enabled 20 | """ 21 | super().__init__(enabled) 22 | self.token = token 23 | self.chatId = chatId 24 | self._profile_initialized = False 25 | self._state_file = "/app/conf/telegram-bot-state.json" 26 | 27 | def send(self, messages: List[str]): 28 | """ 29 | Send messages via Telegram Bot API. 30 | 31 | This method sends notification messages to a Telegram chat using the 32 | Telegram Bot API. It handles message splitting for long messages 33 | and ensures the bot description is set. 34 | 35 | Parameters: 36 | messages (List[str]): List of messages to send 37 | """ 38 | if not self.enabled: 39 | return 40 | 41 | if not messages: 42 | logging.debug("No messages to send via Telegram") 43 | return 44 | 45 | # Try to set bot description once per version 46 | self._ensure_bot_description_set() 47 | 48 | text = "\n".join(messages) 49 | for chunk in self._split_message(text): 50 | self._send_chunk(chunk) 51 | 52 | def _ensure_bot_description_set(self): 53 | """ 54 | Ensure the bot description is set for the current captn version. 55 | 56 | This method checks if the bot description has been set for the current 57 | captn version and sets it if necessary. It uses a state file to track 58 | which versions have already been configured. 59 | """ 60 | try: 61 | from app import __version__ 62 | current_version = __version__ 63 | except ImportError: 64 | logging.debug("Could not import captn version, skipping bot description setup") 65 | return 66 | 67 | # Check if description was already set for this version 68 | if self._is_description_set_for_version(current_version): 69 | return 70 | 71 | # Set the bot description 72 | if self._set_bot_description(): 73 | self._mark_description_set_for_version(current_version) 74 | logging.info(f"Telegram bot description set for captn version {current_version}") 75 | else: 76 | logging.warning("Failed to set Telegram bot description") 77 | 78 | def ensure_bot_description_set(self) -> bool: 79 | """ 80 | Manually ensure the bot description is set for the current captn version. 81 | 82 | Returns: 83 | bool: True if description was set or already set, False if failed 84 | """ 85 | try: 86 | from app import __version__ 87 | current_version = __version__ 88 | except ImportError: 89 | logging.warning("Could not import captn version, cannot set bot description") 90 | return False 91 | 92 | # Check if description was already set for this version 93 | if self._is_description_set_for_version(current_version): 94 | logging.debug(f"Bot description already set for captn version {current_version}") 95 | return True 96 | 97 | # Set the bot description 98 | if self._set_bot_description(): 99 | self._mark_description_set_for_version(current_version) 100 | logging.info(f"Telegram bot description set for captn version {current_version}") 101 | return True 102 | else: 103 | logging.warning("Failed to set Telegram bot description") 104 | return False 105 | 106 | def _is_description_set_for_version(self, version: str) -> bool: 107 | """Check if bot description was already set for the given version.""" 108 | try: 109 | if not os.path.exists(self._state_file): 110 | return False 111 | 112 | with open(self._state_file, 'r') as f: 113 | state = json.load(f) 114 | 115 | return state.get("description_set_versions", {}).get(version, False) 116 | except (json.JSONDecodeError, IOError) as e: 117 | logging.debug(f"Error reading bot state file: {e}") 118 | return False 119 | 120 | def _mark_description_set_for_version(self, version: str): 121 | """Mark that bot description was set for the given version.""" 122 | try: 123 | # Ensure directory exists 124 | os.makedirs(os.path.dirname(self._state_file), exist_ok=True) 125 | 126 | # Load existing state or create new 127 | state = {} 128 | if os.path.exists(self._state_file): 129 | try: 130 | with open(self._state_file, 'r') as f: 131 | state = json.load(f) 132 | except (json.JSONDecodeError, IOError): 133 | state = {} 134 | 135 | # Initialize description_set_versions if not present 136 | if "description_set_versions" not in state: 137 | state["description_set_versions"] = {} 138 | 139 | # Mark this version as set 140 | state["description_set_versions"][version] = True 141 | 142 | # Write back to file 143 | with open(self._state_file, 'w') as f: 144 | json.dump(state, f, indent=2) 145 | 146 | except (json.JSONDecodeError, IOError) as e: 147 | logging.warning(f"Error writing bot state file: {e}") 148 | 149 | def set_bot_description(self, description: str = None) -> bool: 150 | """ 151 | Set the bot description via Telegram Bot API. 152 | 153 | Args: 154 | description (str, optional): Description to set. If None, uses default description with version. 155 | 156 | Returns: 157 | bool: True if successful, False otherwise 158 | """ 159 | if description is None: 160 | try: 161 | from app import __version__ 162 | version = __version__ 163 | description = f"Keeps you updated about performed container updates (v{version})" 164 | except ImportError: 165 | description = "Keeps you updated about performed container updates" 166 | 167 | url = f"https://api.telegram.org/bot{self.token}/setMyDescription" 168 | payload = { 169 | "description": description 170 | } 171 | 172 | try: 173 | resp = requests.post(url, json=payload, timeout=10) 174 | resp.raise_for_status() 175 | 176 | response_data = resp.json() 177 | if response_data.get("ok"): 178 | logging.debug("Telegram bot description set successfully") 179 | return True 180 | else: 181 | logging.error(f"Telegram API error setting description: {response_data.get('description', 'Unknown error')}") 182 | return False 183 | 184 | except requests.exceptions.Timeout: 185 | logging.error("Telegram set description failed: Request timeout") 186 | return False 187 | except requests.exceptions.RequestException as e: 188 | logging.error(f"Telegram set description failed: {e}") 189 | return False 190 | except Exception as e: 191 | logging.error(f"Telegram set description failed with unexpected error: {e}") 192 | return False 193 | 194 | def _set_bot_description(self, description: str = None) -> bool: 195 | """Internal method to set the bot description via Telegram Bot API.""" 196 | return self.set_bot_description(description) 197 | 198 | def get_bot_info(self) -> Dict[str, Any]: 199 | """ 200 | Get current bot information from Telegram Bot API. 201 | 202 | Returns: 203 | Dict containing bot information or empty dict if failed 204 | """ 205 | url = f"https://api.telegram.org/bot{self.token}/getMe" 206 | 207 | try: 208 | resp = requests.get(url, timeout=10) 209 | resp.raise_for_status() 210 | 211 | response_data = resp.json() 212 | if response_data.get("ok"): 213 | return response_data.get("result", {}) 214 | else: 215 | logging.error(f"Telegram API error getting bot info: {response_data.get('description', 'Unknown error')}") 216 | return {} 217 | 218 | except requests.exceptions.Timeout: 219 | logging.error("Telegram get bot info failed: Request timeout") 220 | return {} 221 | except requests.exceptions.RequestException as e: 222 | logging.error(f"Telegram get bot info failed: {e}") 223 | return {} 224 | except Exception as e: 225 | logging.error(f"Telegram get bot info failed with unexpected error: {e}") 226 | return {} 227 | 228 | def _split_message(self, text: str) -> List[str]: 229 | """ 230 | Split text into chunks <= TELEGRAM_MAX_LENGTH. 231 | 232 | This method splits long messages into smaller chunks that fit within 233 | Telegram's message length limits. 234 | 235 | Parameters: 236 | text (str): Text to split 237 | 238 | Returns: 239 | List[str]: List of message chunks 240 | """ 241 | return [text[i:i+TELEGRAM_MAX_LENGTH] for i in range(0, len(text), TELEGRAM_MAX_LENGTH)] 242 | 243 | def _send_chunk(self, chunk: str): 244 | """ 245 | Send a single message chunk via Telegram Bot API. 246 | 247 | This method sends a single message chunk to the configured Telegram chat 248 | using the Telegram Bot API. 249 | 250 | Parameters: 251 | chunk (str): Message chunk to send 252 | """ 253 | url = f"https://api.telegram.org/bot{self.token}/sendMessage" 254 | payload = { 255 | "chat_id": self.chatId, 256 | "text": chunk, 257 | "parse_mode": "HTML" # Use HTML for better formatting 258 | } 259 | 260 | try: 261 | resp = requests.post(url, json=payload, timeout=10) 262 | resp.raise_for_status() 263 | 264 | response_data = resp.json() 265 | if response_data.get("ok"): 266 | logging.debug(f"Telegram message sent successfully to chat {self.chatId}") 267 | else: 268 | logging.error(f"Telegram API error: {response_data.get('description', 'Unknown error')}") 269 | 270 | except requests.exceptions.Timeout: 271 | logging.error("Telegram notification failed: Request timeout") 272 | except requests.exceptions.RequestException as e: 273 | logging.error(f"Telegram notification failed: {e}") 274 | except Exception as e: 275 | logging.error(f"Telegram notification failed with unexpected error: {e}") 276 | 277 | def format_update_report(self, update_data: Dict[str, Any]) -> str: 278 | """ 279 | Format update report for Telegram with essential information. 280 | 281 | Args: 282 | update_data: Dictionary containing update information with keys: 283 | - hostname: str 284 | - timestamp: datetime 285 | - dry_run: bool 286 | - containers_processed: int 287 | - containers_updated: int 288 | - containers_failed: int 289 | - containers_skipped: int 290 | - update_details: List[Dict] with container update details 291 | - errors: List[str] (optional) 292 | - warnings: List[str] (optional) 293 | 294 | Returns: 295 | Formatted HTML message for Telegram 296 | """ 297 | hostname = update_data.get("hostname", "Unknown") 298 | timestamp = update_data.get("timestamp", datetime.now()) 299 | dry_run = update_data.get("dry_run", False) 300 | containers_processed = update_data.get("containers_processed", 0) 301 | containers_updated = update_data.get("containers_updated", 0) 302 | containers_failed = update_data.get("containers_failed", 0) 303 | containers_skipped = update_data.get("containers_skipped", 0) 304 | update_details = update_data.get("update_details", []) 305 | errors = update_data.get("errors", []) 306 | warnings = update_data.get("warnings", []) 307 | start_time = update_data.get("start_time") 308 | end_time = update_data.get("end_time") 309 | 310 | # Format timestamp 311 | if isinstance(timestamp, datetime): 312 | timestamp_str = timestamp.strftime("%Y-%m-%d %H:%M:%S") 313 | else: 314 | timestamp_str = str(timestamp) 315 | 316 | # Build message 317 | lines = [] 318 | 319 | # Header 320 | mode_indicator = "🩺 DRY RUN - " if dry_run else "" 321 | lines.append(f"{mode_indicator}captn Report") 322 | # lines.append(f"📅 {timestamp_str}") 323 | lines.append(f"🖥️ {hostname}") 324 | lines.append("") 325 | 326 | # Summary 327 | lines.append("📊 Summary:") 328 | lines.append(f"• Processed: {containers_processed}") 329 | lines.append(f"• Updated: {containers_updated}") 330 | lines.append(f"• Failed: {containers_failed}") 331 | lines.append(f"• Skipped: {containers_skipped}") 332 | 333 | # Add total duration if available 334 | if start_time and end_time: 335 | total_duration = (end_time - start_time).total_seconds() 336 | if total_duration < 60: 337 | duration_str = f"{total_duration:.1f}s" 338 | elif total_duration < 3600: 339 | duration_str = f"{total_duration / 60:.1f}m" 340 | else: 341 | duration_str = f"{total_duration / 3600:.1f}h" 342 | lines.append(f"• Duration: {duration_str}") 343 | 344 | lines.append("") 345 | 346 | # Separate successful and failed updates 347 | successful_updates = [detail for detail in update_details if detail.get("status") == "succeeded"] 348 | failed_updates = [detail for detail in update_details if detail.get("status") == "failed"] 349 | 350 | # Successful updates 351 | if successful_updates: 352 | lines.append("✅ Successful Updates:") 353 | for detail in successful_updates[:10]: # Limit to first 10 successful updates 354 | container_name = detail.get("container_name", "Unknown") 355 | old_version = detail.get("old_version", "Unknown") 356 | new_version = detail.get("new_version", "Unknown") 357 | update_type = detail.get("update_type", "Unknown") 358 | duration = detail.get("duration") 359 | 360 | # Use emoji based on update type 361 | type_emoji = { 362 | "major": "🚀", 363 | "minor": "✨", 364 | "patch": "🐞", 365 | "build": "🏗️", 366 | "digest": "📦" 367 | }.get(update_type, "⚪") 368 | 369 | # Add duration if available 370 | if duration is not None: 371 | if duration < 60: 372 | duration_str = f"{duration:.1f}s" 373 | elif duration < 3600: 374 | duration_str = f"{duration / 60:.1f}m" 375 | else: 376 | duration_str = f"{duration / 3600:.1f}h" 377 | else: 378 | duration_str = "N/A" 379 | 380 | lines.append(f"") 381 | lines.append(f"{container_name}") 382 | lines.append(f"{old_version} → {new_version}") 383 | lines.append(f" {type_emoji} {update_type}") 384 | lines.append(f" ⏱️ {duration_str}") 385 | 386 | if len(successful_updates) > 10: 387 | lines.append(f"... and {len(successful_updates) - 10} more") 388 | lines.append("") 389 | 390 | # Failed updates 391 | if failed_updates: 392 | lines.append("❌ Failed Updates:") 393 | for detail in failed_updates[:10]: # Limit to first 10 failed updates 394 | container_name = detail.get("container_name", "Unknown") 395 | old_version = detail.get("old_version", "Unknown") 396 | new_version = detail.get("new_version", "Unknown") 397 | update_type = detail.get("update_type", "Unknown") 398 | duration = detail.get("duration") 399 | 400 | # Use emoji based on update type 401 | type_emoji = { 402 | "major": "🚀", 403 | "minor": "✨", 404 | "patch": "🐞", 405 | "build": "🏗️", 406 | "digest": "📦" 407 | }.get(update_type, "⚪") 408 | 409 | # Add duration if available 410 | if duration is not None: 411 | if duration < 60: 412 | duration_str = f"{duration:.1f}s" 413 | elif duration < 3600: 414 | duration_str = f"{duration / 60:.1f}m" 415 | else: 416 | duration_str = f"{duration / 3600:.1f}h" 417 | else: 418 | duration_str = "N/A" 419 | 420 | lines.append(f"") 421 | lines.append(f"{container_name}") 422 | lines.append(f"{old_version} → {new_version}") 423 | lines.append(f" {type_emoji} {update_type}") 424 | lines.append(f" ⏱️ {duration_str}") 425 | 426 | if len(failed_updates) > 10: 427 | lines.append(f"... and {len(failed_updates) - 10} more") 428 | lines.append("") 429 | 430 | # Errors 431 | if errors: 432 | lines.append("❌ Errors:") 433 | for error in errors[:5]: # Limit to first 5 errors 434 | lines.append(f"• {error}") 435 | if len(errors) > 5: 436 | lines.append(f" ... and {len(errors) - 5} more") 437 | lines.append("") 438 | 439 | # Warnings 440 | if warnings: 441 | lines.append("⚠️ Warnings:") 442 | for warning in warnings[:3]: # Limit to first 3 warnings 443 | lines.append(f"• {warning}") 444 | if len(warnings) > 3: 445 | lines.append(f" ... and {len(warnings) - 3} more") 446 | lines.append("") 447 | 448 | # Footer 449 | if dry_run: 450 | lines.append("This was a dry run - no actual changes were made.") 451 | 452 | return "\n".join(lines) 453 | -------------------------------------------------------------------------------- /app/utils/notifiers/smtp.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | import logging 3 | import os 4 | from email.mime.multipart import MIMEMultipart 5 | from email.mime.text import MIMEText 6 | from email.mime.image import MIMEImage 7 | from .base import BaseNotifier 8 | from typing import List, Dict, Any 9 | from datetime import datetime 10 | from app import __version__ 11 | 12 | 13 | class SMTPNotifier(BaseNotifier): 14 | """ 15 | SMTP-based email notifier for captn update reports. 16 | 17 | This notifier sends detailed HTML email reports about container updates 18 | with professional formatting, embedded logo, and comprehensive statistics. 19 | """ 20 | 21 | def __init__(self, smtp_server: str, smtp_port: int, username: str, password: str, 22 | from_addr: str, to_addr: str, enabled: bool = True, timeout: int = 30): 23 | """ 24 | Initialize the SMTP notifier. 25 | 26 | Parameters: 27 | smtp_server (str): SMTP server address 28 | smtp_port (int): SMTP server port 29 | username (str): SMTP username 30 | password (str): SMTP password 31 | from_addr (str): Sender email address 32 | to_addr (str): Recipient email address 33 | enabled (bool): Whether the notifier is enabled 34 | timeout (int): Connection timeout in seconds 35 | """ 36 | super().__init__(enabled) 37 | self.smtp_server = smtp_server 38 | self.smtp_port = smtp_port 39 | self.username = username 40 | self.password = password 41 | self.from_addr = from_addr 42 | self.to_addr = to_addr 43 | self.timeout = timeout 44 | self.logo_path = "/app/assets/icons/favicon.png" 45 | 46 | def send(self, messages: List[str]): 47 | """ 48 | Send email notification via SMTP. 49 | 50 | Parameters: 51 | messages (List[str]): List of messages to send (typically one HTML message) 52 | """ 53 | if not self.enabled: 54 | return 55 | 56 | if not messages: 57 | logging.debug("No messages to send via SMTP") 58 | return 59 | 60 | try: 61 | # Create message 62 | msg = MIMEMultipart('related') 63 | msg['From'] = self.from_addr 64 | msg['To'] = self.to_addr 65 | msg['Subject'] = messages[0].split('')[1].split('')[0] if '' in messages[0] else "captn Update Report" 66 | 67 | # Create alternative container for HTML and text 68 | msg_alternative = MIMEMultipart('alternative') 69 | msg.attach(msg_alternative) 70 | 71 | # Add HTML content 72 | html_content = messages[0] 73 | msg_alternative.attach(MIMEText(html_content, 'html', 'utf-8')) 74 | 75 | # Try to add logo as attachment if it exists (optional) 76 | self._attach_logo(msg) 77 | 78 | # Send email 79 | self._send_email(msg) 80 | 81 | except Exception as e: 82 | logging.error(f"Failed to send email notification: {e}") 83 | 84 | def _attach_logo(self, msg): 85 | """Attach logo to email message if available.""" 86 | if not os.path.exists(self.logo_path): 87 | return 88 | 89 | try: 90 | with open(self.logo_path, 'rb') as f: 91 | logo_data = f.read() 92 | 93 | logo_attachment = MIMEImage(logo_data) 94 | logo_attachment.add_header('Content-ID', '<logo>') 95 | logo_attachment.add_header('Content-Disposition', 'inline', filename='captn-logo.png') 96 | msg.attach(logo_attachment) 97 | logging.debug("Logo attached successfully to email") 98 | except Exception as e: 99 | logging.warning(f"Could not attach logo: {e}") 100 | # Continue without logo rather than failing 101 | 102 | def _send_email(self, msg): 103 | """Send email via SMTP with proper error handling.""" 104 | try: 105 | # Handle SSL (port 465) and TLS (port 587) differently 106 | if self.smtp_port == 465: # SSL 107 | logging.debug(f"Connecting to {self.smtp_server}:{self.smtp_port} using SSL (timeout: {self.timeout}s)") 108 | with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, timeout=self.timeout) as server: 109 | logging.debug("SSL connection established") 110 | if self.username and self.password: 111 | logging.debug("Authenticating with SMTP server") 112 | server.login(self.username, self.password) 113 | logging.debug("Authentication successful") 114 | logging.debug("Sending email message") 115 | server.send_message(msg) 116 | logging.debug(f"Email sent successfully to {self.to_addr}") 117 | else: # TLS (port 587) or other ports 118 | logging.debug(f"Connecting to {self.smtp_server}:{self.smtp_port} using TLS (timeout: {self.timeout}s)") 119 | with smtplib.SMTP(self.smtp_server, self.smtp_port, timeout=self.timeout) as server: 120 | logging.debug("SMTP connection established") 121 | if self.smtp_port == 587: # TLS 122 | logging.debug("Starting TLS connection") 123 | server.starttls() 124 | logging.debug("TLS connection established") 125 | if self.username and self.password: 126 | logging.debug("Authenticating with SMTP server") 127 | server.login(self.username, self.password) 128 | logging.debug("Authentication successful") 129 | logging.debug("Sending email message") 130 | server.send_message(msg) 131 | logging.debug(f"Email sent successfully to {self.to_addr}") 132 | except smtplib.SMTPAuthenticationError as e: 133 | logging.error(f"SMTP authentication failed: {e}") 134 | raise 135 | except smtplib.SMTPRecipientsRefused as e: 136 | logging.error(f"SMTP recipients refused: {e}") 137 | raise 138 | except smtplib.SMTPServerDisconnected as e: 139 | logging.error(f"SMTP server disconnected: {e}") 140 | raise 141 | except smtplib.SMTPConnectError as e: 142 | logging.error(f"SMTP connection error: {e}") 143 | raise 144 | except smtplib.SMTPException as e: 145 | logging.error(f"SMTP error sending email: {e}") 146 | raise 147 | except Exception as e: 148 | logging.error(f"Unexpected error sending email: {e}") 149 | raise 150 | 151 | def format_update_report(self, update_data: Dict[str, Any]) -> str: 152 | """ 153 | Format update report as HTML email with detailed layout. 154 | 155 | Args: 156 | update_data: Dictionary containing update information with keys: 157 | - hostname: str 158 | - timestamp: datetime 159 | - dry_run: bool 160 | - containers_processed: int 161 | - containers_updated: int 162 | - containers_failed: int 163 | - containers_skipped: int 164 | - update_details: List[Dict] with container update details 165 | - errors: List[str] (optional) 166 | - warnings: List[str] (optional) 167 | - start_time: datetime (optional) 168 | - end_time: datetime (optional) 169 | 170 | Returns: 171 | Formatted HTML email content 172 | """ 173 | hostname = update_data.get("hostname", "Unknown") 174 | timestamp = update_data.get("timestamp", datetime.now()) 175 | dry_run = update_data.get("dry_run", False) 176 | containers_processed = update_data.get("containers_processed", 0) 177 | containers_updated = update_data.get("containers_updated", 0) 178 | containers_failed = update_data.get("containers_failed", 0) 179 | containers_skipped = update_data.get("containers_skipped", 0) 180 | update_details = update_data.get("update_details", []) 181 | errors = update_data.get("errors", []) 182 | warnings = update_data.get("warnings", []) 183 | start_time = update_data.get("start_time") 184 | end_time = update_data.get("end_time") 185 | 186 | # Format timestamp 187 | if isinstance(timestamp, datetime): 188 | timestamp_str = timestamp.strftime("%Y-%m-%d %H:%M:%S") 189 | else: 190 | timestamp_str = str(timestamp) 191 | 192 | # Calculate duration 193 | duration_str = "" 194 | if start_time and end_time: 195 | total_duration = (end_time - start_time).total_seconds() 196 | if total_duration < 60: 197 | duration_str = f"{total_duration:.1f}s" 198 | elif total_duration < 3600: 199 | duration_str = f"{total_duration / 60:.1f}m" 200 | else: 201 | duration_str = f"{total_duration / 3600:.1f}h" 202 | 203 | # Separate successful and failed updates 204 | successful_updates = [detail for detail in update_details if detail.get("status") == "succeeded"] 205 | failed_updates = [detail for detail in update_details if detail.get("status") == "failed"] 206 | 207 | # Determine overall status 208 | if containers_failed > 0: 209 | status_color = "#dc3545" # Red 210 | status_text = "Issues Detected" 211 | status_icon = "⚠️" 212 | elif containers_updated > 0: 213 | status_color = "#28a745" # Green 214 | status_text = "Updates Successful" 215 | status_icon = "✅" 216 | elif containers_skipped > 0: 217 | status_color = "#28a745" # Green 218 | status_text = "No Updates Needed" 219 | status_icon = "✅" 220 | else: 221 | status_color = "#6c757d" # Gray 222 | status_text = "No Containers Processed" 223 | status_icon = "ℹ️" 224 | 225 | # Build HTML content 226 | html = f""" 227 | <!DOCTYPE html> 228 | <html lang="en"> 229 | <head> 230 | <meta charset="UTF-8"> 231 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 232 | <title>captn v{__version__} Update Report - {hostname} 233 | 417 | 418 | 419 |
420 |
421 | 422 |

captn v{__version__}

423 |

Container Update Report

424 |

{hostname} • {timestamp_str}

425 |
426 | 427 |
428 | {status_icon} {status_text} 429 |
430 | 431 |
432 | {self._generate_dry_run_notice(dry_run)} 433 | 434 |
435 |

📊 Summary

436 |
437 |
438 |
{containers_processed}
439 |
Processed
440 |
441 |
442 |
{containers_updated}
443 |
Updated
444 |
445 |
446 |
{containers_failed}
447 |
Failed
448 |
449 |
450 |
{containers_skipped}
451 |
Skipped
452 |
453 | {f'
{duration_str}
Duration
' if duration_str else ''} 454 |
455 |
456 | 457 | {self._generate_updates_section(successful_updates, failed_updates)} 458 | {self._generate_errors_section(errors)} 459 | {self._generate_warnings_section(warnings)} 460 |
461 | 462 | 468 |
469 | 470 | 471 | """ 472 | 473 | return html 474 | 475 | def _generate_dry_run_notice(self, dry_run: bool) -> str: 476 | """Generate dry run notice if applicable.""" 477 | if dry_run: 478 | return """ 479 |
480 | 🩺 DRY RUN MODE
481 | This was a test run - no actual changes were made to containers. 482 |
483 | """ 484 | return "" 485 | 486 | def _generate_updates_section(self, successful_updates: List[Dict], failed_updates: List[Dict]) -> str: 487 | """Generate the updates section with successful and failed updates.""" 488 | if not successful_updates and not failed_updates: 489 | return """ 490 |
491 |

📦 Container Updates

492 |
493 | No container updates were performed. 494 |
495 |
496 | """ 497 | 498 | html = '

📦 Container Updates

' 499 | 500 | # Successful updates 501 | if successful_updates: 502 | html += '

✅ Successful Updates

' 503 | for detail in successful_updates[:20]: # Limit to first 20 504 | html += self._format_update_item(detail, "success") 505 | if len(successful_updates) > 20: 506 | html += f'

... and {len(successful_updates) - 20} more successful updates

' 507 | 508 | # Failed updates 509 | if failed_updates: 510 | html += '

❌ Failed Updates

' 511 | for detail in failed_updates[:10]: # Limit to first 10 512 | html += self._format_update_item(detail, "failed") 513 | if len(failed_updates) > 10: 514 | html += f'

... and {len(failed_updates) - 10} more failed updates

' 515 | 516 | html += '
' 517 | return html 518 | 519 | def _format_update_item(self, detail: Dict, status: str) -> str: 520 | """Format a single update item.""" 521 | container_name = detail.get("container_name", "Unknown") 522 | old_version = detail.get("old_version", "Unknown") 523 | new_version = detail.get("new_version", "Unknown") 524 | update_type = detail.get("update_type", "unknown") 525 | duration = detail.get("duration") 526 | 527 | # Format duration 528 | if duration is not None: 529 | if duration < 60: 530 | duration_str = f"{duration:.1f}s" 531 | elif duration < 3600: 532 | duration_str = f"{duration / 60:.1f}m" 533 | else: 534 | duration_str = f"{duration / 3600:.1f}h" 535 | else: 536 | duration_str = "N/A" 537 | 538 | # Get update type emoji and class 539 | type_emoji = { 540 | "major": "🚀", 541 | "minor": "✨", 542 | "patch": "🐞", 543 | "build": "🏗️", 544 | "digest": "📦" 545 | }.get(update_type, "⚪") 546 | 547 | return f""" 548 |
549 |
{container_name}
550 |
{old_version} → {new_version}
551 |
552 | {type_emoji} {update_type} 553 | ⏱️ {duration_str} 554 |
555 |
556 | """ 557 | 558 | def _generate_errors_section(self, errors: List[str]) -> str: 559 | """Generate errors section if there are any.""" 560 | if not errors: 561 | return "" 562 | 563 | error_items = "" 564 | for error in errors[:10]: # Limit to first 10 errors 565 | error_items += f'
• {error}
' 566 | 567 | if len(errors) > 10: 568 | error_items += f'
... and {len(errors) - 10} more errors
' 569 | 570 | return f""" 571 |
572 |

❌ Errors

573 |
574 | {error_items} 575 |
576 |
577 | """ 578 | 579 | def _generate_warnings_section(self, warnings: List[str]) -> str: 580 | """Generate warnings section if there are any.""" 581 | if not warnings: 582 | return "" 583 | 584 | warning_items = "" 585 | for warning in warnings[:5]: # Limit to first 5 warnings 586 | warning_items += f'
• {warning}
' 587 | 588 | if len(warnings) > 5: 589 | warning_items += f'
... and {len(warnings) - 5} more warnings
' 590 | 591 | return f""" 592 |
593 |

⚠️ Warnings

594 |
595 | {warning_items} 596 |
597 |
598 | """ 599 | -------------------------------------------------------------------------------- /docs/04-Scripts.md: -------------------------------------------------------------------------------- 1 | # Pre/Post-Scripts Guide 2 | 3 | This document provides comprehensive guidance on using pre-update and post-update scripts with captn. 4 | 5 | ## Table of Contents 6 | 7 | - [Overview](#overview) 8 | - [How Scripts Work](#how-scripts-work) 9 | - [Script Types](#script-types) 10 | - [Script Discovery](#script-discovery) 11 | - [Environment Variables](#environment-variables) 12 | - [Script Guidelines](#script-guidelines) 13 | - [Configuration](#configuration) 14 | - [Generic Scripts](#generic-scripts) 15 | - [Container-Specific Scripts](#container-specific-scripts) 16 | - [Example Scripts](#example-scripts) 17 | - [Best Practices](#best-practices) 18 | - [Troubleshooting](#troubleshooting) 19 | 20 | --- 21 | 22 | ## Overview 23 | 24 | Pre and post-update scripts allow you to execute custom logic before and after container updates. They provide flexibility to handle: 25 | 26 | - **Pre-Scripts:** 27 | - Database backups 28 | - Data snapshots 29 | - Health checks 30 | - Service dependencies (pause/unpause) 31 | - Pre-flight validations 32 | - Custom preparation tasks 33 | 34 | - **Post-Scripts:** 35 | - Health verification 36 | - Database migrations 37 | - Service restart coordination 38 | - Custom post-update tasks 39 | - Cleanup operations 40 | - Notification triggers 41 | 42 | --- 43 | 44 | ## How Scripts Work 45 | 46 | ### Execution Flow 47 | 48 | ``` 49 | 1. Container Selected for Update 50 | ↓ 51 | 2. Pre-Script Execution 52 | ↓ 53 | 3. Image Pull 54 | ↓ 55 | 4. Container Recreation 56 | ↓ 57 | 5. Container Verification 58 | ↓ 59 | 6. Post-Script Execution 60 | ↓ 61 | 7. Update Complete 62 | ``` 63 | 64 | ### Failure Handling 65 | 66 | **Pre-Script Failure:** 67 | - If `continueOnFailure = false` (default): Update is aborted 68 | - If `continueOnFailure = true`: Update proceeds despite failure 69 | 70 | **Post-Script Failure:** 71 | - If `rollbackOnFailure = true` (default): Container is rolled back to previous version 72 | - If `rollbackOnFailure = false`: Update is considered successful 73 | 74 | --- 75 | 76 | ## Script Types 77 | 78 | ### Pre-Scripts 79 | 80 | Executed **before** the container is updated. 81 | 82 | **Purpose:** 83 | - Prepare the system for update 84 | - Backup data 85 | - Pause dependent services 86 | - Verify pre-conditions 87 | 88 | **Naming Convention:** 89 | - **Container-specific:** `_pre.sh` 90 | - **Generic:** `pre.sh` 91 | 92 | **Example:** 93 | ```bash 94 | # /app/conf/scripts/database_pre.sh 95 | # /app/conf/scripts/pre.sh 96 | ``` 97 | 98 | ### Post-Scripts 99 | 100 | Executed **after** the container has been successfully recreated and verified. 101 | 102 | **Purpose:** 103 | - Verify update success 104 | - Run migrations 105 | - Resume dependent services 106 | - Perform post-update tasks 107 | 108 | **Naming Convention:** 109 | - **Container-specific:** `_post.sh` 110 | - **Generic:** `post.sh` 111 | 112 | **Example:** 113 | ```bash 114 | # /app/conf/scripts/database_post.sh 115 | # /app/conf/scripts/post.sh 116 | ``` 117 | 118 | --- 119 | 120 | ## Script Discovery 121 | 122 | captn follows a specific discovery order when looking for scripts: 123 | 124 | ### Priority Order 125 | 126 | 1. **Container-Specific Script:** `_pre.sh` or `_post.sh` 127 | 2. **Generic Script:** `pre.sh` or `post.sh` 128 | 3. **No Script:** If neither exists, script execution is skipped (not an error) 129 | 130 | ### Example 131 | 132 | For a container named `postgres`: 133 | 134 | **Pre-Script Discovery:** 135 | 1. Look for: `/app/conf/scripts/postgres_pre.sh` ✓ Use if found 136 | 2. Look for: `/app/conf/scripts/pre.sh` ✓ Use if found (and #1 not found) 137 | 3. No script found → Skip pre-script execution 138 | 139 | **Post-Script Discovery:** 140 | 1. Look for: `/app/conf/scripts/postgres_post.sh` ✓ Use if found 141 | 2. Look for: `/app/conf/scripts/post.sh` ✓ Use if found (and #1 not found) 142 | 3. No script found → Skip post-script execution 143 | 144 | --- 145 | 146 | ## Environment Variables 147 | 148 | captn provides several environment variables to scripts: 149 | 150 | ### Available Variables 151 | 152 | | Variable | Description | Example | 153 | |----------|-------------|---------| 154 | | `CAPTN_CONTAINER_NAME` | Name of the container being updated | `nginx` | 155 | | `CAPTN_SCRIPT_TYPE` | Type of script (`pre` or `post`) | `pre` | 156 | | `CAPTN_DRY_RUN` | Whether this is a dry-run | `true` or `false` | 157 | | `CAPTN_LOG_LEVEL` | Current log level | `INFO` | 158 | | `CAPTN_CONFIG_DIR` | captn configuration directory | `/app/conf` | 159 | | `CAPTN_SCRIPTS_DIR` | Scripts directory | `/app/conf/scripts` | 160 | 161 | ### Using Environment Variables 162 | 163 | ```bash 164 | #!/bin/bash 165 | 166 | echo "Container: $CAPTN_CONTAINER_NAME" 167 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 168 | echo "Dry Run: $CAPTN_DRY_RUN" 169 | 170 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 171 | echo "Would perform backup..." 172 | exit 0 173 | fi 174 | 175 | # Actual backup logic 176 | perform_backup "$CAPTN_CONTAINER_NAME" 177 | ``` 178 | 179 | --- 180 | 181 | ## Script Guidelines 182 | 183 | ### Basic Requirements 184 | 185 | 1. **Shebang:** Start with `#!/bin/bash` 186 | 2. **Execute Permission:** Script must be executable (`chmod +x`) 187 | 3. **Exit Code:** 188 | - `0` = Success 189 | - Non-zero = Failure 190 | 4. **Error Handling:** Use `set -e` to exit on errors 191 | 5. **Logging:** Use `echo` for output (captured by captn) 192 | 193 | ### Script Template 194 | 195 | ```bash 196 | #!/bin/bash 197 | # Description of what this script does 198 | 199 | set -e # Exit on error 200 | 201 | echo "=== Script Started ===" 202 | echo "Container: $CAPTN_CONTAINER_NAME" 203 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 204 | echo "Dry Run: $CAPTN_DRY_RUN" 205 | echo "Timestamp: $(date)" 206 | 207 | # Handle dry-run mode 208 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 209 | echo "Would perform actions..." 210 | echo "=== Script Completed (Dry-Run) ===" 211 | exit 0 212 | fi 213 | 214 | # Actual script logic here 215 | echo "Performing actual actions..." 216 | 217 | # Your commands here 218 | 219 | echo "=== Script Completed ===" 220 | exit 0 221 | ``` 222 | 223 | ### Error Handling 224 | 225 | ```bash 226 | #!/bin/bash 227 | set -e # Exit immediately on error 228 | 229 | # Trap errors and provide context 230 | trap 'echo "Error on line $LINENO"; exit 1' ERR 231 | 232 | # Verify prerequisites 233 | if ! docker ps > /dev/null 2>&1; then 234 | echo "Error: Cannot connect to Docker" 235 | exit 1 236 | fi 237 | 238 | # Check container exists 239 | if ! docker ps -a --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 240 | echo "Error: Container $CAPTN_CONTAINER_NAME not found" 241 | exit 1 242 | fi 243 | ``` 244 | 245 | --- 246 | 247 | ## Configuration 248 | 249 | ### Script Configuration 250 | 251 | ```ini 252 | [preScripts] 253 | enabled = true 254 | scriptsDirectory = /app/conf/scripts 255 | timeout = 10m 256 | continueOnFailure = false 257 | 258 | [postScripts] 259 | enabled = true 260 | scriptsDirectory = /app/conf/scripts 261 | timeout = 10m 262 | rollbackOnFailure = true 263 | ``` 264 | 265 | ### Configuration Options 266 | 267 | #### Pre-Scripts 268 | 269 | - **enabled:** Enable/disable pre-script execution 270 | - **scriptsDirectory:** Directory containing scripts 271 | - **timeout:** Maximum execution time 272 | - **continueOnFailure:** Continue update if pre-script fails 273 | 274 | #### Post-Scripts 275 | 276 | - **enabled:** Enable/disable post-script execution 277 | - **scriptsDirectory:** Directory containing scripts 278 | - **timeout:** Maximum execution time 279 | - **rollbackOnFailure:** Rollback container if post-script fails 280 | 281 | --- 282 | 283 | ## Generic Scripts 284 | 285 | Generic scripts apply to all containers that don't have container-specific scripts. 286 | 287 | ### Generic Pre-Script 288 | 289 | **File:** `/app/conf/scripts/pre.sh` 290 | 291 | ```bash 292 | #!/bin/bash 293 | # Generic Pre-Update Script 294 | # This script is executed before container updates 295 | 296 | set -e 297 | 298 | echo "=== Pre-Update Script Started ===" 299 | echo "Container: $CAPTN_CONTAINER_NAME" 300 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 301 | echo "Dry Run: $CAPTN_DRY_RUN" 302 | echo "Timestamp: $(date)" 303 | 304 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 305 | echo "Would perform pre-update tasks..." 306 | echo "=== Pre-Update Script Completed (Dry-Run) ===" 307 | exit 0 308 | fi 309 | 310 | # Verify container is running 311 | if docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 312 | echo "Container $CAPTN_CONTAINER_NAME is running" 313 | else 314 | echo "Warning: Container $CAPTN_CONTAINER_NAME is not running" 315 | fi 316 | 317 | # Generic health check 318 | echo "Performing generic health check..." 319 | docker inspect "$CAPTN_CONTAINER_NAME" > /dev/null 2>&1 || { 320 | echo "Error: Cannot inspect container" 321 | exit 1 322 | } 323 | 324 | # Log container status 325 | docker ps --filter "name=^${CAPTN_CONTAINER_NAME}$" --format "Status: {{.Status}}" 326 | 327 | echo "=== Pre-Update Script Completed ===" 328 | exit 0 329 | ``` 330 | 331 | ### Generic Post-Script 332 | 333 | **File:** `/app/conf/scripts/post.sh` 334 | 335 | ```bash 336 | #!/bin/bash 337 | # Generic Post-Update Script 338 | # This script is executed after container updates 339 | 340 | set -e 341 | 342 | echo "=== Post-Update Script Started ===" 343 | echo "Container: $CAPTN_CONTAINER_NAME" 344 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 345 | echo "Dry Run: $CAPTN_DRY_RUN" 346 | echo "Timestamp: $(date)" 347 | 348 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 349 | echo "Would perform post-update tasks..." 350 | echo "=== Post-Update Script Completed (Dry-Run) ===" 351 | exit 0 352 | fi 353 | 354 | # Verify container is running 355 | if docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 356 | echo "Container $CAPTN_CONTAINER_NAME is running successfully" 357 | else 358 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 359 | exit 1 360 | fi 361 | 362 | # Generic health check 363 | echo "Performing generic health check..." 364 | HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CAPTN_CONTAINER_NAME" 2>/dev/null || echo "none") 365 | 366 | if [ "$HEALTH_STATUS" = "healthy" ] || [ "$HEALTH_STATUS" = "none" ]; then 367 | echo "Health check passed: $HEALTH_STATUS" 368 | else 369 | echo "Warning: Health status is $HEALTH_STATUS" 370 | fi 371 | 372 | echo "=== Post-Update Script Completed ===" 373 | exit 0 374 | ``` 375 | 376 | --- 377 | 378 | ## Container-Specific Scripts 379 | 380 | Container-specific scripts override generic scripts and provide tailored logic. 381 | 382 | ### Database Pre-Script Example 383 | 384 | **File:** `/app/conf/scripts/PostgreSQL_pre.sh` 385 | 386 | ```bash 387 | #!/bin/bash 388 | # PostgreSQL-specific Pre-Update Script 389 | # Performs database backup and pauses dependent containers 390 | 391 | set -e 392 | 393 | echo "=== PostgreSQL Pre-Update Script Started ===" 394 | echo "Container: $CAPTN_CONTAINER_NAME" 395 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 396 | echo "Dry Run: $CAPTN_DRY_RUN" 397 | echo "Timestamp: $(date)" 398 | 399 | BACKUP_DIR="/var/backups/postgresql" 400 | 401 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 402 | echo "Would create backup of PostgreSQL data" 403 | echo "Would pause dependent containers" 404 | echo "=== Pre-Update Script Completed (Dry-Run) ===" 405 | exit 0 406 | fi 407 | 408 | # Verify container is running 409 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 410 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 411 | exit 1 412 | fi 413 | 414 | # Create backup directory 415 | mkdir -p "$BACKUP_DIR" 416 | 417 | # Get list of databases 418 | echo "Fetching list of databases..." 419 | DB_NAMES=$(docker exec -i "$CAPTN_CONTAINER_NAME" psql -U postgres -t -c \ 420 | "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';") 421 | 422 | 423 | # Pause dependent containers 424 | echo "Pausing dependent containers..." 425 | for db_name in $DB_NAMES; do 426 | # Check if container with database name exists 427 | if docker ps --format "{{.Names}}" | grep -q "^${db_name}$"; then 428 | echo "Pausing container: $db_name" 429 | docker pause "$db_name" 430 | else 431 | # Check for containers starting with database name 432 | DEPENDENT_CONTAINERS=$(docker ps --format "{{.Names}}" | grep "^${db_name}-" || true) 433 | for container in $DEPENDENT_CONTAINERS; do 434 | echo "Pausing container: $container" 435 | docker pause "$container" 436 | done 437 | fi 438 | done 439 | 440 | # Backup each database 441 | for db_name in $DB_NAMES; do 442 | echo "Backing up database: $db_name" 443 | docker exec -i "$CAPTN_CONTAINER_NAME" pg_dump -U postgres "$db_name" | \ 444 | gzip > "$BACKUP_DIR/${db_name}_$(date +%Y%m%d_%H%M%S).sql.gz" 445 | done 446 | 447 | echo "=== PostgreSQL Pre-Update Script Completed ===" 448 | exit 0 449 | ``` 450 | 451 | ### Database Post-Script Example 452 | 453 | **File:** `/app/conf/scripts/PostgreSQL_post.sh` 454 | 455 | ```bash 456 | #!/bin/bash 457 | # PostgreSQL-specific Post-Update Script 458 | # Unpauses dependent containers and verifies database health 459 | 460 | set -e 461 | 462 | echo "=== PostgreSQL Post-Update Script Started ===" 463 | echo "Container: $CAPTN_CONTAINER_NAME" 464 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 465 | echo "Dry Run: $CAPTN_DRY_RUN" 466 | echo "Timestamp: $(date)" 467 | 468 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 469 | echo "Would verify database health" 470 | echo "Would unpause dependent containers" 471 | echo "=== Post-Update Script Completed (Dry-Run) ===" 472 | exit 0 473 | fi 474 | 475 | # Verify container is running 476 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 477 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 478 | exit 1 479 | fi 480 | 481 | # Verify PostgreSQL is responsive 482 | echo "Verifying PostgreSQL is responsive..." 483 | MAX_RETRIES=30 484 | RETRY_COUNT=0 485 | 486 | while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do 487 | if docker exec "$CAPTN_CONTAINER_NAME" pg_isready -U postgres > /dev/null 2>&1; then 488 | echo "PostgreSQL is ready" 489 | break 490 | fi 491 | 492 | RETRY_COUNT=$((RETRY_COUNT + 1)) 493 | echo "Waiting for PostgreSQL to be ready... ($RETRY_COUNT/$MAX_RETRIES)" 494 | sleep 2 495 | done 496 | 497 | if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then 498 | echo "Error: PostgreSQL did not become ready in time" 499 | exit 1 500 | fi 501 | 502 | # Get list of databases 503 | echo "Fetching list of databases..." 504 | DB_NAMES=$(docker exec -i "$CAPTN_CONTAINER_NAME" psql -U postgres -t -c \ 505 | "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';") 506 | 507 | # Unpause dependent containers 508 | echo "Unpausing dependent containers..." 509 | for db_name in $DB_NAMES; do 510 | # Check if container with database name exists and is paused 511 | if docker ps -a --filter "name=^${db_name}$" --filter "status=paused" --format "{{.Names}}" | grep -q "^${db_name}$"; then 512 | echo "Unpausing container: $db_name" 513 | docker unpause "$db_name" 514 | else 515 | # Check for paused containers starting with database name 516 | PAUSED_CONTAINERS=$(docker ps -a --filter "status=paused" --format "{{.Names}}" | grep "^${db_name}-" || true) 517 | for container in $PAUSED_CONTAINERS; do 518 | echo "Unpausing container: $container" 519 | docker unpause "$container" 520 | done 521 | fi 522 | done 523 | 524 | echo "=== PostgreSQL Post-Update Script Completed ===" 525 | exit 0 526 | ``` 527 | 528 | --- 529 | 530 | ## Example Scripts 531 | 532 | ### Redis Pre-Script 533 | 534 | **File:** `/app/conf/scripts/redis_pre.sh` 535 | 536 | ```bash 537 | #!/bin/bash 538 | # Redis Pre-Update Script 539 | # Triggers Redis save before update 540 | 541 | set -e 542 | 543 | echo "=== Redis Pre-Update Script Started ===" 544 | echo "Container: $CAPTN_CONTAINER_NAME" 545 | echo "Dry Run: $CAPTN_DRY_RUN" 546 | echo "Timestamp: $(date)" 547 | 548 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 549 | echo "Would trigger Redis SAVE command" 550 | echo "=== Redis Pre-Update Script Completed (Dry-Run) ===" 551 | exit 0 552 | fi 553 | 554 | # Verify container is running 555 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 556 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 557 | exit 1 558 | fi 559 | 560 | # Trigger Redis save 561 | echo "Triggering Redis SAVE command..." 562 | docker exec "$CAPTN_CONTAINER_NAME" redis-cli SAVE 563 | 564 | echo "Redis data saved successfully" 565 | echo "=== Redis Pre-Update Script Completed ===" 566 | exit 0 567 | ``` 568 | 569 | ### Redis Post-Script 570 | 571 | **File:** `/app/conf/scripts/redis_post.sh` 572 | 573 | ```bash 574 | #!/bin/bash 575 | # Redis Post-Update Script 576 | # Verifies Redis is responsive 577 | 578 | set -e 579 | 580 | echo "=== Redis Post-Update Script Started ===" 581 | echo "Container: $CAPTN_CONTAINER_NAME" 582 | echo "Dry Run: $CAPTN_DRY_RUN" 583 | echo "Timestamp: $(date)" 584 | 585 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 586 | echo "Would verify Redis health" 587 | echo "=== Redis Post-Update Script Completed (Dry-Run) ===" 588 | exit 0 589 | fi 590 | 591 | # Verify container is running 592 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 593 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 594 | exit 1 595 | fi 596 | 597 | # Test Redis connectivity 598 | echo "Testing Redis connectivity..." 599 | if docker exec "$CAPTN_CONTAINER_NAME" redis-cli PING | grep -q "PONG"; then 600 | echo "Redis is responsive" 601 | else 602 | echo "Error: Redis is not responding" 603 | exit 1 604 | fi 605 | 606 | # Check Redis info 607 | echo "Checking Redis info..." 608 | docker exec "$CAPTN_CONTAINER_NAME" redis-cli INFO server | grep "redis_version" 609 | 610 | echo "=== Redis Post-Update Script Completed ===" 611 | exit 0 612 | ``` 613 | 614 | ### Nextcloud Post-Script 615 | 616 | **File:** `/app/conf/scripts/Nextcloud_post.sh` 617 | 618 | ```bash 619 | #!/bin/bash 620 | # Nextcloud Post-Update Script 621 | # Runs database migrations and maintenance tasks 622 | 623 | set -e 624 | 625 | echo "=== Nextcloud Post-Update Script Started ===" 626 | echo "Container: $CAPTN_CONTAINER_NAME" 627 | echo "Dry Run: $CAPTN_DRY_RUN" 628 | echo "Timestamp: $(date)" 629 | 630 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 631 | echo "Would run Nextcloud maintenance tasks" 632 | echo "=== Nextcloud Post-Update Script Completed (Dry-Run) ===" 633 | exit 0 634 | fi 635 | 636 | # Verify container is running 637 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 638 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 639 | exit 1 640 | fi 641 | 642 | # Enable maintenance mode 643 | echo "Enabling maintenance mode..." 644 | docker exec -u www-data "$CAPTN_CONTAINER_NAME" php occ maintenance:mode --on 645 | 646 | # Run database migrations 647 | echo "Running database migrations..." 648 | docker exec -u www-data "$CAPTN_CONTAINER_NAME" php occ upgrade 649 | 650 | # Add missing database indices 651 | echo "Adding missing database indices..." 652 | docker exec -u www-data "$CAPTN_CONTAINER_NAME" php occ db:add-missing-indices 653 | 654 | # Add missing columns 655 | echo "Adding missing database columns..." 656 | docker exec -u www-data "$CAPTN_CONTAINER_NAME" php occ db:add-missing-columns 657 | 658 | # Disable maintenance mode 659 | echo "Disabling maintenance mode..." 660 | docker exec -u www-data "$CAPTN_CONTAINER_NAME" php occ maintenance:mode --off 661 | 662 | # Verify Nextcloud is accessible 663 | echo "Verifying Nextcloud status..." 664 | docker exec -u www-data "$CAPTN_CONTAINER_NAME" php occ status 665 | 666 | echo "=== Nextcloud Post-Update Script Completed ===" 667 | exit 0 668 | ``` 669 | 670 | ### Web Server Pre-Script 671 | 672 | **File:** `/app/conf/scripts/nginx_pre.sh` 673 | 674 | ```bash 675 | #!/bin/bash 676 | # Nginx Pre-Update Script 677 | # Validates configuration before update 678 | 679 | set -e 680 | 681 | echo "=== Nginx Pre-Update Script Started ===" 682 | echo "Container: $CAPTN_CONTAINER_NAME" 683 | echo "Dry Run: $CAPTN_DRY_RUN" 684 | echo "Timestamp: $(date)" 685 | 686 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 687 | echo "Would validate Nginx configuration" 688 | echo "=== Nginx Pre-Update Script Completed (Dry-Run) ===" 689 | exit 0 690 | fi 691 | 692 | # Verify container is running 693 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 694 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 695 | exit 1 696 | fi 697 | 698 | # Test Nginx configuration 699 | echo "Testing Nginx configuration..." 700 | if docker exec "$CAPTN_CONTAINER_NAME" nginx -t; then 701 | echo "Nginx configuration is valid" 702 | else 703 | echo "Error: Nginx configuration is invalid" 704 | exit 1 705 | fi 706 | 707 | echo "=== Nginx Pre-Update Script Completed ===" 708 | exit 0 709 | ``` 710 | 711 | ### Application with API Health Check 712 | 713 | **File:** `/app/conf/scripts/webapp_post.sh` 714 | 715 | ```bash 716 | #!/bin/bash 717 | # WebApp Post-Update Script 718 | # Verifies API health endpoint 719 | 720 | set -e 721 | 722 | echo "=== WebApp Post-Update Script Started ===" 723 | echo "Container: $CAPTN_CONTAINER_NAME" 724 | echo "Dry Run: $CAPTN_DRY_RUN" 725 | echo "Timestamp: $(date)" 726 | 727 | API_HEALTH_URL="http://localhost:8080/health" 728 | 729 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 730 | echo "Would check API health endpoint: $API_HEALTH_URL" 731 | echo "=== WebApp Post-Update Script Completed (Dry-Run) ===" 732 | exit 0 733 | fi 734 | 735 | # Verify container is running 736 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 737 | echo "Error: Container $CAPTN_CONTAINER_NAME is not running" 738 | exit 1 739 | fi 740 | 741 | # Wait for application to be ready 742 | echo "Waiting for application to be ready..." 743 | MAX_RETRIES=30 744 | RETRY_COUNT=0 745 | 746 | while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do 747 | # Check health endpoint 748 | HTTP_CODE=$(docker exec "$CAPTN_CONTAINER_NAME" wget -q -O - --server-response "$API_HEALTH_URL" 2>&1 | grep "HTTP/" | awk '{print $2}' || echo "000") 749 | 750 | if [ "$HTTP_CODE" = "200" ]; then 751 | echo "Application health check passed (HTTP $HTTP_CODE)" 752 | break 753 | fi 754 | 755 | RETRY_COUNT=$((RETRY_COUNT + 1)) 756 | echo "Waiting for application... ($RETRY_COUNT/$MAX_RETRIES) - HTTP $HTTP_CODE" 757 | sleep 2 758 | done 759 | 760 | if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then 761 | echo "Error: Application did not become healthy in time" 762 | exit 1 763 | fi 764 | 765 | echo "=== WebApp Post-Update Script Completed ===" 766 | exit 0 767 | ``` 768 | 769 | --- 770 | 771 | ## Best Practices 772 | 773 | ### 1. Always Handle Dry-Run Mode 774 | 775 | ```bash 776 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 777 | echo "Would perform actions..." 778 | exit 0 779 | fi 780 | ``` 781 | 782 | ### 2. Verify Prerequisites 783 | 784 | ```bash 785 | # Check container exists and is running 786 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 787 | echo "Error: Container not running" 788 | exit 1 789 | fi 790 | ``` 791 | 792 | ### 3. Use Appropriate Timeouts 793 | 794 | ```ini 795 | [preScripts] 796 | timeout = 10m # Adjust based on script complexity 797 | 798 | [postScripts] 799 | timeout = 15m # Allow time for migrations 800 | ``` 801 | 802 | ### 4. Implement Retry Logic 803 | 804 | ```bash 805 | MAX_RETRIES=30 806 | RETRY_COUNT=0 807 | 808 | while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do 809 | if check_condition; then 810 | break 811 | fi 812 | RETRY_COUNT=$((RETRY_COUNT + 1)) 813 | sleep 2 814 | done 815 | ``` 816 | 817 | ### 5. Provide Detailed Logging 818 | 819 | ```bash 820 | echo "=== Starting backup process ===" 821 | echo "Backup directory: $BACKUP_DIR" 822 | echo "Timestamp: $(date)" 823 | echo "Container: $CAPTN_CONTAINER_NAME" 824 | ``` 825 | 826 | ### 6. Handle Errors Gracefully 827 | 828 | ```bash 829 | set -e # Exit on error 830 | trap 'echo "Error on line $LINENO"; exit 1' ERR 831 | 832 | # Perform operations with error checking 833 | if ! perform_backup; then 834 | echo "Error: Backup failed" 835 | exit 1 836 | fi 837 | ``` 838 | 839 | ### 7. Test Scripts Manually 840 | 841 | ```bash 842 | # Test script manually before using with captn 843 | export CAPTN_CONTAINER_NAME=postgres 844 | export CAPTN_SCRIPT_TYPE=pre 845 | export CAPTN_DRY_RUN=true 846 | export CAPTN_LOG_LEVEL=INFO 847 | export CAPTN_CONFIG_DIR=/app/conf 848 | export CAPTN_SCRIPTS_DIR=/app/conf/scripts 849 | 850 | ./postgres_pre.sh 851 | ``` 852 | 853 | ### 8. Use Container-Specific Scripts 854 | 855 | Create container-specific scripts for services with unique requirements: 856 | 857 | ```bash 858 | # Generic script for all containers 859 | /app/conf/scripts/pre.sh 860 | 861 | # Specific script for PostgreSQL 862 | /app/conf/scripts/PostgreSQL_pre.sh 863 | 864 | # Specific script for Redis 865 | /app/conf/scripts/redis_pre.sh 866 | ``` 867 | 868 | ### 9. Keep Scripts Simple 869 | 870 | - Focus on a single responsibility 871 | - Avoid complex logic 872 | - Use external tools when appropriate 873 | - Document the purpose clearly 874 | 875 | ### 10. Secure Sensitive Data 876 | 877 | ```bash 878 | # Use environment variables for secrets 879 | DB_PASSWORD="${DB_PASSWORD:-}" 880 | 881 | # Never log sensitive information 882 | echo "Connecting to database..." # Don't log password 883 | ``` 884 | 885 | --- 886 | 887 | ## Troubleshooting 888 | 889 | ### Script Not Executing 890 | 891 | **Check if scripts are enabled:** 892 | ```ini 893 | [preScripts] 894 | enabled = true 895 | 896 | [postScripts] 897 | enabled = true 898 | ``` 899 | 900 | **Verify script exists and is named correctly:** 901 | ```bash 902 | ls -la /app/conf/scripts/ 903 | # Should show: container_name_pre.sh or pre.sh 904 | ``` 905 | 906 | **Check execute permissions:** 907 | ```bash 908 | chmod +x /app/conf/scripts/*.sh 909 | ``` 910 | 911 | ### Script Timeout 912 | 913 | **Increase timeout in configuration:** 914 | ```ini 915 | [preScripts] 916 | timeout = 20m # Increase from default 5m 917 | 918 | [postScripts] 919 | timeout = 20m 920 | ``` 921 | 922 | **Optimize script:** 923 | - Remove unnecessary operations 924 | - Parallelize where possible 925 | - Reduce retry delays 926 | 927 | ### Script Fails in Dry-Run 928 | 929 | **Ensure dry-run handling:** 930 | ```bash 931 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 932 | echo "Would perform actions..." 933 | exit 0 # Exit early in dry-run 934 | fi 935 | 936 | # Actual operations below 937 | ``` 938 | 939 | ### Script Works Manually but Fails in captn 940 | 941 | **Check environment:** 942 | ```bash 943 | # captn provides specific environment variables 944 | echo "Container: $CAPTN_CONTAINER_NAME" 945 | echo "Config Dir: $CAPTN_CONFIG_DIR" 946 | ``` 947 | 948 | **Verify paths:** 949 | ```bash 950 | # Use absolute paths 951 | BACKUP_DIR="/var/backups/myapp" 952 | 953 | # Or use provided variables 954 | BACKUP_DIR="$CAPTN_CONFIG_DIR/backups" 955 | ``` 956 | 957 | ### Debugging Scripts 958 | 959 | **Enable debug logging:** 960 | ```bash 961 | docker exec captn captn --log-level debug --filter name=yourcontainer 962 | ``` 963 | 964 | **Add debug output to script:** 965 | ```bash 966 | #!/bin/bash 967 | set -x # Print commands as they execute 968 | 969 | echo "=== Debug Information ===" 970 | echo "Container: $CAPTN_CONTAINER_NAME" 971 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 972 | echo "Dry Run: $CAPTN_DRY_RUN" 973 | env | grep CAPTN_ # Print all captn variables 974 | ``` 975 | 976 | **Test script manually:** 977 | ```bash 978 | # Set environment variables 979 | export CAPTN_CONTAINER_NAME=mycontainer 980 | export CAPTN_SCRIPT_TYPE=pre 981 | export CAPTN_DRY_RUN=false 982 | 983 | # Run script 984 | /app/conf/scripts/mycontainer_pre.sh 985 | ``` 986 | 987 | ### Container Not Found Errors 988 | 989 | **Verify container name:** 990 | ```bash 991 | # List all containers 992 | docker ps -a --format "{{.Names}}" 993 | 994 | # Check if name matches 995 | docker ps --format "{{.Names}}" | grep "^${CAPTN_CONTAINER_NAME}$" 996 | ``` 997 | 998 | **Handle container name variations:** 999 | ```bash 1000 | # Flexible matching 1001 | CONTAINER_PATTERN="${CAPTN_CONTAINER_NAME}" 1002 | if docker ps --format "{{.Names}}" | grep -qi "$CONTAINER_PATTERN"; then 1003 | echo "Container found" 1004 | fi 1005 | ``` 1006 | 1007 | --- 1008 | 1009 | ## Quick Reference 1010 | 1011 | ### Script Template 1012 | 1013 | ```bash 1014 | #!/bin/bash 1015 | set -e 1016 | 1017 | echo "=== Script Started ===" 1018 | echo "Container: $CAPTN_CONTAINER_NAME" 1019 | echo "Script Type: $CAPTN_SCRIPT_TYPE" 1020 | echo "Dry Run: $CAPTN_DRY_RUN" 1021 | 1022 | if [ "$CAPTN_DRY_RUN" = "true" ]; then 1023 | echo "Would perform actions..." 1024 | exit 0 1025 | fi 1026 | 1027 | # Verify container is running 1028 | if ! docker ps --format "{{.Names}}" | grep -q "^${CAPTN_CONTAINER_NAME}$"; then 1029 | echo "Error: Container not running" 1030 | exit 1 1031 | fi 1032 | 1033 | # Your logic here 1034 | echo "Performing actions..." 1035 | 1036 | echo "=== Script Completed ===" 1037 | exit 0 1038 | ``` 1039 | 1040 | ### Common Commands 1041 | 1042 | ```bash 1043 | # Check container exists 1044 | docker ps -a --filter "name=^${CAPTN_CONTAINER_NAME}$" 1045 | 1046 | # Check container is running 1047 | docker ps --filter "name=^${CAPTN_CONTAINER_NAME}$" 1048 | 1049 | # Execute command in container 1050 | docker exec "$CAPTN_CONTAINER_NAME" command 1051 | 1052 | # Pause container 1053 | docker pause "$CAPTN_CONTAINER_NAME" 1054 | 1055 | # Unpause container 1056 | docker unpause "$CAPTN_CONTAINER_NAME" 1057 | 1058 | # Get container logs 1059 | docker logs "$CAPTN_CONTAINER_NAME" 1060 | 1061 | # Inspect container 1062 | docker inspect "$CAPTN_CONTAINER_NAME" 1063 | ``` 1064 | 1065 | --- 1066 | 1067 | **Previous:** [← Configuration Guide](03-Configuration.md) 1068 | 1069 | **Home:** [← Introduction](01-Introduction.md) 1070 | 1071 | --------------------------------------------------------------------------------