deu/deu.py
2025-05-01 12:39:19 +02:00

637 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import logging
import subprocess
import time
from pathlib import Path
from typing import Dict, Any, Optional, List
import yaml
import toml
import argparse
from dataclasses import dataclass
from enum import Enum, auto
import random
import string
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
class ShellType(Enum):
BASH = auto()
SH = auto()
UNKNOWN = auto()
@dataclass
class ContainerConfig:
path: Path
service: str
image: str
shell: ShellType = ShellType.UNKNOWN
def validate_path(path: Path) -> None:
"""Validate the provided path."""
if not path:
raise ValueError("Path cannot be empty")
if not path.is_absolute():
path = path.absolute()
if not path.parent.exists():
raise FileNotFoundError(f"Parent directory {path.parent} does not exist")
def validate_image(image: str) -> None:
"""Validate the Docker image name."""
if not image:
raise ValueError("Docker image cannot be empty")
# Basic validation for image format
if not any(char in image for char in [":", "@"]):
logger.warning("No tag or digest specified for the image. Using 'latest' tag")
def check_image_exists(image: str) -> bool:
"""Check if the Docker image exists locally."""
try:
subprocess.run(
["docker", "image", "inspect", image], check=True, capture_output=True
)
return True
except subprocess.CalledProcessError:
return False
def pull_image(image: str) -> None:
"""Pull the Docker image if it doesn't exist locally."""
if not check_image_exists(image):
logger.info(f"Pulling image {image}...")
try:
subprocess.run(["docker", "pull", image], check=True)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to pull image {image}: {e}")
raise
def detect_shell(image: str) -> ShellType:
"""Detect available shell in the image."""
try:
# Try bash first
subprocess.run(
["docker", "run", "--rm", image, "which", "bash"],
check=True,
capture_output=True,
)
return ShellType.BASH
except subprocess.CalledProcessError:
try:
# Fallback to sh
subprocess.run(
["docker", "run", "--rm", image, "which", "sh"],
check=True,
capture_output=True,
)
return ShellType.SH
except subprocess.CalledProcessError:
return ShellType.UNKNOWN
def create_docker_compose_config(
workspace_path: Path, image: str, service_name: str
) -> Dict[str, Any]:
"""Create the Docker Compose configuration."""
return {
"services": {
service_name: {
"image": image,
"command": "tail -f /dev/null", # Keep container running with a shell
"volumes": [f"{workspace_path.absolute()}:/workspace"],
"working_dir": "/workspace",
"stdin_open": True,
"tty": True,
"environment": {
"TERM": "xterm-256color",
},
"security_opt": ["seccomp=unconfined"],
}
}
}
def create_toml_config(
container_path: Path, service_name: str, image: str, shell: ShellType
) -> Dict[str, Any]:
"""Create the TOML configuration."""
return {
"container": {
"path": str(container_path.absolute()),
"service": service_name,
"image": image,
"shell": shell.name,
}
}
def write_config(path: Path, config: Dict[str, Any], config_type: str) -> None:
"""Write configuration to a file."""
try:
if config_type == "yaml":
path.write_text(yaml.dump(config, sort_keys=False))
elif config_type == "toml":
path.write_text(toml.dumps(config))
logger.info(f"Created {config_type.upper()} file at: {path}")
except Exception as e:
logger.error(f"Failed to write {config_type.upper()} file: {e}")
raise
def run_docker_compose_command(
container_path: Path, command: str, detach: bool = False
) -> None:
"""Run a Docker Compose command with retries."""
max_retries = 3
retry_delay = 1
for attempt in range(max_retries):
try:
cmd = [
"docker",
"compose",
"-f",
str(container_path / "docker-compose.yml"),
]
cmd.extend(command.split())
if detach:
cmd.append("-d")
subprocess.run(cmd, check=True)
return
except subprocess.CalledProcessError as e:
if attempt == max_retries - 1:
logger.error(
f"Failed to execute Docker Compose command after {max_retries} attempts: {e}"
)
raise
logger.warning(f"Command failed, retrying in {retry_delay} seconds...")
time.sleep(retry_delay)
retry_delay *= 2
def get_container_config() -> ContainerConfig:
"""Get the container configuration from deu.toml."""
try:
config = toml.loads((Path.cwd() / "deu.toml").read_text())
container_config = config["container"]
return ContainerConfig(
path=Path(container_config["path"]),
service=container_config["service"],
image=container_config["image"],
shell=ShellType[container_config["shell"]],
)
except Exception as e:
logger.error(f"Failed to read container configuration: {e}")
sys.exit(1)
def get_global_config_dir() -> Path:
"""Get the global configuration directory."""
return Path.home() / ".config" / "deu"
def find_container_config(
container_name: Optional[str] = None,
) -> Optional[ContainerConfig]:
"""Find container configuration, checking local then global."""
# If container name is provided, search for it
if container_name:
# First check local deu.toml
local_toml = Path.cwd() / "deu.toml"
if local_toml.exists():
try:
config = toml.loads(local_toml.read_text())
if config["container"]["service"] == container_name:
return get_container_config()
except Exception:
pass
# Then check global configs
global_dir = get_global_config_dir()
if global_dir.exists():
for toml_file in global_dir.glob("*.toml"):
try:
config = toml.loads(toml_file.read_text())
if config["container"]["service"] == container_name:
return ContainerConfig(
path=Path(config["container"]["path"]),
service=config["container"]["service"],
image=config["container"]["image"],
shell=ShellType[config["container"]["shell"]],
)
except Exception:
continue
return None
# If no container name, just check local deu.toml
local_toml = Path.cwd() / "deu.toml"
if local_toml.exists():
return get_container_config()
return None
def create_dev_container(
path: str, image: str, service_name: str, is_global: bool = False
) -> None:
"""Create a development container configuration."""
try:
if is_global:
# Create global config directory first
global_dir = get_global_config_dir()
global_dir.mkdir(parents=True, exist_ok=True)
container_path = global_dir / f".{service_name}"
toml_path = global_dir / f"{service_name}.toml"
else:
container_path = Path(path)
toml_path = Path.cwd() / "deu.toml"
validate_image(image)
# Ensure image exists and detect shell
pull_image(image)
shell_type = detect_shell(image)
if shell_type == ShellType.UNKNOWN:
logger.error(
"No shell (bash/sh) found in the image. Please use an image with a shell."
)
sys.exit(1)
# Create the container directory
container_path.mkdir(parents=True, exist_ok=True)
# Create configurations
workspace_path = container_path.parent
compose_config = create_docker_compose_config(
workspace_path, image, service_name
)
toml_config = create_toml_config(
container_path, service_name, image, shell_type
)
# Write configurations
write_config(container_path / "docker-compose.yml", compose_config, "yaml")
write_config(toml_path, toml_config, "toml")
except Exception as e:
logger.error(f"Failed to create development container: {e}")
sys.exit(1)
def generate_service_name() -> str:
"""Generate a random, friendly service name."""
adjectives = [
"happy",
"sleepy",
"daisy",
"sunny",
"lucky",
"fuzzy",
"cozy",
"merry",
]
nouns = ["dog", "cat", "bear", "fox", "bird", "fish", "tree", "star"]
random_number = random.randint(1000, 9999)
return f"{random.choice(adjectives)}_{random.choice(nouns)}_{random_number}"
def handle_activate(container_name: Optional[str] = None) -> None:
"""Activate the container shell."""
config = find_container_config(container_name)
if not config:
if container_name:
logger.error(f"Container '{container_name}' not found locally or globally.")
else:
logger.error(
"No local container configuration found. Run 'deu init' first or specify a container name."
)
sys.exit(1)
print(f"Trying to activate container shell for service '{config.service}'...")
# Check if container is running, start it if not
try:
ps_output = subprocess.run(
["docker", "compose", "-f", str(config.path / "docker-compose.yml"), "ps"],
check=True,
capture_output=True,
text=True,
)
if config.service not in ps_output.stdout:
logger.info(f"Container '{config.service}' is not running. Starting it...")
run_docker_compose_command(config.path, "up -d")
except subprocess.CalledProcessError:
logger.info(f"Container '{config.service}' is not running. Starting it...")
run_docker_compose_command(config.path, "up -d")
# Use appropriate shell based on configuration
shell = "bash" if config.shell == ShellType.BASH else "sh"
run_docker_compose_command(config.path, f"exec {config.service} {shell}")
def handle_background(container_name: Optional[str] = None) -> None:
"""Start the container in background mode."""
config = find_container_config(container_name)
if not config:
if container_name:
logger.error(f"Container '{container_name}' not found locally or globally.")
else:
logger.error(
"No local container configuration found. Run 'deu init' first or specify a container name."
)
sys.exit(1)
run_docker_compose_command(config.path, "up", detach=True)
def handle_logs(container_name: Optional[str] = None) -> None:
"""Show container logs."""
config = find_container_config(container_name)
if not config:
if container_name:
logger.error(f"Container '{container_name}' not found locally or globally.")
else:
logger.error(
"No local container configuration found. Run 'deu init' first or specify a container name."
)
sys.exit(1)
run_docker_compose_command(config.path, "logs -f")
def handle_stop(container_name: Optional[str] = None) -> None:
"""Stop the container."""
config = find_container_config(container_name)
if not config:
if container_name:
logger.error(f"Container '{container_name}' not found locally or globally.")
else:
logger.error(
"No local container configuration found. Run 'deu init' first or specify a container name."
)
sys.exit(1)
run_docker_compose_command(config.path, "stop")
def handle_rm(container_name: Optional[str] = None) -> None:
"""Remove the container."""
config = find_container_config(container_name)
if not config:
if container_name:
logger.error(f"Container '{container_name}' not found locally or globally.")
else:
logger.error(
"No local container configuration found. Run 'deu init' first or specify a container name."
)
sys.exit(1)
run_docker_compose_command(config.path, "down")
def handle_list() -> None:
"""List all available containers (local and global)."""
# Check for local container
local_toml = Path.cwd() / "deu.toml"
if local_toml.exists():
try:
config = toml.loads(local_toml.read_text())
print("Local container:")
print(f" {config['container']['service']} (local)")
except Exception as e:
logger.error(f"Failed to read local configuration: {e}")
# Check global containers
global_dir = get_global_config_dir()
if global_dir.exists():
global_containers = []
for toml_file in global_dir.glob("*.toml"):
try:
config = toml.loads(toml_file.read_text())
global_containers.append(config["container"]["service"])
except Exception:
continue
if global_containers:
print("\nGlobal containers:")
for container in sorted(global_containers):
print(f" {container} (global)")
elif not local_toml.exists():
print("No containers found (local or global).")
def handle_delete(container_name: Optional[str] = None) -> None:
"""Stop and remove a container."""
config = find_container_config(container_name)
if not config:
if container_name:
logger.error(f"Container '{container_name}' not found locally or globally.")
else:
logger.error(
"No local container configuration found. Run 'deu init' first or specify a container name."
)
sys.exit(1)
# Stop the container first
try:
run_docker_compose_command(config.path, "stop")
except Exception as e:
logger.warning(f"Failed to stop container: {e}")
# Then remove it
try:
run_docker_compose_command(config.path, "down")
logger.info(f"Container '{config.service}' has been deleted.")
except Exception as e:
logger.error(f"Failed to remove container: {e}")
sys.exit(1)
def handle_examples() -> None:
"""Show common usage examples."""
print(
"""
Common DEU Usage Examples:
1. Create a new development container:
deu init --image ubuntu:24.04
deu init -g --image python:3.11
deu init --image node:20 --service my_node_app
2. Work with containers:
deu activate # Start shell in local container
deu activate my_container # Start shell in specific container
deu background # Start container in background
deu logs # View container logs
3. Manage containers:
deu stop # Stop local container
deu rm # Remove local container
deu delete # Stop and remove local container
deu list # Show all available containers
4. Global containers:
deu init -g --image ubuntu:24.04 # Create global container
deu activate my_global # Use global container
deu delete my_global # Remove global container
5. Container naming:
deu init --image ubuntu:24.04 --service my_dev
deu activate my_dev
deu delete my_dev
"""
)
def main() -> None:
"""Main entry point for the script."""
parser = argparse.ArgumentParser(
description="DEU - Docker Environment Utility\n\n"
"A simple utility to create and manage development containers.\n"
"Use 'deu examples' to see common usage patterns.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
# Init command
init_parser = subparsers.add_parser(
"init",
help="Initialize a new development container",
description="Create a new development container with the specified image.\n"
"Example: deu init --image ubuntu:24.04",
)
init_parser.add_argument("path", nargs="?", help="Path to the .container directory")
init_parser.add_argument(
"--image", required=True, help="Docker image to use (e.g., ubuntu:24.04)"
)
init_parser.add_argument(
"--service",
default=generate_service_name(),
help="Name of the container service",
)
init_parser.add_argument(
"-g",
"--global",
dest="is_global",
action="store_true",
help="Create container in global config (~/.config/deu/)",
)
# Activate command
activate_parser = subparsers.add_parser(
"activate",
help="Activate container shell",
description="Start an interactive shell in the container.\n"
"Example: deu activate my_container",
)
activate_parser.add_argument(
"container_name", nargs="?", help="Name of the container to activate"
)
# Background command
background_parser = subparsers.add_parser(
"background",
help="Start container in background",
description="Start the container in detached mode.\n"
"Example: deu background my_container",
)
background_parser.add_argument(
"container_name", nargs="?", help="Name of the container to start"
)
# Logs command
logs_parser = subparsers.add_parser(
"logs",
help="Show container logs",
description="View container logs in follow mode.\n"
"Example: deu logs my_container",
)
logs_parser.add_argument(
"container_name", nargs="?", help="Name of the container to show logs for"
)
# Stop command
stop_parser = subparsers.add_parser(
"stop",
help="Stop container",
description="Stop a running container.\n" "Example: deu stop my_container",
)
stop_parser.add_argument(
"container_name", nargs="?", help="Name of the container to stop"
)
# Remove command
rm_parser = subparsers.add_parser(
"rm",
help="Remove container",
description="Remove a container and its configuration.\n"
"Example: deu rm my_container",
)
rm_parser.add_argument(
"container_name", nargs="?", help="Name of the container to remove"
)
# List command
list_parser = subparsers.add_parser(
"list",
help="List all available containers",
description="Show all containers (local and global).\n" "Example: deu list",
)
# Delete command
delete_parser = subparsers.add_parser(
"delete",
help="Stop and remove a container",
description="Stop and remove a container in one command.\n"
"Example: deu delete my_container",
)
delete_parser.add_argument(
"container_name", nargs="?", help="Name of the container to delete"
)
# Examples command
subparsers.add_parser(
"examples",
help="Show common usage examples",
description="Display common usage patterns and examples.",
)
args = parser.parse_args()
if args.command == "init":
create_dev_container(
args.path or ".container", args.image, args.service, args.is_global
)
elif args.command == "activate":
handle_activate(args.container_name)
elif args.command == "background":
handle_background(args.container_name)
elif args.command == "logs":
handle_logs(args.container_name)
elif args.command == "stop":
handle_stop(args.container_name)
elif args.command == "rm":
handle_rm(args.container_name)
elif args.command == "list":
handle_list()
elif args.command == "delete":
handle_delete(args.container_name)
elif args.command == "examples":
handle_examples()
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()