588 lines
14 KiB
Markdown
588 lines
14 KiB
Markdown
# Docker Service Architecture Adaptation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Adapt grist-mcp to follow the docker-service-architecture skill guidelines for better test isolation, environment separation, and CI/CD readiness.
|
|
|
|
**Architecture:** Single-service project pattern with 2-stage testing (unit → integration), environment-specific deploy configs (dev/test/prod), and branch-isolated test infrastructure.
|
|
|
|
**Tech Stack:** Docker Compose, Make, Python/pytest, bash scripts
|
|
|
|
---
|
|
|
|
## Current State Analysis
|
|
|
|
**What we have:**
|
|
- Single service (grist-mcp) with mock server for testing
|
|
- 2-stage testing: unit tests (41) + integration tests (2)
|
|
- docker-compose.test.yaml at project root
|
|
- docker-compose.yaml for production at root
|
|
- Basic Makefile with pre-deploy target
|
|
|
|
**Gaps vs. Skill Guidelines:**
|
|
|
|
| Area | Current | Skill Guideline |
|
|
|------|---------|-----------------|
|
|
| Directory structure | Flat docker-compose files at root | `deploy/{dev,test,prod}/` directories |
|
|
| Test organization | `tests/*.py` + `tests/integration/` | `tests/unit/` + `tests/integration/` |
|
|
| Port allocation | Fixed (3000, 8484) | Dynamic with discovery |
|
|
| Branch isolation | None | TEST_INSTANCE_ID from git branch |
|
|
| Container naming | Default | Instance-based (`-${TEST_INSTANCE_ID}`) |
|
|
| Test storage | Default volumes | tmpfs for ephemeral |
|
|
| depends_on | `service_started` | `service_healthy` |
|
|
|
|
---
|
|
|
|
## Task 1: Restructure Tests Directory
|
|
|
|
**Files:**
|
|
- Move: `tests/test_*.py` → `tests/unit/test_*.py`
|
|
- Keep: `tests/integration/` as-is
|
|
- Create: `tests/unit/__init__.py`
|
|
|
|
**Step 1: Create unit test directory and move files**
|
|
|
|
```bash
|
|
mkdir -p tests/unit
|
|
mv tests/test_*.py tests/unit/
|
|
touch tests/unit/__init__.py
|
|
```
|
|
|
|
**Step 2: Update pyproject.toml testpaths**
|
|
|
|
```toml
|
|
[tool.pytest.ini_options]
|
|
asyncio_mode = "auto"
|
|
testpaths = ["tests/unit", "tests/integration"]
|
|
```
|
|
|
|
**Step 3: Update Makefile test target**
|
|
|
|
```makefile
|
|
test: ## Run unit tests
|
|
uv run pytest tests/unit/ -v
|
|
```
|
|
|
|
**Step 4: Verify tests still pass**
|
|
|
|
```bash
|
|
uv run pytest tests/unit/ -v
|
|
uv run pytest tests/integration/ -v --ignore=tests/integration
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/ pyproject.toml Makefile
|
|
git commit -m "refactor: organize tests into unit/ and integration/ directories"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create Deploy Directory Structure
|
|
|
|
**Files:**
|
|
- Create: `deploy/dev/docker-compose.yml`
|
|
- Create: `deploy/dev/.env.example`
|
|
- Create: `deploy/test/docker-compose.yml`
|
|
- Create: `deploy/prod/docker-compose.yml`
|
|
- Create: `deploy/prod/.env.example`
|
|
- Delete: `docker-compose.yaml`, `docker-compose.test.yaml` (after migration)
|
|
|
|
**Step 1: Create deploy directory structure**
|
|
|
|
```bash
|
|
mkdir -p deploy/{dev,test,prod}
|
|
```
|
|
|
|
**Step 2: Create deploy/dev/docker-compose.yml**
|
|
|
|
```yaml
|
|
# Development environment - hot reload, persistent data
|
|
services:
|
|
grist-mcp:
|
|
build:
|
|
context: ../..
|
|
dockerfile: Dockerfile
|
|
ports:
|
|
- "${PORT:-3000}:3000"
|
|
volumes:
|
|
- ../../src:/app/src:ro
|
|
- ../../config.yaml:/app/config.yaml:ro
|
|
env_file:
|
|
- .env
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 60s
|
|
```
|
|
|
|
**Step 3: Create deploy/dev/.env.example**
|
|
|
|
```bash
|
|
PORT=3000
|
|
GRIST_MCP_TOKEN=your-token-here
|
|
CONFIG_PATH=/app/config.yaml
|
|
```
|
|
|
|
**Step 4: Create deploy/test/docker-compose.yml**
|
|
|
|
```yaml
|
|
# Test environment - ephemeral, branch-isolated
|
|
services:
|
|
grist-mcp:
|
|
build:
|
|
context: ../..
|
|
dockerfile: Dockerfile
|
|
container_name: grist-mcp-test-${TEST_INSTANCE_ID:-default}
|
|
ports:
|
|
- "3000" # Dynamic port
|
|
environment:
|
|
- CONFIG_PATH=/app/config.yaml
|
|
- GRIST_MCP_TOKEN=test-token
|
|
- PORT=3000
|
|
volumes:
|
|
- ../../tests/integration/config.test.yaml:/app/config.yaml:ro
|
|
depends_on:
|
|
mock-grist:
|
|
condition: service_healthy
|
|
networks:
|
|
- test-net
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 10s
|
|
|
|
mock-grist:
|
|
build:
|
|
context: ../../tests/integration/mock_grist
|
|
container_name: mock-grist-test-${TEST_INSTANCE_ID:-default}
|
|
ports:
|
|
- "8484" # Dynamic port
|
|
environment:
|
|
- PORT=8484
|
|
networks:
|
|
- test-net
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8484/health')"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 10s
|
|
|
|
networks:
|
|
test-net:
|
|
name: grist-mcp-test-${TEST_INSTANCE_ID:-default}
|
|
driver: bridge
|
|
```
|
|
|
|
**Step 5: Create deploy/prod/docker-compose.yml**
|
|
|
|
```yaml
|
|
# Production environment - resource limits, logging, restart policy
|
|
services:
|
|
grist-mcp:
|
|
build:
|
|
context: ../..
|
|
dockerfile: Dockerfile
|
|
ports:
|
|
- "${PORT:-3000}:3000"
|
|
volumes:
|
|
- ./config.yaml:/app/config.yaml:ro
|
|
env_file:
|
|
- .env
|
|
restart: unless-stopped
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
memory: 512M
|
|
cpus: "1"
|
|
reservations:
|
|
memory: 128M
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "50m"
|
|
max-file: "5"
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 60s
|
|
```
|
|
|
|
**Step 6: Create deploy/prod/.env.example**
|
|
|
|
```bash
|
|
PORT=3000
|
|
GRIST_MCP_TOKEN=your-production-token
|
|
CONFIG_PATH=/app/config.yaml
|
|
```
|
|
|
|
**Step 7: Verify test compose works**
|
|
|
|
```bash
|
|
cd deploy/test
|
|
TEST_INSTANCE_ID=manual docker compose up -d --build
|
|
docker compose ps
|
|
docker compose down -v
|
|
```
|
|
|
|
**Step 8: Remove old compose files and commit**
|
|
|
|
```bash
|
|
rm docker-compose.yaml docker-compose.test.yaml
|
|
git add deploy/
|
|
git rm docker-compose.yaml docker-compose.test.yaml
|
|
git commit -m "refactor: move docker-compose files to deploy/ directory structure"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Add Test Isolation Scripts
|
|
|
|
**Files:**
|
|
- Create: `scripts/get-test-instance-id.sh`
|
|
- Create: `scripts/run-integration-tests.sh`
|
|
|
|
**Step 1: Create scripts directory**
|
|
|
|
```bash
|
|
mkdir -p scripts
|
|
```
|
|
|
|
**Step 2: Create get-test-instance-id.sh**
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
# scripts/get-test-instance-id.sh
|
|
# Generate a unique instance ID from git branch for parallel test isolation
|
|
|
|
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
# Sanitize: replace non-alphanumeric with dash, limit length
|
|
echo "$BRANCH" | sed 's/[^a-zA-Z0-9]/-/g' | cut -c1-20
|
|
```
|
|
|
|
**Step 3: Create run-integration-tests.sh**
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
# scripts/run-integration-tests.sh
|
|
# Run integration tests with branch isolation and dynamic port discovery
|
|
set -e
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
|
|
# Get branch-based instance ID
|
|
TEST_INSTANCE_ID=$("$SCRIPT_DIR/get-test-instance-id.sh")
|
|
export TEST_INSTANCE_ID
|
|
|
|
echo "Test instance ID: $TEST_INSTANCE_ID"
|
|
|
|
# Start containers
|
|
cd "$PROJECT_ROOT/deploy/test"
|
|
docker compose up -d --build --wait
|
|
|
|
# Discover dynamic ports
|
|
GRIST_MCP_PORT=$(docker compose port grist-mcp 3000 | cut -d: -f2)
|
|
MOCK_GRIST_PORT=$(docker compose port mock-grist 8484 | cut -d: -f2)
|
|
|
|
echo "grist-mcp available at: http://localhost:$GRIST_MCP_PORT"
|
|
echo "mock-grist available at: http://localhost:$MOCK_GRIST_PORT"
|
|
|
|
# Export for tests
|
|
export GRIST_MCP_URL="http://localhost:$GRIST_MCP_PORT"
|
|
export MOCK_GRIST_URL="http://localhost:$MOCK_GRIST_PORT"
|
|
|
|
# Run tests
|
|
cd "$PROJECT_ROOT"
|
|
TEST_EXIT=0
|
|
uv run pytest tests/integration/ -v || TEST_EXIT=$?
|
|
|
|
# Cleanup
|
|
cd "$PROJECT_ROOT/deploy/test"
|
|
docker compose down -v
|
|
|
|
exit $TEST_EXIT
|
|
```
|
|
|
|
**Step 4: Make scripts executable**
|
|
|
|
```bash
|
|
chmod +x scripts/get-test-instance-id.sh
|
|
chmod +x scripts/run-integration-tests.sh
|
|
```
|
|
|
|
**Step 5: Verify scripts work**
|
|
|
|
```bash
|
|
./scripts/get-test-instance-id.sh
|
|
./scripts/run-integration-tests.sh
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add scripts/
|
|
git commit -m "feat: add test isolation scripts with dynamic port discovery"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Update Integration Tests for Dynamic Ports
|
|
|
|
**Files:**
|
|
- Modify: `tests/integration/conftest.py`
|
|
|
|
**Step 1: Update conftest.py to use environment variables**
|
|
|
|
```python
|
|
"""Fixtures for integration tests."""
|
|
|
|
import os
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
# Use environment variables for dynamic port discovery
|
|
GRIST_MCP_URL = os.environ.get("GRIST_MCP_URL", "http://localhost:3000")
|
|
MOCK_GRIST_URL = os.environ.get("MOCK_GRIST_URL", "http://localhost:8484")
|
|
MAX_WAIT_SECONDS = 30
|
|
|
|
|
|
def wait_for_service(url: str, timeout: int = MAX_WAIT_SECONDS) -> bool:
|
|
"""Wait for a service to become healthy."""
|
|
start = time.time()
|
|
while time.time() - start < timeout:
|
|
try:
|
|
response = httpx.get(f"{url}/health", timeout=2.0)
|
|
if response.status_code == 200:
|
|
return True
|
|
except httpx.RequestError:
|
|
pass
|
|
time.sleep(0.5)
|
|
return False
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def services_ready():
|
|
"""Ensure both services are healthy before running tests."""
|
|
if not wait_for_service(MOCK_GRIST_URL):
|
|
pytest.fail(f"Mock Grist server not ready at {MOCK_GRIST_URL}")
|
|
if not wait_for_service(GRIST_MCP_URL):
|
|
pytest.fail(f"grist-mcp server not ready at {GRIST_MCP_URL}")
|
|
return True
|
|
```
|
|
|
|
**Step 2: Update test files to use environment URLs**
|
|
|
|
In `tests/integration/test_mcp_protocol.py` and `tests/integration/test_tools_integration.py`:
|
|
|
|
```python
|
|
import os
|
|
|
|
GRIST_MCP_URL = os.environ.get("GRIST_MCP_URL", "http://localhost:3000")
|
|
MOCK_GRIST_URL = os.environ.get("MOCK_GRIST_URL", "http://localhost:8484")
|
|
```
|
|
|
|
**Step 3: Run tests to verify**
|
|
|
|
```bash
|
|
./scripts/run-integration-tests.sh
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add tests/integration/
|
|
git commit -m "feat: support dynamic ports via environment variables in tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Update Makefile
|
|
|
|
**Files:**
|
|
- Modify: `Makefile`
|
|
|
|
**Step 1: Rewrite Makefile with new structure**
|
|
|
|
```makefile
|
|
.PHONY: help test test-unit test-integration build dev-up dev-down integration pre-deploy clean
|
|
|
|
VERBOSE ?= 0
|
|
PYTEST_ARGS := $(if $(filter 1,$(VERBOSE)),-v,-q)
|
|
|
|
# Default target
|
|
help: ## Show this help
|
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
|
|
|
# Testing
|
|
test: test-unit ## Run all tests (unit only by default)
|
|
|
|
test-unit: ## Run unit tests
|
|
uv run pytest tests/unit/ $(PYTEST_ARGS)
|
|
|
|
test-integration: ## Run integration tests (starts/stops containers)
|
|
./scripts/run-integration-tests.sh
|
|
|
|
# Docker
|
|
build: ## Build Docker image
|
|
docker build -t grist-mcp:latest .
|
|
|
|
dev-up: ## Start development environment
|
|
cd deploy/dev && docker compose up -d --build
|
|
|
|
dev-down: ## Stop development environment
|
|
cd deploy/dev && docker compose down
|
|
|
|
# Pre-deployment
|
|
pre-deploy: test-unit test-integration ## Full pre-deployment pipeline
|
|
@echo "Pre-deployment checks passed!"
|
|
|
|
# Cleanup
|
|
clean: ## Remove test artifacts and containers
|
|
cd deploy/test && docker compose down -v --rmi local 2>/dev/null || true
|
|
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
|
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
|
|
```
|
|
|
|
**Step 2: Verify Makefile targets**
|
|
|
|
```bash
|
|
make help
|
|
make test-unit
|
|
make test-integration
|
|
make pre-deploy
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add Makefile
|
|
git commit -m "refactor: update Makefile for new deploy/ structure"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Update CLAUDE.md
|
|
|
|
**Files:**
|
|
- Modify: `CLAUDE.md`
|
|
|
|
**Step 1: Update commands section**
|
|
|
|
Add to CLAUDE.md:
|
|
|
|
```markdown
|
|
## Commands
|
|
|
|
```bash
|
|
# Run unit tests
|
|
make test-unit
|
|
# or: uv run pytest tests/unit/ -v
|
|
|
|
# Run integration tests (manages containers automatically)
|
|
make test-integration
|
|
# or: ./scripts/run-integration-tests.sh
|
|
|
|
# Full pre-deploy pipeline
|
|
make pre-deploy
|
|
|
|
# Development environment
|
|
make dev-up # Start
|
|
make dev-down # Stop
|
|
|
|
# Build Docker image
|
|
make build
|
|
```
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
src/grist_mcp/ # Source code
|
|
tests/
|
|
├── unit/ # Unit tests (no containers)
|
|
└── integration/ # Integration tests (with Docker)
|
|
deploy/
|
|
├── dev/ # Development docker-compose
|
|
├── test/ # Test docker-compose (ephemeral)
|
|
└── prod/ # Production docker-compose
|
|
scripts/ # Test automation scripts
|
|
```
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add CLAUDE.md
|
|
git commit -m "docs: update CLAUDE.md with new project structure"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Final Verification
|
|
|
|
**Step 1: Run full pre-deploy pipeline**
|
|
|
|
```bash
|
|
make pre-deploy
|
|
```
|
|
|
|
Expected output:
|
|
- Unit tests pass (41 tests)
|
|
- Integration tests pass with branch isolation
|
|
- Containers cleaned up
|
|
|
|
**Step 2: Test parallel execution (optional)**
|
|
|
|
```bash
|
|
# In terminal 1
|
|
git checkout -b test-branch-1
|
|
make test-integration &
|
|
|
|
# In terminal 2
|
|
git checkout -b test-branch-2
|
|
make test-integration &
|
|
```
|
|
|
|
Both should run without port conflicts.
|
|
|
|
**Step 3: Commit final verification**
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "chore: complete docker-service-architecture adaptation"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary of Changes
|
|
|
|
| Before | After |
|
|
|--------|-------|
|
|
| `tests/test_*.py` | `tests/unit/test_*.py` |
|
|
| `docker-compose.yaml` | `deploy/dev/docker-compose.yml` |
|
|
| `docker-compose.test.yaml` | `deploy/test/docker-compose.yml` |
|
|
| (none) | `deploy/prod/docker-compose.yml` |
|
|
| Fixed ports (3000, 8484) | Dynamic ports with discovery |
|
|
| No branch isolation | TEST_INSTANCE_ID from git branch |
|
|
| `service_started` | `service_healthy` |
|
|
| Basic Makefile | Environment-aware with VERBOSE support |
|
|
|
|
## Benefits
|
|
|
|
1. **Parallel testing** - Multiple branches can run tests simultaneously
|
|
2. **Environment parity** - Clear dev/test/prod separation
|
|
3. **CI/CD ready** - Scripts work in automated pipelines
|
|
4. **Faster feedback** - Dynamic ports eliminate conflicts
|
|
5. **Cleaner structure** - Tests and deploys clearly organized
|