initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.claude/settings.local.json
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,127 @@
|
||||
# Light Docker API
|
||||
|
||||
A Python/FastAPI REST API that provides a simplified, secure interface to Docker container operations. It runs as a Docker container itself and only manages containers it creates (tracked via labels).
|
||||
|
||||
## Features
|
||||
|
||||
- **Container Management**: Create, start, stop, restart, and remove containers
|
||||
- **Container Isolation**: Only manages containers it creates (via labels)
|
||||
- **Security Filters**: Blocks dangerous Docker options (privileged mode, host networking, etc.)
|
||||
- **Resource Monitoring**: Get container logs and stats
|
||||
- **Docker-like API**: Familiar endpoint structure for Docker users
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`.
|
||||
|
||||
### Manual Docker Build
|
||||
|
||||
```bash
|
||||
docker build -t light-docker-api .
|
||||
docker run -p 8000:8000 -v /var/run/docker.sock:/var/run/docker.sock:ro light-docker-api
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/` | API information |
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/containers` | List managed containers |
|
||||
| POST | `/containers/create` | Create a new container |
|
||||
| GET | `/containers/{id}` | Inspect a container |
|
||||
| POST | `/containers/{id}/start` | Start a container |
|
||||
| POST | `/containers/{id}/stop` | Stop a container |
|
||||
| POST | `/containers/{id}/restart` | Restart a container |
|
||||
| DELETE | `/containers/{id}` | Remove a container |
|
||||
| GET | `/containers/{id}/logs` | Get container logs |
|
||||
| GET | `/containers/{id}/stats` | Get container stats |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Create a container
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/containers/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"image": "nginx", "name": "my-nginx"}'
|
||||
```
|
||||
|
||||
### List containers
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/containers
|
||||
```
|
||||
|
||||
### Start a container
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/containers/{id}/start
|
||||
```
|
||||
|
||||
### Get container logs
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/containers/{id}/logs
|
||||
```
|
||||
|
||||
### Remove a container
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/containers/{id}
|
||||
```
|
||||
|
||||
## Security Restrictions
|
||||
|
||||
The following options are blocked for security:
|
||||
|
||||
- `privileged: true` - Rejected
|
||||
- `network_mode: host` - Rejected
|
||||
- `pid_mode: host` - Rejected
|
||||
- `ipc_mode: host` - Rejected
|
||||
- `devices` - Rejected
|
||||
- `cap_add` - Only allowed capabilities (none by default)
|
||||
- Volume mounts to sensitive paths (`/`, `/etc`, `/var/run/docker.sock`, etc.)
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `INSTANCE_ID` | Unique instance identifier | Random UUID |
|
||||
| `LIGHT_DOCKER_API_CORS_ORIGINS` | CORS allowed origins | `["*"]` |
|
||||
| `LIGHT_DOCKER_API_ALLOWED_IMAGES` | Allowed image list (empty = all) | `[]` |
|
||||
|
||||
## API Documentation
|
||||
|
||||
When running, interactive API documentation is available at:
|
||||
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
## Container Tracking
|
||||
|
||||
Every container created through this API is labeled with:
|
||||
|
||||
- `light-docker-api.managed=true`
|
||||
- `light-docker-api.instance={instance_id}`
|
||||
|
||||
Only containers with these labels are visible and manageable through the API.
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Note: Local development requires access to the Docker socket.
|
||||
@@ -0,0 +1 @@
|
||||
# Light Docker API
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Configuration settings for Light Docker API."""
|
||||
|
||||
import os
|
||||
from typing import List, Set
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# API settings
|
||||
api_title: str = "Light Docker API"
|
||||
api_version: str = "1.0.0"
|
||||
cors_origins: List[str] = ["*"]
|
||||
|
||||
# Instance identification
|
||||
instance_id: str = os.environ.get("INSTANCE_ID", uuid4().hex[:8])
|
||||
|
||||
# Label configuration
|
||||
label_prefix: str = "light-docker-api"
|
||||
|
||||
# Security settings
|
||||
allowed_images: List[str] = [] # Empty means all images allowed
|
||||
blocked_volume_paths: Set[str] = {
|
||||
"/",
|
||||
"/etc",
|
||||
"/var/run/docker.sock",
|
||||
"/var/run",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/dev",
|
||||
"/boot",
|
||||
"/root",
|
||||
"/home",
|
||||
}
|
||||
|
||||
# Allowed capabilities (empty means none allowed)
|
||||
allowed_capabilities: Set[str] = set()
|
||||
|
||||
class Config:
|
||||
env_prefix = "LIGHT_DOCKER_API_"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Label constants
|
||||
MANAGED_LABEL = f"{settings.label_prefix}.managed"
|
||||
INSTANCE_LABEL = f"{settings.label_prefix}.instance"
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Docker SDK wrapper with label-based container tracking."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Generator, List, Optional, Union
|
||||
|
||||
import docker
|
||||
from docker.errors import APIError, ImageNotFound, NotFound
|
||||
from docker.models.containers import Container
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import INSTANCE_LABEL, MANAGED_LABEL, settings
|
||||
from app.models import (
|
||||
ContainerCreate,
|
||||
ContainerCreateResponse,
|
||||
ContainerLogsResponse,
|
||||
ContainerResponse,
|
||||
ContainerStatsResponse,
|
||||
)
|
||||
|
||||
|
||||
class DockerClientError(HTTPException):
|
||||
"""Exception raised for Docker client errors."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
|
||||
super().__init__(status_code=status_code, detail=message)
|
||||
|
||||
|
||||
class DockerManager:
|
||||
"""Manager for Docker operations with label-based tracking."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Docker client."""
|
||||
try:
|
||||
self.client = docker.from_env()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to connect to Docker: {e}")
|
||||
|
||||
self.instance_id = settings.instance_id
|
||||
|
||||
def _get_managed_labels(self, extra_labels: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
"""Get the labels to apply to managed containers."""
|
||||
labels = {
|
||||
MANAGED_LABEL: "true",
|
||||
INSTANCE_LABEL: self.instance_id,
|
||||
}
|
||||
if extra_labels:
|
||||
labels.update(extra_labels)
|
||||
return labels
|
||||
|
||||
def _container_to_response(self, container: Container) -> ContainerResponse:
|
||||
"""Convert a Docker container to a response model."""
|
||||
container.reload() # Ensure we have the latest state
|
||||
attrs = container.attrs
|
||||
|
||||
# Parse creation time
|
||||
created_str = attrs.get("Created", "")
|
||||
try:
|
||||
# Docker uses ISO format with nanoseconds
|
||||
created = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
created = datetime.now()
|
||||
|
||||
# Get port bindings
|
||||
ports = {}
|
||||
network_settings = attrs.get("NetworkSettings", {})
|
||||
port_bindings = network_settings.get("Ports", {})
|
||||
if port_bindings:
|
||||
for container_port, bindings in port_bindings.items():
|
||||
if bindings:
|
||||
ports[container_port] = [
|
||||
{"HostIp": b.get("HostIp", ""), "HostPort": b.get("HostPort", "")}
|
||||
for b in bindings
|
||||
]
|
||||
|
||||
# Get image name
|
||||
image_name = attrs.get("Config", {}).get("Image", "")
|
||||
if not image_name and container.image:
|
||||
image_name = container.image.tags[0] if container.image.tags else container.image.short_id
|
||||
|
||||
return ContainerResponse(
|
||||
id=container.id,
|
||||
short_id=container.short_id,
|
||||
name=container.name,
|
||||
image=image_name,
|
||||
status=container.status,
|
||||
created=created,
|
||||
ports=ports,
|
||||
labels=attrs.get("Config", {}).get("Labels", {}),
|
||||
)
|
||||
|
||||
def is_managed(self, container: Container) -> bool:
|
||||
"""Check if a container is managed by this API."""
|
||||
labels = container.labels or {}
|
||||
return labels.get(MANAGED_LABEL) == "true"
|
||||
|
||||
def get_managed_container(self, container_id: str) -> Container:
|
||||
"""Get a container by ID, ensuring it's managed by this API."""
|
||||
try:
|
||||
container = self.client.containers.get(container_id)
|
||||
except NotFound:
|
||||
raise DockerClientError(
|
||||
f"Container '{container_id}' not found",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not self.is_managed(container):
|
||||
raise DockerClientError(
|
||||
f"Container '{container_id}' is not managed by this API",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return container
|
||||
|
||||
def list_managed(self, all_containers: bool = True) -> List[ContainerResponse]:
|
||||
"""List all containers managed by this API."""
|
||||
try:
|
||||
containers = self.client.containers.list(
|
||||
all=all_containers,
|
||||
filters={"label": f"{MANAGED_LABEL}=true"},
|
||||
)
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to list containers: {e}")
|
||||
|
||||
return [self._container_to_response(c) for c in containers]
|
||||
|
||||
def create_container(self, request: ContainerCreate) -> ContainerCreateResponse:
|
||||
"""Create a new container with managed labels."""
|
||||
# Prepare labels
|
||||
labels = self._get_managed_labels(request.labels)
|
||||
|
||||
# Prepare port bindings
|
||||
ports = None
|
||||
if request.ports:
|
||||
ports = {}
|
||||
for container_port, host_binding in request.ports.items():
|
||||
if isinstance(host_binding, int):
|
||||
ports[container_port] = host_binding
|
||||
elif isinstance(host_binding, dict):
|
||||
ports[container_port] = (
|
||||
host_binding.get("host_ip", "0.0.0.0"),
|
||||
host_binding.get("host_port"),
|
||||
)
|
||||
elif isinstance(host_binding, list):
|
||||
ports[container_port] = [
|
||||
(b.get("host_ip", "0.0.0.0"), b.get("host_port"))
|
||||
for b in host_binding
|
||||
]
|
||||
|
||||
# Prepare volumes
|
||||
volumes = None
|
||||
if request.volumes:
|
||||
volumes = {
|
||||
host_path: {
|
||||
"bind": config.bind,
|
||||
"mode": config.mode,
|
||||
}
|
||||
for host_path, config in request.volumes.items()
|
||||
}
|
||||
|
||||
# Prepare restart policy
|
||||
restart_policy = None
|
||||
if request.restart_policy:
|
||||
restart_policy = {
|
||||
"Name": request.restart_policy.name,
|
||||
"MaximumRetryCount": request.restart_policy.maximum_retry_count,
|
||||
}
|
||||
|
||||
# Build container options
|
||||
container_options: Dict[str, Any] = {
|
||||
"image": request.image,
|
||||
"labels": labels,
|
||||
"detach": True,
|
||||
}
|
||||
|
||||
if request.name:
|
||||
container_options["name"] = request.name
|
||||
if request.command:
|
||||
container_options["command"] = request.command
|
||||
if request.entrypoint:
|
||||
container_options["entrypoint"] = request.entrypoint
|
||||
if request.environment:
|
||||
container_options["environment"] = request.environment
|
||||
if ports:
|
||||
container_options["ports"] = ports
|
||||
if volumes:
|
||||
container_options["volumes"] = volumes
|
||||
if restart_policy:
|
||||
container_options["restart_policy"] = restart_policy
|
||||
if request.working_dir:
|
||||
container_options["working_dir"] = request.working_dir
|
||||
if request.user:
|
||||
container_options["user"] = request.user
|
||||
if request.hostname:
|
||||
container_options["hostname"] = request.hostname
|
||||
if request.network:
|
||||
container_options["network"] = request.network
|
||||
if request.mem_limit:
|
||||
container_options["mem_limit"] = request.mem_limit
|
||||
if request.cpu_period:
|
||||
container_options["cpu_period"] = request.cpu_period
|
||||
if request.cpu_quota:
|
||||
container_options["cpu_quota"] = request.cpu_quota
|
||||
|
||||
try:
|
||||
container = self.client.containers.create(**container_options)
|
||||
except ImageNotFound:
|
||||
raise DockerClientError(
|
||||
f"Image '{request.image}' not found",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to create container: {e}")
|
||||
|
||||
return ContainerCreateResponse(
|
||||
id=container.id,
|
||||
name=container.name,
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
def inspect_container(self, container_id: str) -> ContainerResponse:
|
||||
"""Get detailed information about a container."""
|
||||
container = self.get_managed_container(container_id)
|
||||
return self._container_to_response(container)
|
||||
|
||||
def start_container(self, container_id: str) -> None:
|
||||
"""Start a container."""
|
||||
container = self.get_managed_container(container_id)
|
||||
try:
|
||||
container.start()
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to start container: {e}")
|
||||
|
||||
def stop_container(self, container_id: str, timeout: int = 10) -> None:
|
||||
"""Stop a container."""
|
||||
container = self.get_managed_container(container_id)
|
||||
try:
|
||||
container.stop(timeout=timeout)
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to stop container: {e}")
|
||||
|
||||
def restart_container(self, container_id: str, timeout: int = 10) -> None:
|
||||
"""Restart a container."""
|
||||
container = self.get_managed_container(container_id)
|
||||
try:
|
||||
container.restart(timeout=timeout)
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to restart container: {e}")
|
||||
|
||||
def remove_container(self, container_id: str, force: bool = False, v: bool = False) -> None:
|
||||
"""Remove a container."""
|
||||
container = self.get_managed_container(container_id)
|
||||
try:
|
||||
container.remove(force=force, v=v)
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to remove container: {e}")
|
||||
|
||||
def get_logs(
|
||||
self,
|
||||
container_id: str,
|
||||
stdout: bool = True,
|
||||
stderr: bool = True,
|
||||
tail: Union[int, str] = "all",
|
||||
since: Optional[int] = None,
|
||||
until: Optional[int] = None,
|
||||
) -> ContainerLogsResponse:
|
||||
"""Get container logs."""
|
||||
container = self.get_managed_container(container_id)
|
||||
try:
|
||||
logs = container.logs(
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
tail=tail,
|
||||
since=since,
|
||||
until=until,
|
||||
)
|
||||
if isinstance(logs, bytes):
|
||||
logs = logs.decode("utf-8", errors="replace")
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to get logs: {e}")
|
||||
|
||||
return ContainerLogsResponse(
|
||||
logs=logs,
|
||||
container_id=container_id,
|
||||
)
|
||||
|
||||
def get_stats(self, container_id: str, stream: bool = False) -> ContainerStatsResponse:
|
||||
"""Get container stats."""
|
||||
container = self.get_managed_container(container_id)
|
||||
try:
|
||||
stats = container.stats(stream=stream)
|
||||
if isinstance(stats, Generator):
|
||||
stats = next(stats)
|
||||
except APIError as e:
|
||||
raise DockerClientError(f"Failed to get stats: {e}")
|
||||
|
||||
# Parse CPU stats
|
||||
cpu_delta = stats.get("cpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0) - \
|
||||
stats.get("precpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0)
|
||||
system_delta = stats.get("cpu_stats", {}).get("system_cpu_usage", 0) - \
|
||||
stats.get("precpu_stats", {}).get("system_cpu_usage", 0)
|
||||
num_cpus = len(stats.get("cpu_stats", {}).get("cpu_usage", {}).get("percpu_usage", [1]))
|
||||
|
||||
cpu_percent = 0.0
|
||||
if system_delta > 0 and cpu_delta > 0:
|
||||
cpu_percent = (cpu_delta / system_delta) * num_cpus * 100.0
|
||||
|
||||
# Parse memory stats
|
||||
memory_stats = stats.get("memory_stats", {})
|
||||
memory_usage = memory_stats.get("usage", 0)
|
||||
memory_limit = memory_stats.get("limit", 1)
|
||||
memory_percent = (memory_usage / memory_limit) * 100.0 if memory_limit > 0 else 0.0
|
||||
|
||||
# Parse network stats
|
||||
networks = stats.get("networks", {})
|
||||
network_rx = sum(n.get("rx_bytes", 0) for n in networks.values())
|
||||
network_tx = sum(n.get("tx_bytes", 0) for n in networks.values())
|
||||
|
||||
return ContainerStatsResponse(
|
||||
container_id=container_id,
|
||||
cpu_percent=round(cpu_percent, 2),
|
||||
memory_usage=memory_usage,
|
||||
memory_limit=memory_limit,
|
||||
memory_percent=round(memory_percent, 2),
|
||||
network_rx_bytes=network_rx,
|
||||
network_tx_bytes=network_tx,
|
||||
)
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Check if Docker daemon is accessible."""
|
||||
try:
|
||||
self.client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Global instance (created on first import)
|
||||
docker_manager: Optional[DockerManager] = None
|
||||
|
||||
|
||||
def get_docker_manager() -> DockerManager:
|
||||
"""Get or create the Docker manager instance."""
|
||||
global docker_manager
|
||||
if docker_manager is None:
|
||||
docker_manager = DockerManager()
|
||||
return docker_manager
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"""Light Docker API - FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.docker_client import get_docker_manager
|
||||
from app.routes import containers
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
# Startup: Initialize Docker client and verify connection
|
||||
docker = get_docker_manager()
|
||||
if not docker.ping():
|
||||
raise RuntimeError("Cannot connect to Docker daemon")
|
||||
print(f"Connected to Docker daemon, instance ID: {docker.instance_id}")
|
||||
yield
|
||||
# Shutdown: Cleanup if needed
|
||||
print("Shutting down Light Docker API")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.api_title,
|
||||
version=settings.api_version,
|
||||
description="A simplified, secure REST API for Docker container operations",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(containers.router)
|
||||
|
||||
|
||||
@app.get("/", tags=["root"])
|
||||
async def root():
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"name": settings.api_title,
|
||||
"version": settings.api_version,
|
||||
"instance_id": settings.instance_id,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
docker = get_docker_manager()
|
||||
docker_ok = docker.ping()
|
||||
return {
|
||||
"status": "healthy" if docker_ok else "unhealthy",
|
||||
"docker": "connected" if docker_ok else "disconnected",
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
"""Pydantic models for request/response validation."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VolumeConfig(BaseModel):
|
||||
"""Volume binding configuration."""
|
||||
|
||||
bind: str = Field(..., description="Container path to mount to")
|
||||
mode: Literal["ro", "rw"] = Field(default="rw", description="Mount mode")
|
||||
|
||||
|
||||
class PortBinding(BaseModel):
|
||||
"""Port binding configuration."""
|
||||
|
||||
host_ip: str = Field(default="0.0.0.0", description="Host IP to bind to")
|
||||
host_port: int = Field(..., description="Host port to bind to")
|
||||
|
||||
|
||||
class RestartPolicy(BaseModel):
|
||||
"""Container restart policy."""
|
||||
|
||||
name: Literal["no", "always", "on-failure", "unless-stopped"] = Field(
|
||||
default="no", description="Restart policy name"
|
||||
)
|
||||
maximum_retry_count: int = Field(
|
||||
default=0, description="Max retries for on-failure policy"
|
||||
)
|
||||
|
||||
|
||||
class ContainerCreate(BaseModel):
|
||||
"""Request model for creating a container."""
|
||||
|
||||
image: str = Field(..., description="Docker image to use")
|
||||
name: Optional[str] = Field(None, description="Container name")
|
||||
command: Optional[Union[str, List[str]]] = Field(
|
||||
None, description="Command to run"
|
||||
)
|
||||
entrypoint: Optional[Union[str, List[str]]] = Field(
|
||||
None, description="Container entrypoint"
|
||||
)
|
||||
environment: Optional[Dict[str, str]] = Field(
|
||||
None, description="Environment variables"
|
||||
)
|
||||
ports: Optional[Dict[str, Union[int, PortBinding, List[PortBinding]]]] = Field(
|
||||
None,
|
||||
description="Port bindings: {'80/tcp': 8080} or {'80/tcp': {'host_ip': '0.0.0.0', 'host_port': 8080}}",
|
||||
)
|
||||
volumes: Optional[Dict[str, VolumeConfig]] = Field(
|
||||
None,
|
||||
description="Volume bindings: {'/host/path': {'bind': '/container/path', 'mode': 'rw'}}",
|
||||
)
|
||||
labels: Optional[Dict[str, str]] = Field(None, description="Container labels")
|
||||
restart_policy: Optional[RestartPolicy] = Field(
|
||||
None, description="Restart policy configuration"
|
||||
)
|
||||
working_dir: Optional[str] = Field(None, description="Working directory")
|
||||
user: Optional[str] = Field(None, description="User to run as")
|
||||
hostname: Optional[str] = Field(None, description="Container hostname")
|
||||
network: Optional[str] = Field(None, description="Network to connect to")
|
||||
mem_limit: Optional[str] = Field(None, description="Memory limit (e.g., '512m')")
|
||||
cpu_period: Optional[int] = Field(None, description="CPU period in microseconds")
|
||||
cpu_quota: Optional[int] = Field(None, description="CPU quota in microseconds")
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class ContainerResponse(BaseModel):
|
||||
"""Response model for container information."""
|
||||
|
||||
id: str = Field(..., description="Container ID")
|
||||
short_id: str = Field(..., description="Short container ID")
|
||||
name: str = Field(..., description="Container name")
|
||||
image: str = Field(..., description="Image name")
|
||||
status: str = Field(..., description="Container status")
|
||||
created: datetime = Field(..., description="Creation timestamp")
|
||||
ports: Dict[str, Any] = Field(default_factory=dict, description="Port bindings")
|
||||
labels: Dict[str, str] = Field(default_factory=dict, description="Container labels")
|
||||
|
||||
|
||||
class ContainerListResponse(BaseModel):
|
||||
"""Response model for listing containers."""
|
||||
|
||||
containers: List[ContainerResponse]
|
||||
count: int
|
||||
|
||||
|
||||
class ContainerCreateResponse(BaseModel):
|
||||
"""Response model for container creation."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContainerLogsResponse(BaseModel):
|
||||
"""Response model for container logs."""
|
||||
|
||||
logs: str
|
||||
container_id: str
|
||||
|
||||
|
||||
class ContainerStatsResponse(BaseModel):
|
||||
"""Response model for container stats."""
|
||||
|
||||
container_id: str
|
||||
cpu_percent: float
|
||||
memory_usage: int
|
||||
memory_limit: int
|
||||
memory_percent: float
|
||||
network_rx_bytes: int
|
||||
network_tx_bytes: int
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response model."""
|
||||
|
||||
error: str
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Simple message response."""
|
||||
|
||||
message: str
|
||||
container_id: Optional[str] = None
|
||||
@@ -0,0 +1 @@
|
||||
# Routes package
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Container management API routes."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
|
||||
from app.docker_client import DockerManager, get_docker_manager
|
||||
from app.models import (
|
||||
ContainerCreate,
|
||||
ContainerCreateResponse,
|
||||
ContainerListResponse,
|
||||
ContainerLogsResponse,
|
||||
ContainerResponse,
|
||||
ContainerStatsResponse,
|
||||
MessageResponse,
|
||||
)
|
||||
from app.security import validate_container_request
|
||||
|
||||
router = APIRouter(prefix="/containers", tags=["containers"])
|
||||
|
||||
|
||||
@router.get("", response_model=ContainerListResponse)
|
||||
async def list_containers(
|
||||
all: bool = Query(True, description="Include stopped containers"),
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> ContainerListResponse:
|
||||
"""
|
||||
List all containers managed by this API.
|
||||
|
||||
Only containers created through this API (with managed labels) will be returned.
|
||||
"""
|
||||
containers = docker.list_managed(all_containers=all)
|
||||
return ContainerListResponse(containers=containers, count=len(containers))
|
||||
|
||||
|
||||
@router.post("/create", response_model=ContainerCreateResponse)
|
||||
async def create_container(
|
||||
request: ContainerCreate = Body(...),
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> ContainerCreateResponse:
|
||||
"""
|
||||
Create a new container.
|
||||
|
||||
The container will be labeled as managed by this API and will only be accessible
|
||||
through this API's endpoints.
|
||||
|
||||
Security restrictions apply:
|
||||
- Privileged mode is not allowed
|
||||
- Host network mode is not allowed
|
||||
- Host PID mode is not allowed
|
||||
- Certain volume mounts are blocked
|
||||
- Device mappings are not allowed
|
||||
"""
|
||||
# Validate security
|
||||
validate_container_request(request)
|
||||
|
||||
# Create the container
|
||||
return docker.create_container(request)
|
||||
|
||||
|
||||
@router.get("/{container_id}", response_model=ContainerResponse)
|
||||
async def inspect_container(
|
||||
container_id: str,
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> ContainerResponse:
|
||||
"""
|
||||
Get detailed information about a container.
|
||||
|
||||
Only managed containers can be inspected.
|
||||
"""
|
||||
return docker.inspect_container(container_id)
|
||||
|
||||
|
||||
@router.post("/{container_id}/start", response_model=MessageResponse)
|
||||
async def start_container(
|
||||
container_id: str,
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
Start a stopped container.
|
||||
|
||||
Only managed containers can be started.
|
||||
"""
|
||||
docker.start_container(container_id)
|
||||
return MessageResponse(message="Container started", container_id=container_id)
|
||||
|
||||
|
||||
@router.post("/{container_id}/stop", response_model=MessageResponse)
|
||||
async def stop_container(
|
||||
container_id: str,
|
||||
timeout: int = Query(10, ge=0, description="Seconds to wait before killing"),
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
Stop a running container.
|
||||
|
||||
Only managed containers can be stopped.
|
||||
"""
|
||||
docker.stop_container(container_id, timeout=timeout)
|
||||
return MessageResponse(message="Container stopped", container_id=container_id)
|
||||
|
||||
|
||||
@router.post("/{container_id}/restart", response_model=MessageResponse)
|
||||
async def restart_container(
|
||||
container_id: str,
|
||||
timeout: int = Query(10, ge=0, description="Seconds to wait before killing"),
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
Restart a container.
|
||||
|
||||
Only managed containers can be restarted.
|
||||
"""
|
||||
docker.restart_container(container_id, timeout=timeout)
|
||||
return MessageResponse(message="Container restarted", container_id=container_id)
|
||||
|
||||
|
||||
@router.delete("/{container_id}", response_model=MessageResponse)
|
||||
async def remove_container(
|
||||
container_id: str,
|
||||
force: bool = Query(False, description="Force removal of running container"),
|
||||
v: bool = Query(False, description="Remove associated volumes"),
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
Remove a container.
|
||||
|
||||
Only managed containers can be removed.
|
||||
"""
|
||||
docker.remove_container(container_id, force=force, v=v)
|
||||
return MessageResponse(message="Container removed", container_id=container_id)
|
||||
|
||||
|
||||
@router.get("/{container_id}/logs", response_model=ContainerLogsResponse)
|
||||
async def get_container_logs(
|
||||
container_id: str,
|
||||
stdout: bool = Query(True, description="Include stdout"),
|
||||
stderr: bool = Query(True, description="Include stderr"),
|
||||
tail: int = Query(100, ge=0, description="Number of lines from end (0 for all)"),
|
||||
since: Optional[int] = Query(None, description="Unix timestamp to start from"),
|
||||
until: Optional[int] = Query(None, description="Unix timestamp to end at"),
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> ContainerLogsResponse:
|
||||
"""
|
||||
Get logs from a container.
|
||||
|
||||
Only managed containers can have their logs retrieved.
|
||||
"""
|
||||
tail_value = "all" if tail == 0 else tail
|
||||
return docker.get_logs(
|
||||
container_id,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
tail=tail_value,
|
||||
since=since,
|
||||
until=until,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{container_id}/stats", response_model=ContainerStatsResponse)
|
||||
async def get_container_stats(
|
||||
container_id: str,
|
||||
docker: DockerManager = Depends(get_docker_manager),
|
||||
) -> ContainerStatsResponse:
|
||||
"""
|
||||
Get resource usage statistics for a container.
|
||||
|
||||
Returns CPU, memory, and network statistics.
|
||||
Only managed containers can have their stats retrieved.
|
||||
"""
|
||||
return docker.get_stats(container_id)
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
"""Security validation for container creation requests."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import settings
|
||||
from app.models import ContainerCreate
|
||||
|
||||
|
||||
class SecurityValidationError(HTTPException):
|
||||
"""Exception raised when security validation fails."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Security validation failed: {message}",
|
||||
)
|
||||
|
||||
|
||||
def validate_image(image: str) -> None:
|
||||
"""Validate that the image is allowed."""
|
||||
if not settings.allowed_images:
|
||||
return # All images allowed if list is empty
|
||||
|
||||
# Check if image matches any allowed pattern
|
||||
for allowed in settings.allowed_images:
|
||||
if image == allowed or image.startswith(f"{allowed}:"):
|
||||
return
|
||||
|
||||
raise SecurityValidationError(
|
||||
f"Image '{image}' is not in the allowed images list"
|
||||
)
|
||||
|
||||
|
||||
def validate_volume_path(host_path: str) -> None:
|
||||
"""Validate that a volume mount path is safe."""
|
||||
# Normalize the path
|
||||
try:
|
||||
normalized = Path(host_path).resolve()
|
||||
except Exception:
|
||||
raise SecurityValidationError(f"Invalid volume path: {host_path}")
|
||||
|
||||
# Check against blocked paths
|
||||
normalized_str = str(normalized)
|
||||
for blocked in settings.blocked_volume_paths:
|
||||
blocked_path = Path(blocked).resolve()
|
||||
# Block exact matches and subdirectories of blocked paths
|
||||
if normalized == blocked_path:
|
||||
raise SecurityValidationError(
|
||||
f"Volume mount to '{host_path}' is not allowed"
|
||||
)
|
||||
# Also block if it's trying to mount a parent of a sensitive path
|
||||
try:
|
||||
normalized.relative_to(blocked_path)
|
||||
raise SecurityValidationError(
|
||||
f"Volume mount to '{host_path}' is not allowed (overlaps with protected path)"
|
||||
)
|
||||
except ValueError:
|
||||
pass # Not a subdirectory, which is fine
|
||||
|
||||
# Block if the blocked path is a subdirectory of the mount
|
||||
try:
|
||||
blocked_path.relative_to(normalized)
|
||||
raise SecurityValidationError(
|
||||
f"Volume mount to '{host_path}' is not allowed (would expose protected paths)"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def validate_volumes(volumes: Optional[Dict[str, Any]]) -> None:
|
||||
"""Validate all volume mounts."""
|
||||
if not volumes:
|
||||
return
|
||||
|
||||
for host_path in volumes.keys():
|
||||
validate_volume_path(host_path)
|
||||
|
||||
|
||||
def validate_capabilities(cap_add: Optional[List[str]]) -> None:
|
||||
"""Validate requested capabilities."""
|
||||
if not cap_add:
|
||||
return
|
||||
|
||||
if not settings.allowed_capabilities:
|
||||
raise SecurityValidationError(
|
||||
"Adding capabilities is not allowed"
|
||||
)
|
||||
|
||||
for cap in cap_add:
|
||||
if cap.upper() not in settings.allowed_capabilities:
|
||||
raise SecurityValidationError(
|
||||
f"Capability '{cap}' is not allowed"
|
||||
)
|
||||
|
||||
|
||||
def check_dangerous_options(raw_data: Dict[str, Any]) -> None:
|
||||
"""Check for dangerous options that should never be allowed."""
|
||||
dangerous_options = {
|
||||
"privileged": "Privileged mode is not allowed",
|
||||
"network_mode": None, # Special handling
|
||||
"pid_mode": "PID mode 'host' is not allowed",
|
||||
"ipc_mode": "IPC mode 'host' is not allowed",
|
||||
"cap_add": None, # Handled separately
|
||||
"devices": "Device mappings are not allowed",
|
||||
"security_opt": "Security options are not allowed",
|
||||
"sysctls": "Sysctl settings are not allowed",
|
||||
}
|
||||
|
||||
for option, message in dangerous_options.items():
|
||||
if option not in raw_data:
|
||||
continue
|
||||
|
||||
value = raw_data[option]
|
||||
|
||||
if option == "privileged" and value is True:
|
||||
raise SecurityValidationError(message)
|
||||
|
||||
if option == "network_mode" and value == "host":
|
||||
raise SecurityValidationError("Network mode 'host' is not allowed")
|
||||
|
||||
if option == "pid_mode" and value == "host":
|
||||
raise SecurityValidationError(message)
|
||||
|
||||
if option == "ipc_mode" and value == "host":
|
||||
raise SecurityValidationError(message)
|
||||
|
||||
if option == "devices" and value:
|
||||
raise SecurityValidationError(message)
|
||||
|
||||
if option == "security_opt" and value:
|
||||
raise SecurityValidationError(message)
|
||||
|
||||
if option == "sysctls" and value:
|
||||
raise SecurityValidationError(message)
|
||||
|
||||
if option == "cap_add" and value:
|
||||
validate_capabilities(value)
|
||||
|
||||
|
||||
def validate_container_request(
|
||||
request: ContainerCreate, raw_data: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Validate a container creation request for security issues.
|
||||
|
||||
Args:
|
||||
request: The parsed ContainerCreate model
|
||||
raw_data: The raw request data (to check for extra fields)
|
||||
|
||||
Raises:
|
||||
SecurityValidationError: If validation fails
|
||||
"""
|
||||
# Validate image
|
||||
validate_image(request.image)
|
||||
|
||||
# Validate volumes
|
||||
validate_volumes(
|
||||
{k: v.model_dump() for k, v in request.volumes.items()}
|
||||
if request.volumes
|
||||
else None
|
||||
)
|
||||
|
||||
# Check for dangerous options in raw data
|
||||
if raw_data:
|
||||
check_dangerous_options(raw_data)
|
||||
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
light-docker-api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- INSTANCE_ID=light-api-1
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
docker>=7.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
Reference in New Issue
Block a user