update the cd-server
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
WEBHOOK_SECRET=your-webhook-secret-here
|
||||
AWS_ECR_REGION=us-east-1
|
||||
4
workspace/sprint_1_2/CODEBASE/deps/implementation/CD_server/.gitignore
vendored
Normal file
4
workspace/sprint_1_2/CODEBASE/deps/implementation/CD_server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py config.py docker_ops.py schemas.py config.yaml ./
|
||||
|
||||
RUN groupadd -g 998 docker || true
|
||||
RUN useradd -m -u 998 -g docker appuser
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 3333
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class Timeouts:
|
||||
request_read: int = 60
|
||||
ecr_login: int = 30
|
||||
docker_pull: int = 600
|
||||
docker_stop: int = 30
|
||||
docker_rm: int = 10
|
||||
docker_run: int = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
listen_address: str = "127.0.0.1"
|
||||
listen_port: int = 3333
|
||||
webhook_secret: str = ""
|
||||
aws_region: str = ""
|
||||
service_port_map: dict[str, dict[str, int]] = field(default_factory=dict)
|
||||
timeouts: Timeouts = field(default_factory=Timeouts)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ServerConfig":
|
||||
secret = os.environ.get("WEBHOOK_SECRET", "")
|
||||
region = os.environ.get("AWS_ECR_REGION", "")
|
||||
if not secret:
|
||||
raise RuntimeError("WEBHOOK_SECRET environment variable must be set")
|
||||
if not region:
|
||||
raise RuntimeError("AWS_ECR_REGION environment variable must be set")
|
||||
return cls(webhook_secret=secret, aws_region=region)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str) -> "ServerConfig":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
timeouts_data = data.pop("timeouts", {})
|
||||
timeouts = Timeouts(**timeouts_data)
|
||||
# Only non-sensitive fields come from config file
|
||||
config = cls(
|
||||
listen_address=data.get("listen_address", "127.0.0.1"),
|
||||
listen_port=data.get("listen_port", 3333),
|
||||
service_port_map=data.get("service_port_map", {}),
|
||||
timeouts=timeouts,
|
||||
)
|
||||
# Secrets always from environment — never from config file
|
||||
config.webhook_secret = os.environ.get("WEBHOOK_SECRET", "")
|
||||
config.aws_region = os.environ.get("AWS_ECR_REGION", "")
|
||||
if not config.webhook_secret:
|
||||
raise RuntimeError("WEBHOOK_SECRET must be set in environment")
|
||||
if not config.aws_region:
|
||||
raise RuntimeError("AWS_ECR_REGION must be set in environment")
|
||||
return config
|
||||
@@ -0,0 +1,16 @@
|
||||
listen_address: "127.0.0.1"
|
||||
listen_port: 3333
|
||||
service_port_map:
|
||||
cv-inference-server:
|
||||
host: 8001
|
||||
container: 8001
|
||||
api-gateway:
|
||||
host: 8080
|
||||
container: 8080
|
||||
timeouts:
|
||||
request_read: 60
|
||||
ecr_login: 30
|
||||
docker_pull: 600
|
||||
docker_stop: 30
|
||||
docker_rm: 10
|
||||
docker_run: 60
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
cd-server:
|
||||
build: .
|
||||
container_name: cd-server
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- CD_SERVER_CONFIG=/app/config.yaml
|
||||
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
import os
|
||||
from config import ServerConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DockerError(Exception):
|
||||
def __init__(self, message: str, operation: str):
|
||||
super().__init__(message)
|
||||
self.operation = operation
|
||||
self.message = message
|
||||
|
||||
|
||||
def run_command(cmd: list[str], timeout: int, env: dict[str, str] | None = None) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env={**os.environ, **(env or {})},
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
raise DockerError(stderr, " ".join(cmd))
|
||||
return result.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
raise DockerError(f"Command timed out after {timeout}s", " ".join(cmd))
|
||||
|
||||
|
||||
def ecr_login(region: str, registry: str) -> None:
|
||||
if registry == "public.ecr.aws":
|
||||
logger.info("Public ECR registry detected, skipping ECR login")
|
||||
return
|
||||
account_id = registry.split(".")[0]
|
||||
ecr_url = f"{account_id}.dkr.ecr.{region}.amazonaws.com"
|
||||
login_cmd = [
|
||||
"sh", "-c",
|
||||
f"aws ecr get-login-password --region {region} | "
|
||||
f"docker login --username AWS --password-stdin {ecr_url}"
|
||||
]
|
||||
run_command(login_cmd, timeout=30)
|
||||
logger.info("ECR login successful for %s", ecr_url)
|
||||
|
||||
|
||||
def parse_image(image: str) -> tuple[str, str]:
|
||||
parts = image.rsplit(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise DockerError(f"Invalid image format: {image}", "parse_image")
|
||||
return parts[0], parts[1]
|
||||
|
||||
|
||||
def is_public_ecr(image: str) -> bool:
|
||||
return image.startswith("public.ecr.aws/")
|
||||
|
||||
|
||||
def pull_image(image: str, timeout: int) -> None:
|
||||
run_command(["docker", "pull", image], timeout=timeout)
|
||||
logger.info("Image pulled: %s", image)
|
||||
|
||||
|
||||
def find_running_container(service_name: str) -> str | None:
|
||||
output = run_command(
|
||||
["docker", "ps", "--filter", f"name={service_name}", "--filter", "status=running", "--format", "{{.ID}}"],
|
||||
timeout=10,
|
||||
)
|
||||
return output if output else None
|
||||
|
||||
|
||||
def stop_container(service_name: str, timeout: int) -> None:
|
||||
container_id = find_running_container(service_name)
|
||||
if not container_id:
|
||||
logger.info("No running container found for %s, skipping teardown", service_name)
|
||||
return
|
||||
logger.info("Stopping container %s (%s)", service_name, container_id)
|
||||
try:
|
||||
run_command(["docker", "stop", service_name], timeout=timeout)
|
||||
except DockerError:
|
||||
logger.warning("Graceful stop failed for %s, force killing", service_name)
|
||||
run_command(["docker", "kill", service_name], timeout=10)
|
||||
logger.info("Removing container %s", service_name)
|
||||
run_command(["docker", "rm", service_name], timeout=10)
|
||||
|
||||
|
||||
def create_container(
|
||||
image: str,
|
||||
service_name: str,
|
||||
env_vars: dict[str, str],
|
||||
host_port: int,
|
||||
container_port: int,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
cmd = ["docker", "run", "-d", "--name", service_name, "--restart", "unless-stopped"]
|
||||
for key, val in env_vars.items():
|
||||
cmd.extend(["-e", f"{key}={val}"])
|
||||
cmd.extend(["-p", f"{host_port}:{container_port}", image])
|
||||
run_command(cmd, timeout=timeout)
|
||||
logger.info("Container %s started on port %d", service_name, host_port)
|
||||
|
||||
|
||||
def deploy_service(payload: dict[str, Any], config: ServerConfig) -> None:
|
||||
service_name = payload["service"]
|
||||
image = payload["image"]
|
||||
env_vars = payload.get("env") or {}
|
||||
timeouts = config.timeouts
|
||||
|
||||
if service_name not in config.service_port_map:
|
||||
raise DockerError(f"Service '{service_name}' not found in port map", "port_map_lookup")
|
||||
port_map = config.service_port_map[service_name]
|
||||
host_port = port_map["host"]
|
||||
container_port = port_map["container"]
|
||||
|
||||
registry = image.split("/")[0] if "/" in image else ""
|
||||
ecr_login(config.aws_region, registry)
|
||||
pull_image(image, timeouts.docker_pull)
|
||||
stop_container(service_name, timeouts.docker_stop)
|
||||
create_container(image, service_name, env_vars, host_port, container_port, timeouts.docker_run)
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CD Server (Deployment Server)
|
||||
FastAPI-based webhook listener for automated Docker container deployments.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
import config
|
||||
import docker_ops
|
||||
from schemas import DeployPayload
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
)
|
||||
logger = logging.getLogger("cd_server")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
app = FastAPI(title="CD Server", version="1.0.0")
|
||||
|
||||
|
||||
def log_json(level: str, **kwargs: Any) -> None:
|
||||
entry = {
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"level": level,
|
||||
**kwargs,
|
||||
}
|
||||
log_line = json.dumps(entry, ensure_ascii=False)
|
||||
if level == "error":
|
||||
logger.error(log_line)
|
||||
elif level == "warning":
|
||||
logger.warning(log_line)
|
||||
else:
|
||||
logger.info(log_line)
|
||||
|
||||
|
||||
def load_config() -> config.ServerConfig:
|
||||
config_path = Path(__file__).parent / "config.yaml"
|
||||
if config_path.exists():
|
||||
return config.ServerConfig.from_file(str(config_path))
|
||||
return config.ServerConfig.from_env()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
return JSONResponse({"status": "healthy"})
|
||||
|
||||
|
||||
@app.post("/deploy")
|
||||
async def deploy(request: Request) -> Response:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
body_bytes = await request.body()
|
||||
|
||||
if len(body_bytes) > 64 * 1024:
|
||||
log_json("warning", source_ip=client_ip, path="/deploy", method="POST", outcome="body_too_large", status_code=400, message="Request body exceeds 64 KB limit")
|
||||
return JSONResponse({"error": "bad_request", "message": "Request body too large"}, status_code=400)
|
||||
|
||||
cfg = request.app.state.config
|
||||
|
||||
signature = request.headers.get("X-Gitea-Signature", "")
|
||||
if not verify_signature(body_bytes, signature, cfg.webhook_secret):
|
||||
log_json("warning", source_ip=client_ip, path="/deploy", method="POST", outcome="signature_failed", status_code=401, message="Invalid signature")
|
||||
return JSONResponse({"error": "unauthorized", "message": "Invalid signature"}, status_code=401)
|
||||
|
||||
try:
|
||||
payload = DeployPayload.model_validate_json(body_bytes)
|
||||
except ValidationError as e:
|
||||
log_json("warning", source_ip=client_ip, path="/deploy", method="POST", outcome="validation_failed", status_code=400, message=str(e))
|
||||
return JSONResponse({"error": "bad_request", "message": "Invalid payload"}, status_code=400)
|
||||
|
||||
service = payload.service
|
||||
image = payload.image
|
||||
env = payload.env or {}
|
||||
log_json("info", source_ip=client_ip, path="/deploy", method="POST", outcome="deployment_initiated", status_code=202, message="Deployment initiated", service=service, image=image)
|
||||
|
||||
try:
|
||||
docker_ops.deploy_service(payload.model_dump(), cfg)
|
||||
except docker_ops.DockerError as e:
|
||||
log_json("error", source_ip=client_ip, path="/deploy", method="POST", outcome="deployment_failed", status_code=500, message=e.message, service=service, image=image)
|
||||
return JSONResponse({"error": "deployment_failed", "message": e.message}, status_code=500)
|
||||
except Exception as e:
|
||||
log_json("error", source_ip=client_ip, path="/deploy", method="POST", outcome="deployment_failed", status_code=500, message=str(e), service=service, image=image)
|
||||
return JSONResponse({"error": "deployment_failed", "message": str(e)}, status_code=500)
|
||||
|
||||
log_json("info", source_ip=client_ip, path="/deploy", method="POST", outcome="deployment_success", status_code=200, message="Deployment completed", service=service, image=image)
|
||||
return JSONResponse({"status": "success", "service": service})
|
||||
|
||||
|
||||
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
|
||||
if not secret:
|
||||
logger.warning("WEBHOOK_SECRET is empty")
|
||||
return False
|
||||
import hmac
|
||||
import hashlib
|
||||
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def limit_request_size(request: Request, call_next):
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > 64 * 1024:
|
||||
return JSONResponse({"error": "bad_request", "message": "Request body too large"}, status_code=400)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
cfg = load_config()
|
||||
except RuntimeError as e:
|
||||
logger.error("Configuration error: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
app.state.config = cfg
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=cfg.listen_address,
|
||||
port=cfg.listen_port,
|
||||
log_config=None,
|
||||
access_log=False,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.135.1
|
||||
uvicorn==0.41.0
|
||||
pydantic==2.13.4
|
||||
@@ -0,0 +1,41 @@
|
||||
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||
import re
|
||||
|
||||
|
||||
class DeployPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
service: str = Field(..., min_length=1, max_length=63)
|
||||
image: str = Field(..., min_length=1)
|
||||
env: dict[str, str] | None = None
|
||||
|
||||
@field_validator("service")
|
||||
@classmethod
|
||||
def validate_service(cls, v: str) -> str:
|
||||
if not re.match(r"^[a-zA-Z0-9_-]{1,63}$", v):
|
||||
raise ValueError("service must be 1-63 chars: alphanumeric, hyphens, underscores only")
|
||||
return v
|
||||
|
||||
@field_validator("image")
|
||||
@classmethod
|
||||
def validate_image(cls, v: str) -> str:
|
||||
if ":" not in v:
|
||||
raise ValueError("image must contain a tag separated by ':'")
|
||||
parts = v.rsplit(":", 1)
|
||||
if not parts[0] or not parts[1]:
|
||||
raise ValueError("image must have non-empty registry/repository and tag")
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", parts[0]):
|
||||
raise ValueError("image repository contains invalid characters")
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", parts[1]):
|
||||
raise ValueError("image tag contains invalid characters")
|
||||
return v
|
||||
|
||||
@field_validator("env")
|
||||
@classmethod
|
||||
def validate_env(cls, v: dict[str, str] | None) -> dict[str, str] | None:
|
||||
if v is None:
|
||||
return v
|
||||
for key, val in v.items():
|
||||
if not isinstance(key, str) or not isinstance(val, str):
|
||||
raise ValueError("env keys and values must be strings")
|
||||
return v
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=CD Server (Deployment Server)
|
||||
After=docker.service network.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
Group=docker
|
||||
WorkingDirectory=/opt/cd-server
|
||||
ExecStart=/usr/bin/docker compose up
|
||||
ExecStop=/usr/bin/docker compose down
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/cd-server/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user