Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# ============================================
# Docker build context exclusions
# ============================================

# Version control
.git
.gitignore

# Tests and development
tests/
.pytest_cache/
__pycache__/
*.pyc
.coverage
htmlcov/
coverage.xml

# Frontend (separate build)
frontend/

# IDE and editor
.vscode/
.idea/
*.swp
*.swo

# Documentation and meta
*.md
!README.md
LICENSE
RUNBOOK.yaml
docs/
showcase/

# Environment and secrets
.env
.env.*
!.env.example
*.key
*.pem
*.p12
*.pfx
*.crt

# OS files
.DS_Store
Thumbs.db

# Build artifacts
dist/
build/
*.egg-info/
scion_skills.egg-info/

# Caches
.mypy_cache/
.ruff_cache/

# Benchmarks and external
gdpval_bench/
scion/

# Local state
.openspace/

# Logs
logs/
*.log

# PR artifacts
pr-*-diff*.txt
docker-compose*.yml
83 changes: 83 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ============================================
# OpenSpace Multi-Stage Dockerfile
# ============================================
# Build: docker build -t openspace .
# Run: docker run -p 8000:8000 --env-file .env openspace
#
# Environment variables (see .env.example):
# OPENSPACE_MCP_HOST, OPENSPACE_MCP_PORT, OPENSPACE_MCP_TRANSPORT,
# OPENSPACE_LOG_LEVEL, OPENSPACE_SHUTDOWN_TIMEOUT,
# OPENSPACE_METRICS_ENABLED, OPENROUTER_API_KEY, etc.
# ============================================

# ── Stage 1: Build ──────────────────────────────────────────────────
FROM python:3.13-slim AS builder

WORKDIR /build

# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc && \
rm -rf /var/lib/apt/lists/*

# Copy dependency files first (layer caching — code changes don't bust this)
COPY pyproject.toml requirements.txt ./

# Install Python dependencies (cached unless requirements change)
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Copy application code (separate layer — changes more often)
COPY openspace/ openspace/

# Install the package itself
RUN pip install --no-cache-dir --prefix=/install --no-deps .

# ── Stage 2: Runtime ────────────────────────────────────────────────
FROM python:3.13-slim AS runtime

LABEL org.opencontainers.image.title="OpenSpace" \
org.opencontainers.image.description="AI Agent Framework with MCP Server" \
org.opencontainers.image.source="https://github.com/Deepfreezechill/OpenSpace"

# Create non-root user
RUN groupadd --gid 1001 openspace && \
useradd --uid 1001 --gid openspace --shell /bin/bash --create-home openspace

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application code
WORKDIR /app
COPY openspace/ openspace/
COPY pyproject.toml ./

# Create directories for runtime data
RUN mkdir -p /app/skills /app/logs && \
chown -R openspace:openspace /app

# Switch to non-root user
USER openspace

# Default environment
ENV OPENSPACE_MCP_HOST=0.0.0.0 \
OPENSPACE_MCP_PORT=8000 \
OPENSPACE_MCP_TRANSPORT=streamable-http \
OPENSPACE_LOG_LEVEL=INFO \
OPENSPACE_SHUTDOWN_TIMEOUT=8 \
OPENSPACE_METRICS_ENABLED=true \
OPENSPACE_SKILL_STORE_PATH=/app/skills \
PYTHONUNBUFFERED=1

EXPOSE 8000

# Health check: hits the real /health endpoint (unauthenticated)
# Default transport is streamable-http, so HTTP check works.
# For stdio transport, override with HEALTHCHECK NONE in docker-compose.
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

# Graceful shutdown: Docker sends SIGTERM, handler drains in-flight tasks
STOPSIGNAL SIGTERM

ENTRYPOINT ["python", "-m", "openspace.mcp_server"]
CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000"]
10 changes: 5 additions & 5 deletions RUNBOOK.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
schema_version: '1.0'
lock:
active_claim: null
claimed_by: null
claimed_at: null
active_claim: '6.3'
claimed_by: 2026-04-07-e9b4572b
claimed_at: '2026-04-07T01:20:00Z'
review:
max_rounds: 3
required_gates:
Expand Down Expand Up @@ -562,8 +562,8 @@ epics:
- id: '6.3'
phase: P6
title: Deployment architecture
status: pending
branch: null
status: claimed
branch: epic/6.3-deployment
pr: null
review_round: 0
description: 'Containerization (Dockerfile), config management (env-based), graceful
Expand Down
5 changes: 5 additions & 0 deletions openspace/deploy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Deployment utilities for OpenSpace.

Provides centralized configuration, graceful shutdown, and container
readiness for production deployments.
"""
127 changes: 127 additions & 0 deletions openspace/deploy/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Centralized environment-based configuration for OpenSpace deployments.

All deployment-relevant settings are read from environment variables with
sensible defaults. Configuration is frozen (immutable) after creation.

Environment variables:
OPENSPACE_MCP_HOST MCP server bind address (default: 0.0.0.0)
OPENSPACE_MCP_PORT MCP server port (default: 8000)
OPENSPACE_MCP_TRANSPORT Transport: stdio|sse|streamable-http (default: stdio)
OPENSPACE_LOG_LEVEL Log level: DEBUG|INFO|WARNING|ERROR (default: INFO)
OPENSPACE_SHUTDOWN_TIMEOUT Graceful shutdown timeout in seconds (default: 30)
OPENSPACE_METRICS_ENABLED Enable Prometheus metrics (default: true)
OPENSPACE_SKILL_STORE_PATH Path to skill store directory (default: skills/)
OPENSPACE_DEBUG Enable debug mode (default: false)

Usage::

from openspace.deploy.config import DeployConfig

cfg = DeployConfig.from_env()
print(cfg.mcp_port) # 8000
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Any, Dict

_VALID_TRANSPORTS = ("stdio", "sse", "streamable-http")
_VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")


def _int_env(name: str, default: int) -> int:
"""Parse an integer environment variable with graceful fallback."""
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
raise ValueError(
f"Environment variable {name}={raw!r} is not a valid integer"
) from None


@dataclass(frozen=True)
class DeployConfig:
"""Immutable deployment configuration.

All values have sane defaults and can be overridden via environment
variables using :meth:`from_env`.
"""

mcp_host: str = "0.0.0.0"
mcp_port: int = 8000
mcp_transport: str = "stdio"
log_level: str = "INFO"
shutdown_timeout: int = 30
metrics_enabled: bool = True
skill_store_path: str = "skills/"
debug: bool = False

def __post_init__(self) -> None:
if not (1 <= self.mcp_port <= 65535):
raise ValueError(
f"port must be 1-65535, got {self.mcp_port}"
)
if self.mcp_transport not in _VALID_TRANSPORTS:
raise ValueError(
f"transport must be one of {_VALID_TRANSPORTS}, "
f"got {self.mcp_transport!r}"
)
# Normalize log_level to uppercase
normalized = self.log_level.upper()
if normalized not in _VALID_LOG_LEVELS:
raise ValueError(
f"log_level must be one of {_VALID_LOG_LEVELS}, "
f"got {self.log_level!r}"
)
if normalized != self.log_level:
object.__setattr__(self, "log_level", normalized)
if self.shutdown_timeout < 1:
raise ValueError(
f"shutdown_timeout must be >= 1, got {self.shutdown_timeout}"
)

@classmethod
def from_env(cls) -> DeployConfig:
"""Create config from environment variables.

Handles empty strings, whitespace, and non-integer values
gracefully with descriptive error messages.
"""
return cls(
mcp_host=os.environ.get("OPENSPACE_MCP_HOST", "0.0.0.0").strip() or "0.0.0.0",
mcp_port=_int_env("OPENSPACE_MCP_PORT", 8000),
mcp_transport=os.environ.get("OPENSPACE_MCP_TRANSPORT", "stdio").strip() or "stdio",
log_level=(os.environ.get("OPENSPACE_LOG_LEVEL", "INFO").strip() or "INFO").upper(),
shutdown_timeout=_int_env("OPENSPACE_SHUTDOWN_TIMEOUT", 30),
metrics_enabled=os.environ.get(
"OPENSPACE_METRICS_ENABLED", "true"
).strip().lower()
in ("true", "1", "yes"),
skill_store_path=os.environ.get(
"OPENSPACE_SKILL_STORE_PATH", "skills/"
).strip() or "skills/",
debug=os.environ.get("OPENSPACE_DEBUG", "false").strip().lower()
in ("true", "1", "yes"),
)

def to_safe_dict(self) -> Dict[str, Any]:
"""Serialize config without secrets.

Returns only deployment-relevant fields. API keys, tokens,
and other secrets are never included.
"""
return {
"mcp_host": self.mcp_host,
"mcp_port": self.mcp_port,
"mcp_transport": self.mcp_transport,
"log_level": self.log_level,
"shutdown_timeout": self.shutdown_timeout,
"metrics_enabled": self.metrics_enabled,
"skill_store_path": self.skill_store_path,
"debug": self.debug,
}
Loading
Loading