diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f533bb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Python +.venv +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.coverage +*.egg-info/ + +# Logs +logs/ +*.log + +# Git +.git +.gitignore +.gitattributes + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Documentation +*.md +!README.md + +# CI/CD +.github/ + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml +compose.yaml + +# Misc +.env +.env.* +.DS_Store diff --git a/.github/workflows/compose-smoke.yml b/.github/workflows/compose-smoke.yml new file mode 100644 index 0000000..a3ee9f2 --- /dev/null +++ b/.github/workflows/compose-smoke.yml @@ -0,0 +1,55 @@ +name: Compose Smoke + +on: + pull_request: + paths: + - 'services/**' + - 'dev/compose/**' + - 'compose.yaml' + - '.github/workflows/compose-smoke.yml' + +jobs: + compose: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Validate compose configuration + run: docker compose -f dev/compose/docker-compose.yml config + + - name: Build services + run: docker compose -f dev/compose/docker-compose.yml build + + - name: Start services + run: docker compose -f dev/compose/docker-compose.yml up -d + + - name: Wait for services to be healthy + run: | + echo "Waiting for services to be healthy..." + timeout 120 bash -c 'until [ $(docker compose -f dev/compose/docker-compose.yml ps | grep -c "healthy") -eq 2 ]; do echo "Waiting..."; sleep 3; done' + echo "Both services are healthy" + docker compose -f dev/compose/docker-compose.yml ps + + - name: Check orchestrator-ren health + run: | + echo "Testing orchestrator-ren /healthz endpoint..." + curl -f http://localhost:8000/healthz + + - name: Check actuator-bus health + run: | + echo "Testing actuator-bus /healthz endpoint..." + curl -f http://localhost:8010/healthz + + - name: Show logs on failure + if: failure() + run: docker compose -f dev/compose/docker-compose.yml logs + + - name: Cleanup + if: always() + run: docker compose -f dev/compose/docker-compose.yml down diff --git a/.gitignore b/.gitignore index b739a3c..a2b45ef 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ build/ .ipynb_checkpoints/ datasets/raw/ artifacts/ +logs/ diff --git a/Makefile b/Makefile index 759379e..2da0719 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,18 @@ -.PHONY: help venv validate test clean +.PHONY: help venv validate test clean up down logs compose-test compose-validate help: @echo "Available targets:" - @echo " make venv - Create Python virtual environment and install dependencies" - @echo " make validate - Run contract validator with golden checks" - @echo " make test - Run all pytest tests" - @echo " make clean - Remove virtual environment and cache files" + @echo " make venv - Create Python virtual environment and install dependencies" + @echo " make validate - Run contract validator with golden checks" + @echo " make test - Run all pytest tests" + @echo " make clean - Remove virtual environment and cache files" + @echo "" + @echo "Docker Compose targets:" + @echo " make up - Start dev stack (orchestrator + actuator)" + @echo " make down - Stop and remove dev stack containers" + @echo " make logs - Follow logs from all containers" + @echo " make compose-test - Run smoke test against running containers" + @echo " make compose-validate - Validate docker-compose configuration" venv: python -m venv .venv @@ -22,3 +29,26 @@ clean: if exist .venv rmdir /s /q .venv for /d /r %%i in (__pycache__) do @if exist "%%i" rmdir /s /q "%%i" for /d /r %%i in (.pytest_cache) do @if exist "%%i" rmdir /s /q "%%i" + +# ============================================================================ +# Docker Compose Targets +# ============================================================================ + +up: + cd dev/compose && docker compose up -d --build + +down: + cd dev/compose && docker compose down + +logs: + cd dev/compose && docker compose logs -f + +compose-test: + @echo "Testing orchestrator-ren on port 8000..." + @curl -s http://localhost:8000/healthz || echo "Failed to connect to orchestrator-ren" + @echo "" + @echo "Testing actuator-bus on port 8010..." + @curl -s http://localhost:8010/healthz || echo "Failed to connect to actuator-bus" + +compose-validate: + cd dev/compose && docker compose config diff --git a/README.md b/README.md index a95d104..11b0261 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,23 @@ git checkout -b feat/contracts-v1 git checkout -b feat/orchestrator-skeleton ``` +## Docker Compose Development Stack + +Run orchestrator-ren and actuator-bus locally: + +```bash +# Start services +docker compose up + +# In another terminal - run smoke tests +make compose-test + +# Stop services +docker compose down +``` + +See `dev/compose/README.md` for detailed documentation. + ## Branching - `main`: protected, release-quality - `dev`: integration diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4ae0947 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,52 @@ +# This file provides a convenient entrypoint at the repo root. +# The source of truth is dev/compose/docker-compose.yml +# +# Usage: docker compose up +# +# See dev/compose/README.md for full documentation. + +services: + orchestrator-ren: + build: + context: ./services/orchestrator_ren + dockerfile: Dockerfile + container_name: monad-orchestrator-ren + ports: + - "8000:8000" + volumes: + - ./logs:/app/logs + healthcheck: + test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://localhost:8000/healthz', timeout=2).read()"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + networks: + - monad-network + restart: unless-stopped + + actuator-bus: + build: + context: ./services/actuator_bus + dockerfile: Dockerfile + container_name: monad-actuator-bus + ports: + - "8010:8001" + volumes: + - ./logs:/app/logs + healthcheck: + test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://localhost:8001/healthz', timeout=2).read()"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + depends_on: + orchestrator-ren: + condition: service_healthy + networks: + - monad-network + restart: unless-stopped + +networks: + monad-network: + driver: bridge diff --git a/dev/compose/README.md b/dev/compose/README.md new file mode 100644 index 0000000..1fa5359 --- /dev/null +++ b/dev/compose/README.md @@ -0,0 +1,194 @@ +# MONAD Development Stack + +Docker Compose setup for local development and testing of MONAD services. + +## Services + +- **orchestrator-ren**: Robot Execution Orchestrator (port 8000) +- **actuator-bus**: Robot Actuator Command Interface (port 8010) + +## Quick Start + +### Start the Stack + +```bash +# From repository root +make up + +# Or from this directory +docker compose up -d --build +``` + +### Check Service Health + +```bash +# Health check endpoints +curl http://localhost:8000/healthz # orchestrator-ren +curl http://localhost:8010/healthz # actuator-bus + +# Full service info +curl http://localhost:8000/ # orchestrator-ren +curl http://localhost:8010/ # actuator-bus +``` + +### View Logs + +```bash +# From repository root +make logs + +# Or from this directory +docker compose logs -f + +# View specific service +docker compose logs -f orchestrator-ren +docker compose logs -f actuator-bus +``` + +### Stop the Stack + +```bash +# From repository root +make down + +# Or from this directory +docker compose down +``` + +## Makefile Targets + +From the repository root: + +- `make up` - Start all services (builds images if needed) +- `make down` - Stop and remove all containers +- `make logs` - Follow logs from all services +- `make compose-test` - Run smoke tests against running services +- `make compose-validate` - Validate docker-compose configuration + +## Shared Resources + +### Logs Volume + +Both services mount `./logs:/app/logs` for shared logging output. + +### Network + +Services communicate via the `monad-network` bridge network. + +## Health Checks + +Both services include health checks via the `/healthz` endpoint: + +- **Endpoint**: `/healthz` (returns `{"status": "healthy"}`) +- **Interval**: 30 seconds +- **Timeout**: 3 seconds +- **Retries**: 3 +- **Start Period**: 5 seconds + +Check container health: +```bash +docker compose ps +``` + +The actuator-bus service depends on orchestrator-ren being healthy before starting. + +## Ports + +- **8000**: orchestrator-ren (internal and external) +- **8010**: actuator-bus (maps to internal 8001) + +## Testing Workflow + +1. Start the stack: + ```bash + make up + ``` + +2. Wait for services to be healthy (check with `docker compose ps`) + +3. Test orchestrator-ren: + ```bash + # Create a ticket + curl -X POST http://localhost:8000/ticket \ + -H "Content-Type: application/json" \ + -d '{ + "command": "drive", + "params": {"speed": 1.5, "direction": 90, "duration_seconds": 5}, + "priority": "normal" + }' + + # Execute the ticket (use the returned ticket_id) + curl -X POST http://localhost:8000/execute \ + -H "Content-Type: application/json" \ + -d '{"ticket_id": "tick-20251111-abcd1234"}' + ``` + +4. Test actuator-bus: + ```bash + curl -X POST http://localhost:8010/actuate \ + -H "Content-Type: application/json" \ + -d '{ + "timestamp": "2025-11-11T10:30:00Z", + "command": "drive", + "params": {"speed": 1.5, "direction": 90} + }' + ``` + +5. View interactive API documentation: + - Orchestrator-REN: http://localhost:8000/docs + - Actuator-Bus: http://localhost:8010/docs + +## Troubleshooting + +### Container won't start + +Check logs: +```bash +docker compose logs +``` + +### Port already in use + +Stop conflicting services or change ports in `docker-compose.yml`. + +### Build issues + +Clean and rebuild: +```bash +docker compose down +docker compose build --no-cache +docker compose up -d +``` + +### Health check failing + +Check service is responding: +```bash +docker compose exec orchestrator-ren curl http://localhost:8000/ +docker compose exec actuator-bus curl http://localhost:8001/ +``` + +## Development Tips + +- Use `docker compose up` (without `-d`) to see real-time logs +- Edit code in `services/` directories - rebuild required after changes +- Use `docker compose restart ` to restart a specific service +- Check container resource usage: `docker stats` + +## CI/CD Integration + +The stack can be used in CI pipelines: + +```bash +# Start services +docker compose up -d + +# Wait for health +docker compose ps + +# Run tests +make compose-test + +# Cleanup +docker compose down +``` diff --git a/dev/compose/docker-compose.yml b/dev/compose/docker-compose.yml new file mode 100644 index 0000000..99c7f4a --- /dev/null +++ b/dev/compose/docker-compose.yml @@ -0,0 +1,45 @@ +services: + orchestrator-ren: + build: + context: ../../services/orchestrator_ren + dockerfile: Dockerfile + container_name: monad-orchestrator-ren + ports: + - "8000:8000" + volumes: + - ../../logs:/app/logs + healthcheck: + test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://localhost:8000/healthz', timeout=2).read()"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + networks: + - monad-network + restart: unless-stopped + + actuator-bus: + build: + context: ../../services/actuator_bus + dockerfile: Dockerfile + container_name: monad-actuator-bus + ports: + - "8010:8001" + volumes: + - ../../logs:/app/logs + healthcheck: + test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://localhost:8001/healthz', timeout=2).read()"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + depends_on: + orchestrator-ren: + condition: service_healthy + networks: + - monad-network + restart: unless-stopped + +networks: + monad-network: + driver: bridge diff --git a/services/actuator_bus/Dockerfile b/services/actuator_bus/Dockerfile new file mode 100644 index 0000000..d533adc --- /dev/null +++ b/services/actuator_bus/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +# Prevent Python from writing .pyc files and buffer stdout/stderr +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --trusted-host pypi.org --trusted-host files.pythonhosted.org -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "from urllib.request import urlopen; urlopen('http://localhost:8001/healthz', timeout=2).read()" || exit 1 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/services/actuator_bus/main.py b/services/actuator_bus/main.py index bb4fcfd..da977b6 100644 --- a/services/actuator_bus/main.py +++ b/services/actuator_bus/main.py @@ -95,6 +95,12 @@ async def root(): return {"service": "actuator-bus", "version": "0.1.0", "status": "operational"} +@app.get("/healthz") +async def healthz(): + """Health check endpoint for container orchestration.""" + return {"status": "healthy"} + + @app.post("/actuate", response_model=ActuateResponse, status_code=status.HTTP_200_OK) async def actuate(request: ActuateRequest): """ diff --git a/services/actuator_bus/requirements.txt b/services/actuator_bus/requirements.txt index 0c158e1..c08d873 100644 --- a/services/actuator_bus/requirements.txt +++ b/services/actuator_bus/requirements.txt @@ -1,5 +1,5 @@ -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -pydantic>=2.0.0 -pytest>=7.4.0 -httpx>=0.25.0 +fastapi~=0.121.0 +uvicorn[standard]~=0.38.0 +pydantic~=2.12.0 +pytest~=9.0.0 +httpx~=0.28.0 diff --git a/services/orchestrator_ren/Dockerfile b/services/orchestrator_ren/Dockerfile new file mode 100644 index 0000000..14ecfa2 --- /dev/null +++ b/services/orchestrator_ren/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +# Prevent Python from writing .pyc files and buffer stdout/stderr +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --trusted-host pypi.org --trusted-host files.pythonhosted.org -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "from urllib.request import urlopen; urlopen('http://localhost:8000/healthz', timeout=2).read()" || exit 1 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/orchestrator_ren/main.py b/services/orchestrator_ren/main.py index f26fb76..4e2587d 100644 --- a/services/orchestrator_ren/main.py +++ b/services/orchestrator_ren/main.py @@ -244,6 +244,12 @@ async def root(): } +@app.get("/healthz") +async def healthz(): + """Health check endpoint for container orchestration.""" + return {"status": "healthy"} + + @app.post("/ticket", response_model=Ticket, status_code=status.HTTP_201_CREATED) async def create_ticket(request: TicketCreateRequest): """ diff --git a/services/orchestrator_ren/requirements.txt b/services/orchestrator_ren/requirements.txt index 2d70152..5f7c9ba 100644 --- a/services/orchestrator_ren/requirements.txt +++ b/services/orchestrator_ren/requirements.txt @@ -1,4 +1,4 @@ -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -pydantic>=2.0.0 -requests>=2.0.0 +fastapi~=0.121.0 +uvicorn[standard]~=0.38.0 +pydantic~=2.12.0 +requests~=2.32.0