Files
peasead-elastic-container/lib/docker_manager.py
Andrew Pease 3c21bd7d14 Fix Detection Engine timeouts and standardize command output
Detection Engine improvements (kibana_manager.py):
- Increase prepackaged rules installation timeout: 60s → 180s
- Increase bulk enable rules timeout: 60s → 120s
- Fixes timeout errors when installing ~1400 prebuilt detection rules
- Rules install successfully even with previous timeout, this eliminates warnings

Output formatting improvements (docker_manager.py):
- Remove bash-style separators (##### lines)
- Standardize all command output to match 'start' command style
- Use consistent cyan for actions, green for success
- Add blank lines before final success messages
- Commands affected: stop, destroy, restart, stage
2025-12-01 13:40:08 -06:00

453 lines
14 KiB
Python

"""
Docker management for Elastic Container
Handles all Docker Compose operations
"""
import subprocess
import click
import requests
from typing import Optional
from urllib3.exceptions import InsecureRequestWarning
from .config import Config
from .utils import run_command, show_docker_error
# Suppress SSL warnings for self-signed certificates
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
class DockerManager:
"""
Manages Docker Compose operations for Elastic Container
"""
def __init__(self, config: Config, verbose: bool = False):
"""
Initialize Docker Manager
Args:
config: Configuration object
verbose: Enable verbose output
"""
self.config = config
self.verbose = verbose
self.compose_command = self._detect_compose_command()
def _detect_compose_command(self) -> Optional[list]:
"""
Detect available docker compose command
Returns:
Command as list (e.g., ["docker", "compose"]) or None if not found
"""
# Try docker compose (plugin) first
try:
result = subprocess.run(
["docker", "compose", "version"],
capture_output=True,
check=True
)
if self.verbose:
click.secho("✓ Found: docker compose (plugin)", fg="green")
return ["docker", "compose"]
except (subprocess.CalledProcessError, FileNotFoundError):
pass
# Try docker-compose (standalone)
try:
result = subprocess.run(
["docker-compose", "version"],
capture_output=True,
check=True
)
if self.verbose:
click.secho("✓ Found: docker-compose (standalone)", fg="green")
return ["docker-compose"]
except (subprocess.CalledProcessError, FileNotFoundError):
pass
return None
def check_docker_available(self) -> bool:
"""
Check if Docker is installed and running
Returns:
True if Docker is ready, False otherwise
"""
# Check if docker command exists
try:
subprocess.run(
["docker", "--version"],
capture_output=True,
check=True
)
except FileNotFoundError:
show_docker_error(
"Docker is not installed!",
"Docker is required to run Elastic Container.",
[
"macOS: https://docs.docker.com/desktop/install/mac-install/",
"Linux: https://docs.docker.com/engine/install/",
"Windows: https://docs.docker.com/desktop/install/windows-install/"
]
)
return False
# Check if Docker daemon is running
try:
subprocess.run(
["docker", "ps"],
capture_output=True,
check=True
)
except subprocess.CalledProcessError:
show_docker_error(
"Docker is installed but not running!",
"Please start Docker:",
[
"macOS/Windows: Open Docker Desktop application",
"Linux: Run 'sudo systemctl start docker' or 'sudo service docker start'"
]
)
return False
# Check if compose is available
if self.compose_command is None:
show_docker_error(
"Docker Compose is not available!",
"Elastic Container requires 'docker compose' (plugin) or 'docker-compose' (standalone).",
[
"Plugin (recommended): Included with Docker Desktop or install docker-compose-plugin",
"Standalone: https://docs.docker.com/compose/install/"
]
)
return False
if self.verbose:
click.secho("✓ Docker is available and running", fg="green")
return True
def _run_compose(self, args: list, check: bool = True) -> tuple[int, str, str]:
"""
Execute a docker compose command
Args:
args: Additional arguments for docker compose
check: Raise exception on error
Returns:
Tuple of (exit_code, stdout, stderr)
"""
command = self.compose_command + args
return run_command(command, check=check, verbose=self.verbose)
def stage(self) -> bool:
"""
Pull all required Docker images
Returns:
True on success, False on failure
"""
click.echo()
click.secho("Staging Docker images...", fg="cyan")
click.echo()
images = [
f"docker.elastic.co/elasticsearch/elasticsearch:{self.config.stack_version}",
f"docker.elastic.co/kibana/kibana:{self.config.stack_version}",
f"docker.elastic.co/elastic-agent/elastic-agent:{self.config.stack_version}"
]
for image in images:
click.echo(f"Pulling {image}...")
exit_code, stdout, stderr = run_command(
["docker", "pull", image],
check=False,
verbose=self.verbose
)
if exit_code != 0:
click.secho(f"✗ Failed to pull {image}", fg="red")
click.echo(stderr)
return False
click.secho(f"✓ Pulled {image}", fg="green")
click.echo()
click.secho("✓ Images staged successfully", fg="green")
return True
def start(self) -> bool:
"""
Start the Elastic Stack containers
Returns:
True on success, False on failure
"""
click.echo()
click.secho("Starting Elastic Stack network and containers...", fg="cyan", bold=True)
click.echo()
exit_code, stdout, stderr = self._run_compose(
["up", "-d", "--no-deps"],
check=False
)
if exit_code != 0:
show_docker_error(
"Failed to start containers!",
f"Command: {' '.join(self.compose_command)} up -d\nExit code: {exit_code}\n\nError output:\n{stderr}",
[
"Check if ports 5601, 9200, 8220 are already in use",
"Ensure you have enough disk space",
"Try 'docker compose down -v' to clean up first",
"Run with -v flag for verbose output"
]
)
return False
# Show output (docker compose shows container status)
if stdout:
click.echo(stdout)
return True
def stop(self) -> bool:
"""
Stop running containers without removing them
Returns:
True on success, False on failure
"""
click.echo()
click.secho("Stopping containers...", fg="cyan")
click.echo()
exit_code, stdout, stderr = self._run_compose(["stop"], check=False)
if exit_code != 0:
click.secho("✗ Failed to stop containers", fg="red")
click.echo(stderr)
return False
if stdout:
click.echo(stdout)
click.echo()
click.secho("✓ Containers stopped", fg="green")
return True
def destroy(self) -> bool:
"""
Stop and remove containers, networks, and volumes
Returns:
True on success, False on failure
"""
click.echo()
click.secho("Destroying Elastic Stack...", fg="cyan")
click.echo()
exit_code, stdout, stderr = self._run_compose(["down", "-v"], check=False)
if exit_code != 0:
click.secho("✗ Failed to destroy stack", fg="red")
click.echo(stderr)
return False
if stdout:
click.echo(stdout)
click.echo()
click.secho("✓ Stack destroyed", fg="green")
return True
def restart(self) -> bool:
"""
Restart Elastic Stack containers
Returns:
True on success, False on failure
"""
click.echo()
click.secho("Restarting containers...", fg="cyan")
click.echo()
exit_code, stdout, stderr = self._run_compose(
["restart", "elasticsearch", "kibana", "fleet-server"],
check=False
)
if exit_code != 0:
click.secho("✗ Failed to restart containers", fg="red")
click.echo(stderr)
return False
if stdout:
click.echo(stdout)
click.echo()
click.secho("✓ Containers restarted", fg="green")
return True
def status(self) -> bool:
"""
Show status of running containers
Returns:
True on success, False on failure
"""
exit_code, stdout, stderr = self._run_compose(["ps"], check=False)
if exit_code != 0:
click.secho(f"✗ Failed to get status", fg="red")
click.echo(stderr)
return False
# Filter out setup container
lines = stdout.split('\n')
filtered_lines = [line for line in lines if 'setup' not in line.lower() or line.startswith('NAME')]
click.echo('\n'.join(filtered_lines))
return True
def check_containers_exist(self) -> tuple[bool, bool]:
"""
Check if containers exist and whether they're running
Returns:
Tuple of (containers_exist, containers_running)
"""
# Check all containers including stopped ones
exit_code, stdout, stderr = self._run_compose(["ps", "-a"], check=False)
if exit_code != 0:
return False, False
# Check for main containers (elasticsearch, kibana, fleet-server)
main_containers = ["elasticsearch", "kibana", "fleet-server"]
found_containers = []
running_containers = []
for line in stdout.split('\n'):
line_lower = line.lower()
for container in main_containers:
if container in line_lower:
found_containers.append(container)
# Check if running (has "Up" in status or "running")
if 'up' in line_lower or 'running' in line_lower:
running_containers.append(container)
# Containers exist if we found at least 2 of the 3 main ones
containers_exist = len(found_containers) >= 2
# All running if we found all 3 running
all_running = len(running_containers) >= 3
return containers_exist, all_running
def get_ca_fingerprint(self) -> Optional[str]:
"""
Extract CA certificate fingerprint from Elasticsearch container
Returns:
Fingerprint string or None on failure
"""
try:
# Get the CA cert from the container
cat_command = self.compose_command + [
"exec", "-w", "/usr/share/elasticsearch/config/certs/ca",
"elasticsearch", "cat", "ca.crt"
]
cat_result = subprocess.run(
cat_command,
capture_output=True,
text=True,
check=True
)
# Get fingerprint using openssl
openssl_result = subprocess.run(
["openssl", "x509", "-noout", "-fingerprint", "-sha256"],
input=cat_result.stdout,
capture_output=True,
text=True,
check=True
)
# Parse fingerprint (format: "SHA256 Fingerprint=XX:XX:XX...")
fingerprint_line = openssl_result.stdout.strip()
if "=" in fingerprint_line:
fingerprint = fingerprint_line.split("=")[1].replace(":", "")
return fingerprint
return None
except subprocess.CalledProcessError as e:
if self.verbose:
click.secho(f"Failed to get CA fingerprint: {e}", fg="red")
return None
def clear_documents(self, host_ip: str) -> bool:
"""
Clear all documents in logs and metrics data streams
Args:
host_ip: Elasticsearch host IP
Returns:
True on success, False on failure
"""
click.echo()
click.secho("Clearing data streams...", fg="cyan", bold=True)
click.echo()
es_url = f"https://{host_ip}:{self.config.es_port}"
auth = (self.config.elastic_username, self.config.elastic_password)
success = True
# Clear logs data stream
try:
response = requests.delete(
f"{es_url}/_data_stream/logs-*",
auth=auth,
verify=False,
timeout=30
)
if response.status_code == 200 and "true" in response.text:
click.secho("✓ Successfully cleared logs data stream", fg="green")
else:
click.secho("✗ Failed to clear logs data stream", fg="red")
success = False
except Exception as e:
click.secho(f"✗ Failed to clear logs data stream: {e}", fg="red")
success = False
# Clear metrics data stream
try:
response = requests.delete(
f"{es_url}/_data_stream/metrics-*",
auth=auth,
verify=False,
timeout=30
)
if response.status_code == 200 and "true" in response.text:
click.secho("✓ Successfully cleared metrics data stream", fg="green")
else:
click.secho("✗ Failed to clear metrics data stream", fg="red")
success = False
except Exception as e:
click.secho(f"✗ Failed to clear metrics data stream: {e}", fg="red")
success = False
click.echo()
return success