Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@
.vscode/
tests/

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.venv/
venv/
ENV/
env/
.coverage
.pytest_cache/
htmlcov/

# Ignore common archive and executable files
*.exe
*.zip
Expand All @@ -20,7 +49,20 @@ tests/
README.md
LICENSE
renovate.json
pyproject.toml

# Docker-specific files
Dockerfile
docker-compose.yml
.dockerignore

# OS
.DS_Store
Thumbs.db

# Logs
*.log
autoheal_logs.txt

# Environment
.env
19 changes: 19 additions & 0 deletions .github/workflows/docker-publish-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Docker Publish Dev

on:
workflow_dispatch:
push:
branches:
- dev

permissions:
packages: write

jobs:
call-reusable-workflow:
uses: JourneyDocker/github-workflows/.github/workflows/docker-publish.yml@main
with:
tag: dev
platforms: linux/amd64
registry: github
secrets: inherit
48 changes: 48 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,51 @@
# IDE
.idea
*.iml

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Virtual environments
.venv/
venv/
ENV/
env/

# Testing
.coverage
.pytest_cache/
htmlcov/

# Docker
.dockerignore

# OS
.DS_Store
Thumbs.db

# Logs
*.log
autoheal_logs.txt

# Environment
.env
22 changes: 14 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
FROM alpine:3.22.2
FROM python:3.14.1-alpine

# Install required packages
RUN apk add --no-cache curl jq tzdata
RUN apk add --no-cache curl tzdata procps

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package curl is installed but not used by the Python application. It was needed in the shell script version for Docker API calls, but the Python version uses the docker library instead. Consider removing it unless it's needed for debugging purposes. Similarly, procps is added but may not be necessary.

Suggested change
RUN apk add --no-cache curl tzdata procps
RUN apk add --no-cache tzdata procps

Copilot uses AI. Check for mistakes.

# Set working directory
WORKDIR /app

# Copy source code
COPY autoheal/ /app/autoheal/

# Install Python dependencies
RUN pip install --no-cache-dir requests docker

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependencies are installed without version constraints. This can lead to non-reproducible builds and potential compatibility issues. Consider pinning versions: pip install --no-cache-dir requests==2.31.0 docker==7.0.0 (adjust versions as needed).

Suggested change
RUN pip install --no-cache-dir requests docker
RUN pip install --no-cache-dir requests==2.31.0 docker==7.0.0

Copilot uses AI. Check for mistakes.

# Environment variables
ENV AUTOHEAL_CONTAINER_LABEL=autoheal \
Expand All @@ -13,16 +22,13 @@ ENV AUTOHEAL_CONTAINER_LABEL=autoheal \
DOCKER_SOCK=/var/run/docker.sock \
CURL_TIMEOUT=30 \
WEBHOOK_URL="" \
WEBHOOK_JSON_KEY="text" \
WEBHOOK_JSON_ENTRY="text" \
APPRISE_URL="" \
POST_RESTART_SCRIPT=""

# Copy entrypoint script
COPY docker-entrypoint /

# Health check to ensure the process is running
HEALTHCHECK --interval=5s CMD pgrep -f autoheal || exit 1
HEALTHCHECK --interval=5s CMD pgrep -f python || exit 1

# Set entrypoint and default command
ENTRYPOINT ["/docker-entrypoint"]
ENTRYPOINT ["python", "-m", "autoheal"]
CMD ["autoheal"]
3 changes: 3 additions & 0 deletions autoheal/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Docker Autoheal - Monitor and automatically restart unhealthy Docker containers."""

__version__ = "2.0.0"
55 changes: 55 additions & 0 deletions autoheal/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Main entry point for Docker Autoheal."""

import signal
import sys
import time
import os
import threading
from .config import Config
from .health_monitor import monitor_containers
from .logging_utils import setup_logging

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line after imports. PEP 8 recommends a blank line between imports and the rest of the code for better readability.

Suggested change
from .logging_utils import setup_logging
from .logging_utils import setup_logging

Copilot uses AI. Check for mistakes.
def main():
"""Main function."""
config = Config()
logger = setup_logging(config.log_level)

def signal_handler(signum, frame, shutdown_event):
"""Handle SIGTERM and SIGINT."""
logger.info(f"Received signal {signum}, shutting down gracefully")
shutdown_event.set()

logger.info(f"Docker Autoheal v{__import__('autoheal').__version__} starting up")

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version attribute lookup uses __import__('autoheal').__version__, which will fail if the autoheal package doesn't have a __version__ attribute defined. Based on the autoheal/__init__.py file, __version__ is defined, but this approach is fragile. Consider importing it directly: from . import __version__ at the top of the file.

Copilot uses AI. Check for mistakes.
logger.info("Configuration:")
for key, value in config.__dict__.items():
if key.lower() == 'webhook_url':
# Mask sensitive URLs
str_value = str(value)
if str_value:
masked_value = str_value[:20] + '...' if len(str_value) > 20 else '***'
else:
masked_value = str_value
Comment on lines +24 to +30

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The masking logic for sensitive URLs only applies to 'webhook_url' (case-sensitive check), but there are other potentially sensitive values like 'apprise_url' that should also be masked. Consider applying the masking logic to all URL-related configuration values.

Copilot uses AI. Check for mistakes.
else:
masked_value = str(value)
logger.info(f" {key.upper()}={masked_value}")

if config.unix_sock and not os.path.exists(config.docker_sock):
logger.error("Unix socket is currently not available")
sys.exit(1)

shutdown_event = threading.Event()
signal.signal(signal.SIGTERM, lambda signum, frame: signal_handler(signum, frame, shutdown_event))
signal.signal(signal.SIGINT, lambda signum, frame: signal_handler(signum, frame, shutdown_event))

if config.autoheal_start_period > 0:
logger.info(f"Waiting {config.autoheal_start_period} second(s) before starting monitoring")
time.sleep(config.autoheal_start_period)

monitor_containers(config, shutdown_event)


if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "autoheal":
main()
else:
# Exec other commands

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index error: os.execvp(sys.argv[1], sys.argv[1:]) will fail when len(sys.argv) == 1 (no arguments provided). This code path is reached when the script is run without arguments or with arguments other than "autoheal". Add a check or error message for this case.

Suggested change
# Exec other commands
# Exec other commands
if len(sys.argv) == 1:
print("Error: No command provided to execute.", file=sys.stderr)
sys.exit(1)

Copilot uses AI. Check for mistakes.
os.execvp(sys.argv[1], sys.argv[1:])

Check notice on line 55 in autoheal/__main__.py

View check run for this annotation

codefactor.io / CodeFactor

autoheal/__main__.py#L55

Starting a process without a shell. (B606)
56 changes: 56 additions & 0 deletions autoheal/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Configuration management for Docker Autoheal."""

import os
from dataclasses import dataclass, field


@dataclass
class Config:
"""Configuration class for environment variables."""

docker_sock: str = field(default_factory=lambda: os.getenv("DOCKER_SOCK", "/var/run/docker.sock"))
curl_timeout: int = field(default_factory=lambda: int(os.getenv("CURL_TIMEOUT", "30")))
webhook_url: str = field(default_factory=lambda: os.getenv("WEBHOOK_URL", ""))
webhook_json_entry: str = field(default_factory=lambda: os.getenv("WEBHOOK_JSON_ENTRY", "text"))

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configuration field name changed from WEBHOOK_JSON_KEY (in the old shell script) to webhook_json_entry, but the Dockerfile still uses WEBHOOK_JSON_ENTRY as the environment variable name. While this works due to the os.getenv call, the inconsistency with the old WEBHOOK_JSON_KEY name may be confusing for users upgrading. Consider documenting this breaking change or maintaining backward compatibility.

Suggested change
webhook_json_entry: str = field(default_factory=lambda: os.getenv("WEBHOOK_JSON_ENTRY", "text"))
webhook_json_entry: str = field(default_factory=lambda: os.getenv("WEBHOOK_JSON_ENTRY") or os.getenv("WEBHOOK_JSON_KEY", "text"))

Copilot uses AI. Check for mistakes.
apprise_url: str = field(default_factory=lambda: os.getenv("APPRISE_URL", ""))
post_restart_script: str = field(default_factory=lambda: os.getenv("POST_RESTART_SCRIPT", ""))
autoheal_container_label: str = field(default_factory=lambda: os.getenv("AUTOHEAL_CONTAINER_LABEL", "autoheal"))
autoheal_start_period: int = field(default_factory=lambda: int(os.getenv("AUTOHEAL_START_PERIOD", "0")))
autoheal_interval: int = field(default_factory=lambda: int(os.getenv("AUTOHEAL_INTERVAL", "5")))
autoheal_default_stop_timeout: int = field(default_factory=lambda: int(os.getenv("AUTOHEAL_DEFAULT_STOP_TIMEOUT", "10")))
autoheal_only_monitor_running: bool = field(default_factory=lambda: os.getenv("AUTOHEAL_ONLY_MONITOR_RUNNING", "false").lower() == "true")
autoheal_restart_threshold: int = field(default_factory=lambda: int(os.getenv("AUTOHEAL_RESTART_THRESHOLD", "5")))
autoheal_restart_window: int = field(default_factory=lambda: int(os.getenv("AUTOHEAL_RESTART_WINDOW", "600")))
log_level: int = field(default_factory=lambda: int(os.getenv("LOG_LEVEL", "1"))) # 1=INFO, 2=DEBUG

@property
def http_endpoint(self) -> str:
"""Determine the HTTP endpoint based on DOCKER_SOCK."""
if self.docker_sock.startswith("tcp://"):
return self.docker_sock.replace("tcp://", "http://", 1)
elif self.docker_sock.startswith("tcps://"):
return self.docker_sock.replace("tcps://", "https://", 1)
else:
return "http://localhost"

@property
def unix_sock(self) -> str:
"""Return unix socket path if applicable."""
if not self.docker_sock.startswith(("tcp://", "tcps://")):
return self.docker_sock
return ""

@property
def ca_cert(self) -> str:
"""CA cert path for TLS."""
return "/certs/ca.pem" if self.docker_sock.startswith("tcps://") else ""

@property
def client_key(self) -> str:
"""Client key path for TLS."""
return "/certs/client-key.pem" if self.docker_sock.startswith("tcps://") else ""

@property
def client_cert(self) -> str:
"""Client cert path for TLS."""
return "/certs/client-cert.pem" if self.docker_sock.startswith("tcps://") else ""
76 changes: 76 additions & 0 deletions autoheal/docker_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Docker API interaction functions."""

import docker
from .config import Config
from .logging_utils import get_logger

logger = get_logger()


def get_docker_client(config: Config) -> docker.DockerClient:
"""Get Docker client."""
kwargs = {}
if config.unix_sock:
kwargs["base_url"] = f"unix://{config.unix_sock}"
elif config.docker_sock.startswith("tcp://"):
kwargs["base_url"] = config.docker_sock
elif config.docker_sock.startswith("tcps://"):
tls_config = docker.tls.TLSConfig(
ca_cert=config.ca_cert,
client_cert=(config.client_cert, config.client_key),
verify=True
)
kwargs["base_url"] = config.docker_sock
kwargs["tls"] = tls_config
else:
pass # default
try:
return docker.DockerClient(**kwargs)
except Exception as e:
logger.error(f"Failed to create Docker client: {e}")
raise
Comment on lines +10 to +31

Copilot AI Dec 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new Docker client is created on every call to get_docker_client(), which is inefficient. This function is called in get_container_info(), restart_container(), and stop_container(), meaning a new client is instantiated for each operation. Consider creating a single client instance and reusing it throughout the application lifecycle.

Copilot uses AI. Check for mistakes.


def get_container_info(config: Config) -> list:
"""Get list of unhealthy containers."""
try:
client = get_docker_client(config)
filters = {"health": ["unhealthy"]}
if config.autoheal_container_label != "all":
filters["label"] = [f"{config.autoheal_container_label}=true"]
if config.autoheal_only_monitor_running:
filters["status"] = ["running"]
return client.containers.list(filters=filters)
except Exception as e:
logger.error(f"Failed to get container info: {e}")
return []


def restart_container(container_id: str, timeout: int, config: Config) -> bool:
"""Restart a container."""
try:
client = get_docker_client(config)
container = client.containers.get(container_id)
container.restart(timeout=timeout)
return True
except docker.errors.APIError as e:
logger.error(f"Failed to restart container {container_id}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error restarting container {container_id}: {e}")
return False


def stop_container(container_id: str, timeout: int, config: Config) -> bool:
"""Stop a container."""
try:
client = get_docker_client(config)
container = client.containers.get(container_id)
container.stop(timeout=timeout)
return True
except docker.errors.APIError as e:
logger.error(f"Failed to stop container {container_id}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error stopping container {container_id}: {e}")
return False
Loading