Skip to content

Commit 81af1a3

Browse files
DeepfreezechillBrian KrafftCopilot
authored
Epic 6.3: Deployment Architecture - config, shutdown, Dockerfile (#52)
* 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> * fix: address R1 review findings for Epic 6.3 deployment - Expand .dockerignore: add docs/, showcase/, crypto patterns, caches - Add 13 new adversarial tests (39 total): port boundaries, boolean parsing, non-integer env, empty env, task exception during drain, auto-cancel during shutdown, zero hooks/tasks, timeout minimum, Dockerfile instruction checks, wiring verification for DeployConfig, /health route, shutdown handler - Verify config log_level normalization (lowercase → UPPER) - Verify shutdown timeout minimum (0 rejected, -1 rejected) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address R2 review findings - shutdown wiring, /health auth bypass, HEALTHCHECK - Wire GracefulShutdownHandler into uvicorn lifecycle via serve_with_shutdown() with finally block guaranteeing shutdown runs after server.serve() - Move /health endpoint OUTSIDE BearerTokenMiddleware for K8s probe access - Set uvicorn timeout_graceful_shutdown to half of shutdown budget - Change Dockerfile OPENSPACE_SHUTDOWN_TIMEOUT from 30 to 8 (fits Docker 10s) - Replace HEALTHCHECK with real HTTP /health endpoint check (not false-positive import) - Add 2 new tests: /health-outside-auth, shutdown-after-uvicorn, Docker timeout alignment - 41 deployment tests, 1925 total passed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Brian Krafft <bkrafft@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6f214cc commit 81af1a3

8 files changed

Lines changed: 940 additions & 17 deletions

File tree

.dockerignore

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
coverage.xml
17+
18+
# Frontend (separate build)
19+
frontend/
20+
21+
# IDE and editor
22+
.vscode/
23+
.idea/
24+
*.swp
25+
*.swo
26+
27+
# Documentation and meta
28+
*.md
29+
!README.md
30+
LICENSE
31+
RUNBOOK.yaml
32+
docs/
33+
showcase/
34+
35+
# Environment and secrets
36+
.env
37+
.env.*
38+
!.env.example
39+
*.key
40+
*.pem
41+
*.p12
42+
*.pfx
43+
*.crt
44+
45+
# OS files
46+
.DS_Store
47+
Thumbs.db
48+
49+
# Build artifacts
50+
dist/
51+
build/
52+
*.egg-info/
53+
scion_skills.egg-info/
54+
55+
# Caches
56+
.mypy_cache/
57+
.ruff_cache/
58+
59+
# Benchmarks and external
60+
gdpval_bench/
61+
scion/
62+
63+
# Local state
64+
.openspace/
65+
66+
# Logs
67+
logs/
68+
*.log
69+
70+
# PR artifacts
71+
pr-*-diff*.txt
72+
docker-compose*.yml

Dockerfile

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 — code changes don't bust this)
24+
COPY pyproject.toml requirements.txt ./
25+
26+
# Install Python dependencies (cached unless requirements change)
27+
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
28+
29+
# Copy application code (separate layer — changes more often)
30+
COPY openspace/ openspace/
31+
32+
# Install the package itself
33+
RUN pip install --no-cache-dir --prefix=/install --no-deps .
34+
35+
# ── Stage 2: Runtime ────────────────────────────────────────────────
36+
FROM python:3.13-slim AS runtime
37+
38+
LABEL org.opencontainers.image.title="OpenSpace" \
39+
org.opencontainers.image.description="AI Agent Framework with MCP Server" \
40+
org.opencontainers.image.source="https://github.com/Deepfreezechill/OpenSpace"
41+
42+
# Create non-root user
43+
RUN groupadd --gid 1001 openspace && \
44+
useradd --uid 1001 --gid openspace --shell /bin/bash --create-home openspace
45+
46+
# Copy installed packages from builder
47+
COPY --from=builder /install /usr/local
48+
49+
# Copy application code
50+
WORKDIR /app
51+
COPY openspace/ openspace/
52+
COPY pyproject.toml ./
53+
54+
# Create directories for runtime data
55+
RUN mkdir -p /app/skills /app/logs && \
56+
chown -R openspace:openspace /app
57+
58+
# Switch to non-root user
59+
USER openspace
60+
61+
# Default environment
62+
ENV OPENSPACE_MCP_HOST=0.0.0.0 \
63+
OPENSPACE_MCP_PORT=8000 \
64+
OPENSPACE_MCP_TRANSPORT=streamable-http \
65+
OPENSPACE_LOG_LEVEL=INFO \
66+
OPENSPACE_SHUTDOWN_TIMEOUT=8 \
67+
OPENSPACE_METRICS_ENABLED=true \
68+
OPENSPACE_SKILL_STORE_PATH=/app/skills \
69+
PYTHONUNBUFFERED=1
70+
71+
EXPOSE 8000
72+
73+
# Health check: hits the real /health endpoint (unauthenticated)
74+
# Default transport is streamable-http, so HTTP check works.
75+
# For stdio transport, override with HEALTHCHECK NONE in docker-compose.
76+
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
77+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
78+
79+
# Graceful shutdown: Docker sends SIGTERM, handler drains in-flight tasks
80+
STOPSIGNAL SIGTERM
81+
82+
ENTRYPOINT ["python", "-m", "openspace.mcp_server"]
83+
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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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+
def _int_env(name: str, default: int) -> int:
35+
"""Parse an integer environment variable with graceful fallback."""
36+
raw = os.environ.get(name, "").strip()
37+
if not raw:
38+
return default
39+
try:
40+
return int(raw)
41+
except ValueError:
42+
raise ValueError(
43+
f"Environment variable {name}={raw!r} is not a valid integer"
44+
) from None
45+
46+
47+
@dataclass(frozen=True)
48+
class DeployConfig:
49+
"""Immutable deployment configuration.
50+
51+
All values have sane defaults and can be overridden via environment
52+
variables using :meth:`from_env`.
53+
"""
54+
55+
mcp_host: str = "0.0.0.0"
56+
mcp_port: int = 8000
57+
mcp_transport: str = "stdio"
58+
log_level: str = "INFO"
59+
shutdown_timeout: int = 30
60+
metrics_enabled: bool = True
61+
skill_store_path: str = "skills/"
62+
debug: bool = False
63+
64+
def __post_init__(self) -> None:
65+
if not (1 <= self.mcp_port <= 65535):
66+
raise ValueError(
67+
f"port must be 1-65535, got {self.mcp_port}"
68+
)
69+
if self.mcp_transport not in _VALID_TRANSPORTS:
70+
raise ValueError(
71+
f"transport must be one of {_VALID_TRANSPORTS}, "
72+
f"got {self.mcp_transport!r}"
73+
)
74+
# Normalize log_level to uppercase
75+
normalized = self.log_level.upper()
76+
if normalized not in _VALID_LOG_LEVELS:
77+
raise ValueError(
78+
f"log_level must be one of {_VALID_LOG_LEVELS}, "
79+
f"got {self.log_level!r}"
80+
)
81+
if normalized != self.log_level:
82+
object.__setattr__(self, "log_level", normalized)
83+
if self.shutdown_timeout < 1:
84+
raise ValueError(
85+
f"shutdown_timeout must be >= 1, got {self.shutdown_timeout}"
86+
)
87+
88+
@classmethod
89+
def from_env(cls) -> DeployConfig:
90+
"""Create config from environment variables.
91+
92+
Handles empty strings, whitespace, and non-integer values
93+
gracefully with descriptive error messages.
94+
"""
95+
return cls(
96+
mcp_host=os.environ.get("OPENSPACE_MCP_HOST", "0.0.0.0").strip() or "0.0.0.0",
97+
mcp_port=_int_env("OPENSPACE_MCP_PORT", 8000),
98+
mcp_transport=os.environ.get("OPENSPACE_MCP_TRANSPORT", "stdio").strip() or "stdio",
99+
log_level=(os.environ.get("OPENSPACE_LOG_LEVEL", "INFO").strip() or "INFO").upper(),
100+
shutdown_timeout=_int_env("OPENSPACE_SHUTDOWN_TIMEOUT", 30),
101+
metrics_enabled=os.environ.get(
102+
"OPENSPACE_METRICS_ENABLED", "true"
103+
).strip().lower()
104+
in ("true", "1", "yes"),
105+
skill_store_path=os.environ.get(
106+
"OPENSPACE_SKILL_STORE_PATH", "skills/"
107+
).strip() or "skills/",
108+
debug=os.environ.get("OPENSPACE_DEBUG", "false").strip().lower()
109+
in ("true", "1", "yes"),
110+
)
111+
112+
def to_safe_dict(self) -> Dict[str, Any]:
113+
"""Serialize config without secrets.
114+
115+
Returns only deployment-relevant fields. API keys, tokens,
116+
and other secrets are never included.
117+
"""
118+
return {
119+
"mcp_host": self.mcp_host,
120+
"mcp_port": self.mcp_port,
121+
"mcp_transport": self.mcp_transport,
122+
"log_level": self.log_level,
123+
"shutdown_timeout": self.shutdown_timeout,
124+
"metrics_enabled": self.metrics_enabled,
125+
"skill_store_path": self.skill_store_path,
126+
"debug": self.debug,
127+
}

0 commit comments

Comments
 (0)