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