update the cd-server
Some checks failed
Backend EC - Modal Deployment / notify-deploy (push) Has been cancelled
Backend EC - Modal Deployment / build-and-push (push) Has been cancelled
Deploy CD Server / deploy (push) Failing after 16s

This commit is contained in:
DatTT127
2026-07-19 17:48:27 +07:00
parent 6caa654389
commit 15437a5f18
13 changed files with 1012 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
WEBHOOK_SECRET=your-webhook-secret-here
AWS_ECR_REGION=us-east-1

View File

@@ -0,0 +1,4 @@
.env
__pycache__/
*.pyc
*.pyo

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -0,0 +1,3 @@
fastapi==0.135.1
uvicorn==0.41.0
pydantic==2.13.4

View File

@@ -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

View File

@@ -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