Skip to content

Commit 94e5ba1

Browse files
Brian KrafftCopilot
andcommitted
feat: Epic 6.3 - Deployment architecture (Dockerfile, config, shutdown)
- DeployConfig: frozen dataclass, env-based with validation (port, transport, log level) - GracefulShutdownHandler: signal-aware, drains in-flight tasks, timeout, idempotent - Multi-stage Dockerfile: python:3.13-slim, non-root user, HEALTHCHECK, STOPSIGNAL - .dockerignore: excludes tests, frontend, secrets, build artifacts - 26 new tests (1,910 total passed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6f214cc commit 94e5ba1

7 files changed

Lines changed: 672 additions & 5 deletions

File tree

.dockerignore

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# ============================================
2+
# Docker build context exclusions
3+
# ============================================
4+
5+
# Version control
6+
.git
7+
.gitignore
8+
9+
# Tests and development
10+
tests/
11+
.pytest_cache/
12+
__pycache__/
13+
*.pyc
14+
.coverage
15+
htmlcov/
16+
17+
# Frontend (separate build)
18+
frontend/
19+
20+
# IDE and editor
21+
.vscode/
22+
.idea/
23+
*.swp
24+
*.swo
25+
26+
# Documentation and meta
27+
*.md
28+
!README.md
29+
LICENSE
30+
RUNBOOK.yaml
31+
32+
# Environment and secrets
33+
.env
34+
.env.*
35+
!.env.example
36+
37+
# OS files
38+
.DS_Store
39+
Thumbs.db
40+
41+
# Build artifacts
42+
dist/
43+
build/
44+
*.egg-info/
45+
46+
# Logs
47+
logs/
48+
*.log

Dockerfile

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# ============================================
2+
# OpenSpace Multi-Stage Dockerfile
3+
# ============================================
4+
# Build: docker build -t openspace .
5+
# Run: docker run -p 8000:8000 --env-file .env openspace
6+
#
7+
# Environment variables (see .env.example):
8+
# OPENSPACE_MCP_HOST, OPENSPACE_MCP_PORT, OPENSPACE_MCP_TRANSPORT,
9+
# OPENSPACE_LOG_LEVEL, OPENSPACE_SHUTDOWN_TIMEOUT,
10+
# OPENSPACE_METRICS_ENABLED, OPENROUTER_API_KEY, etc.
11+
# ============================================
12+
13+
# ── Stage 1: Build ──────────────────────────────────────────────────
14+
FROM python:3.13-slim AS builder
15+
16+
WORKDIR /build
17+
18+
# Install build dependencies
19+
RUN apt-get update && \
20+
apt-get install -y --no-install-recommends gcc && \
21+
rm -rf /var/lib/apt/lists/*
22+
23+
# Copy dependency files first (layer caching)
24+
COPY pyproject.toml requirements.txt ./
25+
COPY openspace/ openspace/
26+
27+
# Install Python dependencies
28+
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt && \
29+
pip install --no-cache-dir --prefix=/install .
30+
31+
# ── Stage 2: Runtime ────────────────────────────────────────────────
32+
FROM python:3.13-slim AS runtime
33+
34+
LABEL org.opencontainers.image.title="OpenSpace" \
35+
org.opencontainers.image.description="AI Agent Framework with MCP Server" \
36+
org.opencontainers.image.source="https://github.com/Deepfreezechill/OpenSpace"
37+
38+
# Create non-root user
39+
RUN groupadd --gid 1001 openspace && \
40+
useradd --uid 1001 --gid openspace --shell /bin/bash --create-home openspace
41+
42+
# Copy installed packages from builder
43+
COPY --from=builder /install /usr/local
44+
45+
# Copy application code
46+
WORKDIR /app
47+
COPY openspace/ openspace/
48+
COPY pyproject.toml ./
49+
50+
# Create directories for runtime data
51+
RUN mkdir -p /app/skills /app/logs && \
52+
chown -R openspace:openspace /app
53+
54+
# Switch to non-root user
55+
USER openspace
56+
57+
# Default environment
58+
ENV OPENSPACE_MCP_HOST=0.0.0.0 \
59+
OPENSPACE_MCP_PORT=8000 \
60+
OPENSPACE_MCP_TRANSPORT=streamable-http \
61+
OPENSPACE_LOG_LEVEL=INFO \
62+
OPENSPACE_SHUTDOWN_TIMEOUT=30 \
63+
OPENSPACE_METRICS_ENABLED=true \
64+
OPENSPACE_SKILL_STORE_PATH=/app/skills \
65+
PYTHONUNBUFFERED=1
66+
67+
EXPOSE 8000
68+
69+
# Health check: hit the MCP health endpoint
70+
# For stdio transport, override this in docker-compose
71+
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
72+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
73+
74+
# Graceful shutdown: Docker sends SIGTERM, handler drains in-flight tasks
75+
STOPSIGNAL SIGTERM
76+
77+
ENTRYPOINT ["python", "-m", "openspace.mcp_server"]
78+
CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000"]

RUNBOOK.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
schema_version: '1.0'
22
lock:
3-
active_claim: null
4-
claimed_by: null
5-
claimed_at: null
3+
active_claim: '6.3'
4+
claimed_by: 2026-04-07-e9b4572b
5+
claimed_at: '2026-04-07T01:20:00Z'
66
review:
77
max_rounds: 3
88
required_gates:
@@ -562,8 +562,8 @@ epics:
562562
- id: '6.3'
563563
phase: P6
564564
title: Deployment architecture
565-
status: pending
566-
branch: null
565+
status: claimed
566+
branch: epic/6.3-deployment
567567
pr: null
568568
review_round: 0
569569
description: 'Containerization (Dockerfile), config management (env-based), graceful

openspace/deploy/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Deployment utilities for OpenSpace.
2+
3+
Provides centralized configuration, graceful shutdown, and container
4+
readiness for production deployments.
5+
"""

openspace/deploy/config.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Centralized environment-based configuration for OpenSpace deployments.
2+
3+
All deployment-relevant settings are read from environment variables with
4+
sensible defaults. Configuration is frozen (immutable) after creation.
5+
6+
Environment variables:
7+
OPENSPACE_MCP_HOST MCP server bind address (default: 0.0.0.0)
8+
OPENSPACE_MCP_PORT MCP server port (default: 8000)
9+
OPENSPACE_MCP_TRANSPORT Transport: stdio|sse|streamable-http (default: stdio)
10+
OPENSPACE_LOG_LEVEL Log level: DEBUG|INFO|WARNING|ERROR (default: INFO)
11+
OPENSPACE_SHUTDOWN_TIMEOUT Graceful shutdown timeout in seconds (default: 30)
12+
OPENSPACE_METRICS_ENABLED Enable Prometheus metrics (default: true)
13+
OPENSPACE_SKILL_STORE_PATH Path to skill store directory (default: skills/)
14+
OPENSPACE_DEBUG Enable debug mode (default: false)
15+
16+
Usage::
17+
18+
from openspace.deploy.config import DeployConfig
19+
20+
cfg = DeployConfig.from_env()
21+
print(cfg.mcp_port) # 8000
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import os
27+
from dataclasses import dataclass
28+
from typing import Any, Dict
29+
30+
_VALID_TRANSPORTS = ("stdio", "sse", "streamable-http")
31+
_VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
32+
33+
34+
@dataclass(frozen=True)
35+
class DeployConfig:
36+
"""Immutable deployment configuration.
37+
38+
All values have sane defaults and can be overridden via environment
39+
variables using :meth:`from_env`.
40+
"""
41+
42+
mcp_host: str = "0.0.0.0"
43+
mcp_port: int = 8000
44+
mcp_transport: str = "stdio"
45+
log_level: str = "INFO"
46+
shutdown_timeout: int = 30
47+
metrics_enabled: bool = True
48+
skill_store_path: str = "skills/"
49+
debug: bool = False
50+
51+
def __post_init__(self) -> None:
52+
if not (1 <= self.mcp_port <= 65535):
53+
raise ValueError(
54+
f"port must be 1-65535, got {self.mcp_port}"
55+
)
56+
if self.mcp_transport not in _VALID_TRANSPORTS:
57+
raise ValueError(
58+
f"transport must be one of {_VALID_TRANSPORTS}, "
59+
f"got {self.mcp_transport!r}"
60+
)
61+
if self.log_level.upper() not in _VALID_LOG_LEVELS:
62+
raise ValueError(
63+
f"log_level must be one of {_VALID_LOG_LEVELS}, "
64+
f"got {self.log_level!r}"
65+
)
66+
if self.shutdown_timeout < 0:
67+
raise ValueError(
68+
f"shutdown_timeout must be non-negative, got {self.shutdown_timeout}"
69+
)
70+
71+
@classmethod
72+
def from_env(cls) -> DeployConfig:
73+
"""Create config from environment variables."""
74+
return cls(
75+
mcp_host=os.environ.get("OPENSPACE_MCP_HOST", "0.0.0.0"),
76+
mcp_port=int(os.environ.get("OPENSPACE_MCP_PORT", "8000")),
77+
mcp_transport=os.environ.get("OPENSPACE_MCP_TRANSPORT", "stdio"),
78+
log_level=os.environ.get("OPENSPACE_LOG_LEVEL", "INFO").upper(),
79+
shutdown_timeout=int(
80+
os.environ.get("OPENSPACE_SHUTDOWN_TIMEOUT", "30")
81+
),
82+
metrics_enabled=os.environ.get(
83+
"OPENSPACE_METRICS_ENABLED", "true"
84+
).lower()
85+
in ("true", "1", "yes"),
86+
skill_store_path=os.environ.get(
87+
"OPENSPACE_SKILL_STORE_PATH", "skills/"
88+
),
89+
debug=os.environ.get("OPENSPACE_DEBUG", "false").lower()
90+
in ("true", "1", "yes"),
91+
)
92+
93+
def to_safe_dict(self) -> Dict[str, Any]:
94+
"""Serialize config without secrets.
95+
96+
Returns only deployment-relevant fields. API keys, tokens,
97+
and other secrets are never included.
98+
"""
99+
return {
100+
"mcp_host": self.mcp_host,
101+
"mcp_port": self.mcp_port,
102+
"mcp_transport": self.mcp_transport,
103+
"log_level": self.log_level,
104+
"shutdown_timeout": self.shutdown_timeout,
105+
"metrics_enabled": self.metrics_enabled,
106+
"skill_store_path": self.skill_store_path,
107+
"debug": self.debug,
108+
}

0 commit comments

Comments
 (0)