audit: clean Docker architecture, consolidate test fixtures, add containerized test runner (#94)
This commit is contained in:
committed by
GitHub
parent
1e19164379
commit
d7d7a5a80a
@@ -1,49 +0,0 @@
|
||||
# ── Ollama with Pre-loaded Models ──────────────────────────────────────────────
|
||||
#
|
||||
# This Dockerfile extends the official Ollama image with pre-loaded models
|
||||
# for faster startup and better performance.
|
||||
#
|
||||
# Build: docker build -f Dockerfile.ollama -t timmy-ollama:latest .
|
||||
# Run: docker run -p 11434:11434 -v ollama-data:/root/.ollama timmy-ollama:latest
|
||||
|
||||
FROM ollama/ollama:latest
|
||||
|
||||
# Set environment variables
|
||||
ENV OLLAMA_HOST=0.0.0.0:11434
|
||||
|
||||
# Create a startup script that pulls models on first run
|
||||
RUN mkdir -p /app
|
||||
COPY <<EOF /app/init-models.sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Ollama startup — checking for models..."
|
||||
|
||||
# Start Ollama in the background
|
||||
ollama serve &
|
||||
OLLAMA_PID=$!
|
||||
|
||||
# Wait for Ollama to be ready
|
||||
echo "⏳ Waiting for Ollama to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then
|
||||
echo "✓ Ollama is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Pull the default model if not already present
|
||||
echo "📥 Pulling llama3.2 model..."
|
||||
ollama pull llama3.2 || true
|
||||
|
||||
echo "✓ Ollama initialization complete"
|
||||
|
||||
# Keep the process running
|
||||
wait $OLLAMA_PID
|
||||
EOF
|
||||
|
||||
RUN chmod +x /app/init-models.sh
|
||||
|
||||
# Use the init script as the entrypoint
|
||||
ENTRYPOINT ["/app/init-models.sh"]
|
||||
67
Makefile
67
Makefile
@@ -1,7 +1,9 @@
|
||||
.PHONY: install install-bigbrain dev nuke fresh test test-cov test-cov-html watch lint clean help \
|
||||
up down logs \
|
||||
docker-build docker-up docker-down docker-agent docker-logs docker-shell \
|
||||
cloud-deploy cloud-up cloud-down cloud-logs cloud-status cloud-update
|
||||
test-docker test-docker-cov test-docker-functional test-docker-build test-docker-down \
|
||||
cloud-deploy cloud-up cloud-down cloud-logs cloud-status cloud-update \
|
||||
logs-up logs-down logs-kibana
|
||||
|
||||
PYTEST := poetry run pytest
|
||||
UVICORN := poetry run uvicorn
|
||||
@@ -114,6 +116,38 @@ test-cov-html:
|
||||
test-ollama:
|
||||
FUNCTIONAL_DOCKER=1 $(PYTEST) tests/functional/test_ollama_chat.py -v --tb=long -x
|
||||
|
||||
# ── Docker test containers ───────────────────────────────────────────────────
|
||||
# Clean containers from cached images; source bind-mounted for fast iteration.
|
||||
# Rebuild only needed when pyproject.toml / poetry.lock change.
|
||||
|
||||
# Build the test image (cached — fast unless deps change)
|
||||
test-docker-build:
|
||||
DOCKER_BUILDKIT=1 docker compose -f docker-compose.test.yml build
|
||||
|
||||
# Run all unit + integration tests in a clean container (default)
|
||||
# Override: make test-docker ARGS="-k swarm -v"
|
||||
test-docker: test-docker-build
|
||||
docker compose -f docker-compose.test.yml run --rm test \
|
||||
pytest tests/ -q --tb=short $(ARGS)
|
||||
docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
# Run tests with coverage inside a container
|
||||
test-docker-cov: test-docker-build
|
||||
docker compose -f docker-compose.test.yml run --rm test \
|
||||
pytest tests/ --cov=src --cov-report=term-missing -q $(ARGS)
|
||||
docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
# Spin up the full stack (dashboard + optional Ollama) and run functional tests
|
||||
test-docker-functional: test-docker-build
|
||||
docker compose -f docker-compose.test.yml --profile functional up -d --wait
|
||||
docker compose -f docker-compose.test.yml run --rm test \
|
||||
pytest tests/functional/ -v --tb=short $(ARGS) || true
|
||||
docker compose -f docker-compose.test.yml --profile functional down -v
|
||||
|
||||
# Tear down any leftover test containers and volumes
|
||||
test-docker-down:
|
||||
docker compose -f docker-compose.test.yml --profile functional --profile ollama --profile agents down -v
|
||||
|
||||
# ── Code quality ──────────────────────────────────────────────────────────────
|
||||
|
||||
lint:
|
||||
@@ -226,6 +260,22 @@ cloud-scale:
|
||||
cloud-pull-model:
|
||||
docker exec timmy-ollama ollama pull $${MODEL:-llama3.2}
|
||||
|
||||
# ── ELK Logging ──────────────────────────────────────────────────────────────
|
||||
# Overlay on top of the production stack for centralised log aggregation.
|
||||
# Kibana UI: http://localhost:5601
|
||||
|
||||
logs-up:
|
||||
docker compose -f docker-compose.prod.yml -f docker-compose.logging.yml up -d
|
||||
|
||||
logs-down:
|
||||
docker compose -f docker-compose.prod.yml -f docker-compose.logging.yml down
|
||||
|
||||
logs-kibana:
|
||||
@echo "Opening Kibana at http://localhost:5601 ..."
|
||||
@command -v open >/dev/null 2>&1 && open http://localhost:5601 || \
|
||||
command -v xdg-open >/dev/null 2>&1 && xdg-open http://localhost:5601 || \
|
||||
echo " → Open http://localhost:5601 in your browser"
|
||||
|
||||
# ── Housekeeping ──────────────────────────────────────────────────────────────
|
||||
|
||||
clean:
|
||||
@@ -268,6 +318,15 @@ help:
|
||||
@echo " make pre-commit-install install pre-commit hooks"
|
||||
@echo " make clean remove build artefacts and caches"
|
||||
@echo ""
|
||||
@echo " Docker Testing (Clean Containers)"
|
||||
@echo " ─────────────────────────────────────────────────"
|
||||
@echo " make test-docker run tests in clean container"
|
||||
@echo " make test-docker ARGS=\"-k swarm\" filter tests in container"
|
||||
@echo " make test-docker-cov tests + coverage in container"
|
||||
@echo " make test-docker-functional full-stack functional tests"
|
||||
@echo " make test-docker-build build test image (cached)"
|
||||
@echo " make test-docker-down tear down test containers"
|
||||
@echo ""
|
||||
@echo " Docker (Advanced)"
|
||||
@echo " ─────────────────────────────────────────────────"
|
||||
@echo " make docker-build build the timmy-time:latest image"
|
||||
@@ -289,3 +348,9 @@ help:
|
||||
@echo " make cloud-scale N=4 scale agent workers"
|
||||
@echo " make cloud-pull-model MODEL=llama3.2 pull LLM model"
|
||||
@echo ""
|
||||
@echo " ELK Log Aggregation"
|
||||
@echo " ─────────────────────────────────────────────────"
|
||||
@echo " make logs-up start prod + ELK stack"
|
||||
@echo " make logs-down stop prod + ELK stack"
|
||||
@echo " make logs-kibana open Kibana UI (http://localhost:5601)"
|
||||
@echo ""
|
||||
|
||||
23
deploy/elk/elasticsearch.yml
Normal file
23
deploy/elk/elasticsearch.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# ── Elasticsearch — Single-node config for Timmy Time ───────────────────────
|
||||
#
|
||||
# Minimal config for a single-node deployment. For multi-node clusters
|
||||
# see: https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
|
||||
|
||||
cluster.name: timmy-logs
|
||||
node.name: timmy-es-01
|
||||
|
||||
# Single-node discovery (no cluster formation overhead)
|
||||
discovery.type: single-node
|
||||
|
||||
# Bind to all interfaces inside the container
|
||||
network.host: 0.0.0.0
|
||||
|
||||
# Security: disable X-Pack security for internal-only deployments.
|
||||
# Enable and configure TLS if exposing Elasticsearch externally.
|
||||
xpack.security.enabled: false
|
||||
|
||||
# Memory: let the JVM use container-aware defaults
|
||||
# (set ES_JAVA_OPTS in docker-compose for explicit heap)
|
||||
|
||||
# Index lifecycle — auto-delete old logs after 30 days
|
||||
# Applied via ILM policy created by Logstash on first boot.
|
||||
14
deploy/elk/kibana.yml
Normal file
14
deploy/elk/kibana.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
# ── Kibana — Dashboard config for Timmy Time ────────────────────────────────
|
||||
|
||||
server.name: timmy-kibana
|
||||
server.host: "0.0.0.0"
|
||||
server.port: 5601
|
||||
|
||||
# Connect to Elasticsearch on the Docker network
|
||||
elasticsearch.hosts: ["http://elasticsearch:9200"]
|
||||
|
||||
# Disable telemetry (sovereign deployment)
|
||||
telemetry.enabled: false
|
||||
|
||||
# Default index pattern
|
||||
# Kibana will auto-create this on first boot when logs arrive.
|
||||
78
deploy/elk/logstash.conf
Normal file
78
deploy/elk/logstash.conf
Normal file
@@ -0,0 +1,78 @@
|
||||
# ── Logstash Pipeline — Timmy Time log aggregation ──────────────────────────
|
||||
#
|
||||
# Collects Docker container logs via the GELF input, parses them,
|
||||
# and ships structured events to Elasticsearch.
|
||||
#
|
||||
# Flow: Docker (GELF driver) → Logstash :12201/udp → Elasticsearch
|
||||
|
||||
input {
|
||||
# GELF (Graylog Extended Log Format) — Docker's native structured log driver.
|
||||
# Each container sends logs here automatically via the logging driver config
|
||||
# in docker-compose.logging.yml.
|
||||
gelf {
|
||||
port => 12201
|
||||
type => "docker"
|
||||
}
|
||||
}
|
||||
|
||||
filter {
|
||||
# ── Tag by container name ──────────────────────────────────────────────────
|
||||
# Docker GELF driver populates these fields automatically:
|
||||
# container_name, container_id, image_name, tag, command, created
|
||||
|
||||
# Strip leading "/" from container_name (Docker convention)
|
||||
if [container_name] {
|
||||
mutate {
|
||||
gsub => ["container_name", "^/", ""]
|
||||
}
|
||||
}
|
||||
|
||||
# ── Parse JSON log lines (FastAPI/uvicorn emit JSON when configured) ──────
|
||||
if [message] =~ /^\{/ {
|
||||
json {
|
||||
source => "message"
|
||||
target => "log"
|
||||
skip_on_invalid_json => true
|
||||
}
|
||||
}
|
||||
|
||||
# ── Extract log level ─────────────────────────────────────────────────────
|
||||
# Try structured field first, fall back to regex on raw message
|
||||
if [log][level] {
|
||||
mutate { add_field => { "log_level" => "%{[log][level]}" } }
|
||||
} else if [level] {
|
||||
mutate { add_field => { "log_level" => "%{level}" } }
|
||||
} else {
|
||||
grok {
|
||||
match => { "message" => "(?i)(?<log_level>DEBUG|INFO|WARNING|ERROR|CRITICAL)" }
|
||||
tag_on_failure => []
|
||||
}
|
||||
}
|
||||
|
||||
# Normalise to uppercase
|
||||
if [log_level] {
|
||||
mutate { uppercase => ["log_level"] }
|
||||
}
|
||||
|
||||
# ── Add service metadata ──────────────────────────────────────────────────
|
||||
mutate {
|
||||
add_field => { "environment" => "production" }
|
||||
add_field => { "project" => "timmy-time" }
|
||||
}
|
||||
}
|
||||
|
||||
output {
|
||||
elasticsearch {
|
||||
hosts => ["http://elasticsearch:9200"]
|
||||
index => "timmy-logs-%{+YYYY.MM.dd}"
|
||||
|
||||
# ILM policy: auto-rollover + delete after 30 days
|
||||
ilm_enabled => true
|
||||
ilm_rollover_alias => "timmy-logs"
|
||||
ilm_pattern => "{now/d}-000001"
|
||||
ilm_policy => "timmy-logs-policy"
|
||||
}
|
||||
|
||||
# Also print to stdout for debugging (disable in production)
|
||||
# stdout { codec => rubydebug }
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
# ── Timmy Time — Enhanced Docker Compose with Ollama ──────────────────────────
|
||||
#
|
||||
# This enhanced version includes Ollama service for local LLM inference.
|
||||
# Services:
|
||||
# ollama Local LLM inference server (required for Timmy)
|
||||
# dashboard FastAPI app + swarm coordinator
|
||||
# timmy Timmy sovereign agent
|
||||
# agent Swarm worker template (scale with --scale agent=N --profile agents)
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.enhanced.yml up -d
|
||||
# docker compose -f docker-compose.enhanced.yml logs -f dashboard
|
||||
# docker compose -f docker-compose.enhanced.yml down
|
||||
|
||||
services:
|
||||
|
||||
# ── Ollama — Local LLM Inference Server ────────────────────────────────────
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: timmy-ollama
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_HOST: "0.0.0.0:11434"
|
||||
networks:
|
||||
- swarm-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── Dashboard (coordinator + FastAPI) ──────────────────────────────────────
|
||||
dashboard:
|
||||
build: .
|
||||
image: timmy-time:latest
|
||||
container_name: timmy-dashboard
|
||||
user: "0:0"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- timmy-data:/app/data
|
||||
- ./src:/app/src
|
||||
- ./static:/app/static
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
# Point to Ollama container
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
GROK_ENABLED: "${GROK_ENABLED:-false}"
|
||||
XAI_API_KEY: "${XAI_API_KEY:-}"
|
||||
GROK_DEFAULT_MODEL: "${GROK_DEFAULT_MODEL:-grok-3-fast}"
|
||||
networks:
|
||||
- swarm-net
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# ── Timmy — Sovereign AI Agent ─────────────────────────────────────────────
|
||||
timmy:
|
||||
build: .
|
||||
image: timmy-time:latest
|
||||
container_name: timmy-agent
|
||||
volumes:
|
||||
- timmy-data:/app/data
|
||||
- ./src:/app/src
|
||||
environment:
|
||||
COORDINATOR_URL: "http://dashboard:8000"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
TIMMY_AGENT_ID: "timmy"
|
||||
command: ["python", "-m", "timmy.docker_agent"]
|
||||
networks:
|
||||
- swarm-net
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Agent Worker Template ──────────────────────────────────────────────────
|
||||
# Scale: docker compose -f docker-compose.enhanced.yml up --scale agent=4 --profile agents
|
||||
agent:
|
||||
build: .
|
||||
image: timmy-time:latest
|
||||
profiles:
|
||||
- agents
|
||||
volumes:
|
||||
- timmy-data:/app/data
|
||||
- ./src:/app/src
|
||||
environment:
|
||||
COORDINATOR_URL: "http://dashboard:8000"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
AGENT_NAME: "${AGENT_NAME:-Worker}"
|
||||
AGENT_CAPABILITIES: "${AGENT_CAPABILITIES:-general}"
|
||||
command: ["sh", "-c", "python -m swarm.agent_runner --agent-id agent-$(hostname) --name $${AGENT_NAME:-Worker}"]
|
||||
networks:
|
||||
- swarm-net
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Volumes ────────────────────────────────────────────────────────────────────
|
||||
volumes:
|
||||
timmy-data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PWD}/data"
|
||||
ollama-data:
|
||||
driver: local
|
||||
|
||||
# ── Internal Network ───────────────────────────────────────────────────────────
|
||||
networks:
|
||||
swarm-net:
|
||||
driver: bridge
|
||||
127
docker-compose.logging.yml
Normal file
127
docker-compose.logging.yml
Normal file
@@ -0,0 +1,127 @@
|
||||
# ── Timmy Time — ELK Log Aggregation Overlay ────────────────────────────────
|
||||
#
|
||||
# Adds Elasticsearch + Logstash + Kibana alongside the production stack.
|
||||
# Use as an overlay on top of the prod compose:
|
||||
#
|
||||
# docker compose \
|
||||
# -f docker-compose.prod.yml \
|
||||
# -f docker-compose.logging.yml \
|
||||
# up -d
|
||||
#
|
||||
# ── How it works ────────────────────────────────────────────────────────────
|
||||
#
|
||||
# 1. Every container's Docker logging driver is set to GELF, which sends
|
||||
# structured log events (JSON with container metadata) over UDP.
|
||||
#
|
||||
# 2. Logstash listens on :12201/udp, parses the GELF messages, extracts
|
||||
# log levels, parses JSON payloads from FastAPI/uvicorn, and adds
|
||||
# project metadata.
|
||||
#
|
||||
# 3. Logstash ships the enriched events to Elasticsearch, indexed by day
|
||||
# (timmy-logs-YYYY.MM.dd) with a 30-day ILM retention policy.
|
||||
#
|
||||
# 4. Kibana provides the web UI on :5601 for searching, filtering,
|
||||
# and building dashboards over the indexed logs.
|
||||
#
|
||||
# ── Access ──────────────────────────────────────────────────────────────────
|
||||
# Kibana: http://localhost:5601
|
||||
# Elasticsearch: http://localhost:9200 (API only, not exposed by default)
|
||||
#
|
||||
# ── Resource notes ──────────────────────────────────────────────────────────
|
||||
# Elasticsearch: ~512 MB heap (ES_JAVA_OPTS below). Increase for
|
||||
# high-throughput deployments.
|
||||
# Logstash: ~256 MB heap. Lightweight for GELF → ES pipeline.
|
||||
# Kibana: ~300 MB RAM. Stateless — safe to restart anytime.
|
||||
#
|
||||
# Total overhead: ~1.1 GB RAM on top of the base production stack.
|
||||
|
||||
services:
|
||||
|
||||
# ── Elasticsearch — log storage and search engine ─────────────────────────
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
|
||||
container_name: timmy-elasticsearch
|
||||
volumes:
|
||||
- es-data:/usr/share/elasticsearch/data
|
||||
- ./deploy/elk/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro
|
||||
environment:
|
||||
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
networks:
|
||||
- swarm-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\\|yellow\"'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# ── Logstash — log pipeline (GELF in → Elasticsearch out) ────────────────
|
||||
logstash:
|
||||
image: docker.elastic.co/logstash/logstash:8.17.0
|
||||
container_name: timmy-logstash
|
||||
volumes:
|
||||
- ./deploy/elk/logstash.conf:/usr/share/logstash/pipeline/logstash.conf:ro
|
||||
environment:
|
||||
LS_JAVA_OPTS: "-Xms256m -Xmx256m"
|
||||
ports:
|
||||
- "12201:12201/udp" # GELF input from Docker logging driver
|
||||
networks:
|
||||
- swarm-net
|
||||
depends_on:
|
||||
elasticsearch:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Kibana — log visualisation UI ─────────────────────────────────────────
|
||||
kibana:
|
||||
image: docker.elastic.co/kibana/kibana:8.17.0
|
||||
container_name: timmy-kibana
|
||||
volumes:
|
||||
- ./deploy/elk/kibana.yml:/usr/share/kibana/config/kibana.yml:ro
|
||||
ports:
|
||||
- "5601:5601"
|
||||
networks:
|
||||
- swarm-net
|
||||
depends_on:
|
||||
elasticsearch:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Override existing services to use GELF logging driver ─────────────────
|
||||
# These extend the services defined in docker-compose.prod.yml.
|
||||
# Docker merges the logging config into the existing service definition.
|
||||
|
||||
dashboard:
|
||||
logging:
|
||||
driver: gelf
|
||||
options:
|
||||
gelf-address: "udp://localhost:12201"
|
||||
tag: "dashboard"
|
||||
depends_on:
|
||||
logstash:
|
||||
condition: service_started
|
||||
|
||||
timmy:
|
||||
logging:
|
||||
driver: gelf
|
||||
options:
|
||||
gelf-address: "udp://localhost:12201"
|
||||
tag: "timmy-agent"
|
||||
depends_on:
|
||||
logstash:
|
||||
condition: service_started
|
||||
|
||||
ollama:
|
||||
logging:
|
||||
driver: gelf
|
||||
options:
|
||||
gelf-address: "udp://localhost:12201"
|
||||
tag: "ollama"
|
||||
depends_on:
|
||||
logstash:
|
||||
condition: service_started
|
||||
|
||||
# ── Persistent volume for Elasticsearch indices ────────────────────────────
|
||||
volumes:
|
||||
es-data:
|
||||
@@ -11,8 +11,6 @@
|
||||
# docker compose -f docker-compose.microservices.yml logs -f dashboard
|
||||
# docker compose -f docker-compose.microservices.yml up --scale worker=4
|
||||
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
|
||||
# ── Ollama LLM Service ────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,54 +1,73 @@
|
||||
# ── Timmy Time — test stack ──────────────────────────────────────────────────
|
||||
# ── Timmy Time — Test Stack ──────────────────────────────────────────────────
|
||||
#
|
||||
# Lightweight compose for functional tests. Runs the dashboard on port 18000
|
||||
# and optional agent workers on the swarm-test-net network.
|
||||
# Clean containers for test runs. Designed for fast iteration:
|
||||
# • Cached builder layers — only rebuilds when pyproject.toml changes
|
||||
# • Bind-mounted source — code changes are instant, no rebuild needed
|
||||
# • Ephemeral test-data — every run starts with clean state
|
||||
#
|
||||
# Profiles:
|
||||
# (default) dashboard only (Ollama on host via host.docker.internal)
|
||||
# ollama adds a containerised Ollama instance + auto model pull
|
||||
# agents adds scalable agent workers
|
||||
# ── Profiles ────────────────────────────────────────────────────────────────
|
||||
# (default) test runner only (unit + integration tests)
|
||||
# functional adds a live dashboard on port 18000 for HTTP-level tests
|
||||
# ollama adds containerised Ollama (CPU, qwen2.5:0.5b) for LLM tests
|
||||
# agents adds swarm agent workers for multi-agent tests
|
||||
#
|
||||
# Usage:
|
||||
# # Swarm tests (no LLM needed):
|
||||
# FUNCTIONAL_DOCKER=1 pytest tests/functional/test_docker_swarm.py -v
|
||||
# ── Quick-start ─────────────────────────────────────────────────────────────
|
||||
# make test-docker # unit + integration in container
|
||||
# make test-docker ARGS="-k swarm" # filter tests
|
||||
# make test-docker-functional # full-stack functional tests
|
||||
# make test-docker-cov # with coverage report
|
||||
#
|
||||
# # Full-stack with Ollama (pulls qwen2.5:0.5b automatically):
|
||||
# FUNCTIONAL_DOCKER=1 pytest tests/functional/test_ollama_chat.py -v
|
||||
#
|
||||
# Or manually:
|
||||
# docker compose -f docker-compose.test.yml -p timmy-test up -d --build --wait
|
||||
# curl http://localhost:18000/health
|
||||
# docker compose -f docker-compose.test.yml -p timmy-test down -v
|
||||
# ── Manual usage ────────────────────────────────────────────────────────────
|
||||
# docker compose -f docker-compose.test.yml run --rm test
|
||||
# docker compose -f docker-compose.test.yml run --rm test pytest tests/swarm -v
|
||||
# docker compose -f docker-compose.test.yml --profile functional up -d --wait
|
||||
# docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
services:
|
||||
|
||||
# ── Ollama — local LLM for functional tests ───────────────────────────────
|
||||
# Activated with: --profile ollama
|
||||
# Uses a tiny model (qwen2.5:0.5b, ~400 MB) so it runs on CPU-only CI.
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: timmy-test-ollama
|
||||
profiles:
|
||||
- ollama
|
||||
# ── Test Runner ───────────────────────────────────────────────────────────
|
||||
# Runs pytest in a clean container. Exits when tests complete.
|
||||
# Source and tests are bind-mounted so code changes don't require a rebuild.
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.test
|
||||
cache_from:
|
||||
- timmy-test:latest
|
||||
image: timmy-test:latest
|
||||
volumes:
|
||||
- ./src:/app/src:ro
|
||||
- ./tests:/app/tests:ro
|
||||
- ./static:/app/static:ro
|
||||
- ./pyproject.toml:/app/pyproject.toml:ro
|
||||
- test-data:/app/data
|
||||
environment:
|
||||
TIMMY_TEST_MODE: "1"
|
||||
LIGHTNING_BACKEND: "mock"
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
networks:
|
||||
- swarm-test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
- test-net
|
||||
# Default command — override with: docker compose run --rm test pytest <args>
|
||||
command: ["pytest", "tests/", "-q", "--tb=short"]
|
||||
|
||||
# ── Dashboard — live server for functional tests ──────────────────────────
|
||||
# Activated with: --profile functional
|
||||
dashboard:
|
||||
build: .
|
||||
image: timmy-time:test
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.test
|
||||
cache_from:
|
||||
- timmy-test:latest
|
||||
image: timmy-test:latest
|
||||
profiles:
|
||||
- functional
|
||||
container_name: timmy-test-dashboard
|
||||
ports:
|
||||
- "18000:8000"
|
||||
volumes:
|
||||
- ./src:/app/src:ro
|
||||
- ./static:/app/static:ro
|
||||
- test-data:/app/data
|
||||
- ./src:/app/src
|
||||
- ./static:/app/static
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
TIMMY_TEST_MODE: "1"
|
||||
@@ -58,7 +77,8 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- swarm-test-net
|
||||
- test-net
|
||||
command: ["uvicorn", "dashboard.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 5s
|
||||
@@ -66,14 +86,38 @@ services:
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
# ── Ollama — local LLM for functional tests ──────────────────────────────
|
||||
# Activated with: --profile ollama
|
||||
# Uses a tiny model (qwen2.5:0.5b, ~400 MB) so it runs on CPU-only CI.
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: timmy-test-ollama
|
||||
profiles:
|
||||
- ollama
|
||||
networks:
|
||||
- test-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
# ── Agent — swarm worker for multi-agent tests ───────────────────────────
|
||||
# Activated with: --profile agents
|
||||
# Scale: docker compose -f docker-compose.test.yml --profile agents up --scale agent=4
|
||||
agent:
|
||||
build: .
|
||||
image: timmy-time:test
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.test
|
||||
cache_from:
|
||||
- timmy-test:latest
|
||||
image: timmy-test:latest
|
||||
profiles:
|
||||
- agents
|
||||
volumes:
|
||||
- ./src:/app/src:ro
|
||||
- test-data:/app/data
|
||||
- ./src:/app/src
|
||||
environment:
|
||||
COORDINATOR_URL: "http://dashboard:8000"
|
||||
OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
||||
@@ -83,16 +127,21 @@ services:
|
||||
TIMMY_TEST_MODE: "1"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command: ["sh", "-c", "python -m swarm.agent_runner --agent-id agent-$(hostname) --name $${AGENT_NAME:-TestWorker}"]
|
||||
command: >-
|
||||
sh -c "python -m swarm.agent_runner
|
||||
--agent-id agent-$$(hostname)
|
||||
--name $${AGENT_NAME:-TestWorker}"
|
||||
networks:
|
||||
- swarm-test-net
|
||||
- test-net
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_healthy
|
||||
|
||||
# ── Ephemeral volume — destroyed with `docker compose down -v` ─────────────
|
||||
volumes:
|
||||
test-data:
|
||||
|
||||
# ── Isolated test network ─────────────────────────────────────────────────
|
||||
networks:
|
||||
swarm-test-net:
|
||||
test-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ── Timmy Time — docker-compose ─────────────────────────────────────────────
|
||||
# ── Timmy Time — Development Compose ────────────────────────────────────────
|
||||
#
|
||||
# Services
|
||||
# dashboard FastAPI app + swarm coordinator (always on)
|
||||
# timmy Sovereign AI agent (separate container)
|
||||
# agent Swarm worker template — scale with:
|
||||
# docker compose up --scale agent=N --profile agents
|
||||
#
|
||||
@@ -14,6 +15,27 @@
|
||||
# make docker-agent add one agent worker
|
||||
# make docker-down stop everything
|
||||
# make docker-logs tail logs
|
||||
#
|
||||
# ── Security note: root user in dev ─────────────────────────────────────────
|
||||
# This dev compose runs containers as root (user: "0:0") so that
|
||||
# bind-mounted host files (./src, ./static) are readable regardless of
|
||||
# host UID/GID — the #1 cause of 403 errors on macOS.
|
||||
#
|
||||
# Production (docker-compose.prod.yml) uses NO bind mounts and runs as
|
||||
# the Dockerfile's non-root "timmy" user. Never expose this dev compose
|
||||
# to untrusted networks.
|
||||
#
|
||||
# ── Ollama host access ──────────────────────────────────────────────────────
|
||||
# By default OLLAMA_URL points to http://host.docker.internal:11434 which
|
||||
# reaches Ollama running on the Docker host (macOS/Windows native).
|
||||
#
|
||||
# Linux: The extra_hosts entry maps host.docker.internal → host-gateway,
|
||||
# which resolves to the host IP on Docker 20.10+. If you run an
|
||||
# older Docker version, set OLLAMA_URL=http://172.17.0.1:11434
|
||||
# in your .env file instead.
|
||||
#
|
||||
# Containerised Ollama: Use docker-compose.microservices.yml which runs
|
||||
# Ollama as a sibling container on the same network.
|
||||
|
||||
services:
|
||||
|
||||
@@ -22,12 +44,7 @@ services:
|
||||
build: .
|
||||
image: timmy-time:latest
|
||||
container_name: timmy-dashboard
|
||||
# Run as root in the dev compose because bind-mounted host files
|
||||
# (./src, ./static) may not be readable by the image's non-root
|
||||
# "timmy" user — this is the #1 cause of 403 errors on macOS.
|
||||
# Production (docker-compose.prod.yml) uses no bind mounts and
|
||||
# correctly runs as the Dockerfile's non-root USER.
|
||||
user: "0:0"
|
||||
user: "0:0" # dev only — see security note above
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
@@ -36,14 +53,13 @@ services:
|
||||
- ./static:/app/static # live-reload: CSS/asset changes reflect immediately
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
# Point to host Ollama (Mac default). Override in .env if different.
|
||||
OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
||||
# Grok (xAI) — opt-in premium cloud backend
|
||||
GROK_ENABLED: "${GROK_ENABLED:-false}"
|
||||
XAI_API_KEY: "${XAI_API_KEY:-}"
|
||||
GROK_DEFAULT_MODEL: "${GROK_DEFAULT_MODEL:-grok-3-fast}"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # Linux compatibility
|
||||
- "host.docker.internal:host-gateway" # Linux: maps to host IP
|
||||
networks:
|
||||
- swarm-net
|
||||
restart: unless-stopped
|
||||
|
||||
59
docker/Dockerfile.test
Normal file
59
docker/Dockerfile.test
Normal file
@@ -0,0 +1,59 @@
|
||||
# ── Timmy Time — Test Runner Image ───────────────────────────────────────────
|
||||
#
|
||||
# Lean image with test dependencies baked in. Designed to be used with
|
||||
# docker-compose.test.yml which bind-mounts src/, tests/, and static/
|
||||
# so you never rebuild for code changes — only when deps change.
|
||||
#
|
||||
# Build: docker compose -f docker-compose.test.yml build
|
||||
# Run: docker compose -f docker-compose.test.yml run --rm test
|
||||
#
|
||||
# The builder stage is shared with the production Dockerfile so
|
||||
# dependency layers stay cached across dev ↔ test ↔ prod builds.
|
||||
|
||||
# ── Stage 1: Builder — export deps via Poetry, install via pip ──────────────
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir poetry poetry-plugin-export
|
||||
|
||||
# Copy only dependency files (layer caching — rebuilds only when deps change)
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
|
||||
# Export ALL deps including dev/test extras
|
||||
RUN poetry export --extras swarm --extras telegram --extras dev \
|
||||
--with dev --without-hashes \
|
||||
-f requirements.txt -o requirements.txt
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
# ── Stage 2: Test runtime ───────────────────────────────────────────────────
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages \
|
||||
/usr/local/lib/python3.12/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# Create directories for bind mounts
|
||||
RUN mkdir -p /app/src /app/tests /app/static /app/data
|
||||
|
||||
ENV PYTHONPATH=/app/src:/app/tests
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV TIMMY_TEST_MODE=1
|
||||
|
||||
# Default: run pytest (overridable via docker-compose command)
|
||||
CMD ["pytest", "tests/", "-q", "--tb=short"]
|
||||
@@ -2,11 +2,20 @@
|
||||
# ── Ollama Initialization Script ──────────────────────────────────────────────
|
||||
#
|
||||
# Starts Ollama and pulls models on first run.
|
||||
# Requires: curl (ships with the ollama image).
|
||||
# jq is installed at runtime if missing so we can parse /api/tags reliably
|
||||
# instead of fragile grep-based JSON extraction.
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Ollama startup — checking for models..."
|
||||
|
||||
# ── Ensure jq is available (ollama image is Debian-based) ────────────────────
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "📦 Installing jq for reliable JSON parsing..."
|
||||
apt-get update -qq && apt-get install -y -qq jq >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Start Ollama in background
|
||||
ollama serve &
|
||||
OLLAMA_PID=$!
|
||||
@@ -18,15 +27,26 @@ for i in {1..60}; do
|
||||
echo "✓ Ollama is ready"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "❌ Ollama failed to start after 60 s"
|
||||
exit 1
|
||||
fi
|
||||
echo " Attempt $i/60..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check if models are already present
|
||||
# Check if models are already present (jq with grep fallback)
|
||||
echo "📋 Checking available models..."
|
||||
MODELS=$(curl -s http://localhost:11434/api/tags | grep -o '"name":"[^"]*"' | wc -l)
|
||||
TAGS_JSON=$(curl -s http://localhost:11434/api/tags)
|
||||
|
||||
if [ "$MODELS" -eq 0 ]; then
|
||||
if command -v jq &>/dev/null; then
|
||||
MODELS=$(echo "$TAGS_JSON" | jq '.models | length')
|
||||
else
|
||||
# Fallback: count "name" keys (less reliable but functional)
|
||||
MODELS=$(echo "$TAGS_JSON" | grep -o '"name"' | wc -l)
|
||||
fi
|
||||
|
||||
if [ "${MODELS:-0}" -eq 0 ]; then
|
||||
echo "📥 No models found. Pulling llama3.2..."
|
||||
ollama pull llama3.2 || echo "⚠️ Failed to pull llama3.2 (may already be pulling)"
|
||||
else
|
||||
|
||||
74
poetry.lock
generated
74
poetry.lock
generated
@@ -458,7 +458,7 @@ files = [
|
||||
{file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"},
|
||||
{file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
|
||||
]
|
||||
markers = {main = "extra == \"discord\""}
|
||||
markers = {main = "extra == \"discord\" or extra == \"dev\""}
|
||||
|
||||
[[package]]
|
||||
name = "audioop-lts"
|
||||
@@ -538,8 +538,7 @@ version = "2.0.0"
|
||||
description = "Foreign Function Interface for Python calling C code."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
markers = "os_name == \"nt\" and implementation_name != \"pypy\""
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
|
||||
@@ -626,6 +625,7 @@ files = [
|
||||
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
|
||||
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
|
||||
]
|
||||
markers = {main = "os_name == \"nt\" and implementation_name != \"pypy\" and extra == \"dev\"", dev = "os_name == \"nt\" and implementation_name != \"pypy\""}
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
|
||||
@@ -800,7 +800,7 @@ version = "7.13.4"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415"},
|
||||
{file = "coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b"},
|
||||
@@ -909,6 +909,7 @@ files = [
|
||||
{file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"},
|
||||
{file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.extras]
|
||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
@@ -1599,11 +1600,12 @@ version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
@@ -2490,11 +2492,12 @@ version = "1.3.0.post0"
|
||||
description = "Capture the outcome of Python function calls."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"},
|
||||
{file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=19.2.0"
|
||||
@@ -2642,11 +2645,12 @@ version = "1.6.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
@@ -2842,12 +2846,12 @@ version = "3.0"
|
||||
description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
markers = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\""
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
|
||||
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
|
||||
]
|
||||
markers = {main = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\" and extra == \"dev\"", dev = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
@@ -6617,12 +6621,13 @@ version = "1.7.1"
|
||||
description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"},
|
||||
{file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
|
||||
{file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
@@ -6630,11 +6635,12 @@ version = "9.0.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
||||
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||
@@ -6652,11 +6658,12 @@ version = "1.3.0"
|
||||
description = "Pytest support for asyncio"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"},
|
||||
{file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=8.2,<10"
|
||||
@@ -6672,11 +6679,12 @@ version = "7.0.0"
|
||||
description = "Pytest plugin for measuring coverage."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
|
||||
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
coverage = {version = ">=7.10.6", extras = ["toml"]}
|
||||
@@ -6686,17 +6694,34 @@ pytest = ">=7"
|
||||
[package.extras]
|
||||
testing = ["process-tests", "pytest-xdist", "virtualenv"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-randomly"
|
||||
version = "4.0.1"
|
||||
description = "Pytest plugin to randomly order tests and control random.seed."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7"},
|
||||
{file = "pytest_randomly-4.0.1.tar.gz", hash = "sha256:174e57bb12ac2c26f3578188490bd333f0e80620c3f47340158a86eca0593cd8"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
pytest = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-timeout"
|
||||
version = "2.4.0"
|
||||
description = "pytest plugin to abort hanging tests"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
|
||||
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=7.0.0"
|
||||
@@ -7213,11 +7238,12 @@ version = "4.41.0"
|
||||
description = "Official Python bindings for Selenium WebDriver"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "selenium-4.41.0-py3-none-any.whl", hash = "sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"},
|
||||
{file = "selenium-4.41.0.tar.gz", hash = "sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2026.1.4"
|
||||
@@ -7291,11 +7317,12 @@ version = "2.4.0"
|
||||
description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
|
||||
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
@@ -7626,11 +7653,12 @@ version = "0.33.0"
|
||||
description = "A friendly Python library for async concurrency and I/O"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b"},
|
||||
{file = "trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=23.2.0"
|
||||
@@ -7646,11 +7674,12 @@ version = "0.12.2"
|
||||
description = "WebSocket library for Trio"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"},
|
||||
{file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
outcome = ">=1.2.0"
|
||||
@@ -7983,11 +8012,12 @@ version = "1.9.0"
|
||||
description = "WebSocket client for Python with low level API options"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"},
|
||||
{file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"]
|
||||
@@ -8071,11 +8101,12 @@ version = "1.3.2"
|
||||
description = "Pure-Python WebSocket protocol implementation"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"},
|
||||
{file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"},
|
||||
]
|
||||
markers = {main = "extra == \"dev\""}
|
||||
|
||||
[package.dependencies]
|
||||
h11 = ">=0.16.0,<1"
|
||||
@@ -8228,6 +8259,7 @@ propcache = ">=0.2.1"
|
||||
|
||||
[extras]
|
||||
bigbrain = ["airllm"]
|
||||
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-randomly", "pytest-timeout", "selenium"]
|
||||
discord = ["discord.py"]
|
||||
swarm = ["redis"]
|
||||
telegram = ["python-telegram-bot"]
|
||||
@@ -8236,4 +8268,4 @@ voice = ["pyttsx3"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<4"
|
||||
content-hash = "546e3cc56929a6b988223fbc685fdb61468fbe5a50249be624742edca30f137e"
|
||||
content-hash = "8e608d71fafb99eda990a90f7879127522ec03fcd2bd34b115d2b4fde4c0fe87"
|
||||
|
||||
@@ -54,6 +54,7 @@ pytest-asyncio = { version = ">=0.24.0", optional = true }
|
||||
pytest-cov = { version = ">=5.0.0", optional = true }
|
||||
pytest-timeout = { version = ">=2.3.0", optional = true }
|
||||
selenium = { version = ">=4.20.0", optional = true }
|
||||
pytest-randomly = { version = ">=3.16.0", optional = true }
|
||||
|
||||
[tool.poetry.extras]
|
||||
swarm = ["redis"]
|
||||
@@ -61,7 +62,7 @@ telegram = ["python-telegram-bot"]
|
||||
discord = ["discord.py"]
|
||||
bigbrain = ["airllm"]
|
||||
voice = ["pyttsx3"]
|
||||
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "selenium"]
|
||||
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-randomly", "selenium"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = ">=8.0.0"
|
||||
@@ -69,6 +70,7 @@ pytest-asyncio = ">=0.24.0"
|
||||
pytest-cov = ">=5.0.0"
|
||||
pytest-timeout = ">=2.3.0"
|
||||
selenium = ">=4.20.0"
|
||||
pytest-randomly = "^4.0.1"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
timmy = "timmy.cli:main"
|
||||
@@ -85,9 +87,15 @@ addopts = "-v --tb=short --timeout=30"
|
||||
markers = [
|
||||
"unit: Unit tests (fast, no I/O)",
|
||||
"integration: Integration tests (may use SQLite)",
|
||||
"functional: Functional tests (real HTTP requests, no mocking)",
|
||||
"e2e: End-to-end tests (full system, may be slow)",
|
||||
"dashboard: Dashboard route tests",
|
||||
"swarm: Swarm coordinator tests",
|
||||
"slow: Tests that take >1 second",
|
||||
"selenium: Requires Selenium and Chrome (browser automation)",
|
||||
"docker: Requires Docker and docker-compose",
|
||||
"ollama: Requires Ollama service running",
|
||||
"skip_ci: Skip in CI environment (local development only)",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
|
||||
@@ -186,6 +186,32 @@ def db_connection():
|
||||
|
||||
# ── Additional Clean Test Fixtures ──────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
"""Point all swarm SQLite paths to a temp directory for test isolation.
|
||||
|
||||
This is the single source of truth — individual test files should NOT
|
||||
redefine this fixture. All eight swarm modules that carry a module-level
|
||||
DB_PATH are patched here so every test gets a clean, ephemeral database.
|
||||
"""
|
||||
db_path = tmp_path / "swarm.db"
|
||||
for module in [
|
||||
"swarm.tasks",
|
||||
"swarm.registry",
|
||||
"swarm.stats",
|
||||
"swarm.learner",
|
||||
"swarm.routing",
|
||||
"swarm.event_log",
|
||||
"swarm.task_queue.models",
|
||||
"swarm.work_orders.models",
|
||||
]:
|
||||
try:
|
||||
monkeypatch.setattr(f"{module}.DB_PATH", db_path)
|
||||
except AttributeError:
|
||||
pass # Module may not be importable in minimal test envs
|
||||
yield db_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ollama_client():
|
||||
"""Provide a mock Ollama client for unit tests."""
|
||||
|
||||
@@ -7,16 +7,6 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
"""Point swarm SQLite to a temp directory for test isolation."""
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from dashboard.app import app
|
||||
|
||||
@@ -1,175 +1,120 @@
|
||||
"""End-to-end tests for Docker deployment.
|
||||
|
||||
These tests verify that the Dockerized application starts correctly,
|
||||
responds to requests, and all services are properly orchestrated.
|
||||
These tests verify that Dockerfiles and compose configs are present,
|
||||
syntactically valid, and declare the expected services and settings.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def docker_compose_file():
|
||||
"""Return the path to the docker-compose file."""
|
||||
return Path(__file__).parent.parent.parent / "docker-compose.enhanced.yml"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def docker_services_running(docker_compose_file):
|
||||
"""Start Docker services for testing."""
|
||||
if not docker_compose_file.exists():
|
||||
pytest.skip("docker-compose.enhanced.yml not found")
|
||||
|
||||
# Start services
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "-f", str(docker_compose_file), "up", "-d"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"Failed to start Docker services: {result.stderr}")
|
||||
|
||||
# Wait for services to be ready
|
||||
time.sleep(10)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
subprocess.run(
|
||||
["docker", "compose", "-f", str(docker_compose_file), "down"],
|
||||
capture_output=True,
|
||||
)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
|
||||
reason="Docker not installed"
|
||||
reason="Docker not installed",
|
||||
)
|
||||
def test_docker_compose_file_exists():
|
||||
"""Test that docker-compose.enhanced.yml exists."""
|
||||
compose_file = Path(__file__).parent.parent.parent / "docker-compose.enhanced.yml"
|
||||
assert compose_file.exists(), "docker-compose.enhanced.yml should exist"
|
||||
class TestDockerComposeFiles:
|
||||
"""Validate that all compose files exist and parse cleanly."""
|
||||
|
||||
def test_base_compose_exists(self):
|
||||
assert (PROJECT_ROOT / "docker-compose.yml").exists()
|
||||
|
||||
def test_dev_overlay_exists(self):
|
||||
assert (PROJECT_ROOT / "docker-compose.dev.yml").exists()
|
||||
|
||||
def test_prod_compose_exists(self):
|
||||
assert (PROJECT_ROOT / "docker-compose.prod.yml").exists()
|
||||
|
||||
def test_test_compose_exists(self):
|
||||
assert (PROJECT_ROOT / "docker-compose.test.yml").exists()
|
||||
|
||||
def test_microservices_compose_exists(self):
|
||||
assert (PROJECT_ROOT / "docker-compose.microservices.yml").exists()
|
||||
|
||||
def test_base_compose_syntax(self):
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "-f", str(PROJECT_ROOT / "docker-compose.yml"), "config"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, f"Docker Compose syntax error: {result.stderr}"
|
||||
|
||||
def test_microservices_compose_services_defined(self):
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "compose",
|
||||
"-f", str(PROJECT_ROOT / "docker-compose.microservices.yml"),
|
||||
"config", "--format", "json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, f"Config error: {result.stderr}"
|
||||
config = json.loads(result.stdout)
|
||||
services = config.get("services", {})
|
||||
assert "ollama" in services, "ollama service should be defined"
|
||||
assert "dashboard" in services, "dashboard service should be defined"
|
||||
assert "timmy" in services, "timmy service should be defined"
|
||||
|
||||
def test_microservices_compose_content(self):
|
||||
content = (PROJECT_ROOT / "docker-compose.microservices.yml").read_text()
|
||||
assert "ollama" in content
|
||||
assert "dashboard" in content
|
||||
assert "timmy" in content
|
||||
assert "timmy-net" in content
|
||||
assert "ollama-data" in content
|
||||
assert "timmy-data" in content
|
||||
|
||||
def test_test_compose_has_test_runner(self):
|
||||
content = (PROJECT_ROOT / "docker-compose.test.yml").read_text()
|
||||
assert "test:" in content, "Test compose should define a 'test' service"
|
||||
assert "TIMMY_TEST_MODE" in content
|
||||
assert "pytest" in content
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
|
||||
reason="Docker not installed"
|
||||
)
|
||||
def test_docker_compose_syntax():
|
||||
"""Test that docker-compose file has valid syntax."""
|
||||
compose_file = Path(__file__).parent.parent.parent / "docker-compose.enhanced.yml"
|
||||
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "-f", str(compose_file), "config"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
class TestDockerfiles:
|
||||
"""Validate the primary Dockerfile and specialised images."""
|
||||
|
||||
def test_dockerfile_exists(self):
|
||||
assert (PROJECT_ROOT / "Dockerfile").exists()
|
||||
|
||||
def test_dockerfile_ollama_exists(self):
|
||||
assert (PROJECT_ROOT / "docker" / "Dockerfile.ollama").exists()
|
||||
|
||||
def test_dockerfile_agent_exists(self):
|
||||
assert (PROJECT_ROOT / "docker" / "Dockerfile.agent").exists()
|
||||
|
||||
def test_dockerfile_dashboard_exists(self):
|
||||
assert (PROJECT_ROOT / "docker" / "Dockerfile.dashboard").exists()
|
||||
|
||||
def test_dockerfile_test_exists(self):
|
||||
assert (PROJECT_ROOT / "docker" / "Dockerfile.test").exists()
|
||||
|
||||
def test_dockerfile_health_check(self):
|
||||
content = (PROJECT_ROOT / "Dockerfile").read_text()
|
||||
assert "HEALTHCHECK" in content, "Dockerfile should include HEALTHCHECK"
|
||||
assert "/health" in content
|
||||
|
||||
def test_dockerfile_non_root_user(self):
|
||||
content = (PROJECT_ROOT / "Dockerfile").read_text()
|
||||
assert "USER timmy" in content
|
||||
assert "groupadd -r timmy" in content
|
||||
|
||||
@pytest.mark.skipif(
|
||||
subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
|
||||
reason="Docker not installed",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"Docker Compose syntax error: {result.stderr}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
|
||||
reason="Docker not installed"
|
||||
)
|
||||
def test_dockerfile_exists():
|
||||
"""Test that Dockerfile exists."""
|
||||
dockerfile = Path(__file__).parent.parent.parent / "Dockerfile"
|
||||
assert dockerfile.exists(), "Dockerfile should exist"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
|
||||
reason="Docker not installed"
|
||||
)
|
||||
def test_dockerfile_ollama_exists():
|
||||
"""Test that Dockerfile.ollama exists."""
|
||||
dockerfile = Path(__file__).parent.parent.parent / "Dockerfile.ollama"
|
||||
assert dockerfile.exists(), "Dockerfile.ollama should exist"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
|
||||
reason="Docker not installed"
|
||||
)
|
||||
def test_docker_image_build():
|
||||
"""Test that the Docker image can be built."""
|
||||
result = subprocess.run(
|
||||
["docker", "build", "-t", "timmy-time:test", "."],
|
||||
cwd=Path(__file__).parent.parent.parent,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# Don't fail if build fails, just skip
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"Docker build failed: {result.stderr}")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
subprocess.run(["which", "docker"], capture_output=True, shell=True).returncode != 0,
|
||||
reason="Docker not installed"
|
||||
)
|
||||
def test_docker_compose_services_defined():
|
||||
"""Test that docker-compose defines all required services."""
|
||||
compose_file = Path(__file__).parent.parent.parent / "docker-compose.enhanced.yml"
|
||||
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "-f", str(compose_file), "config"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, "Docker Compose config should be valid"
|
||||
|
||||
config = json.loads(result.stdout)
|
||||
services = config.get("services", {})
|
||||
|
||||
# Check for required services
|
||||
assert "ollama" in services, "ollama service should be defined"
|
||||
assert "dashboard" in services, "dashboard service should be defined"
|
||||
assert "timmy" in services, "timmy service should be defined"
|
||||
|
||||
|
||||
def test_docker_compose_enhanced_yml_content():
|
||||
"""Test that docker-compose.enhanced.yml has correct configuration."""
|
||||
compose_file = Path(__file__).parent.parent.parent / "docker-compose.enhanced.yml"
|
||||
|
||||
with open(compose_file) as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for key configurations
|
||||
assert "ollama" in content, "Should reference ollama service"
|
||||
assert "dashboard" in content, "Should reference dashboard service"
|
||||
assert "timmy" in content, "Should reference timmy agent"
|
||||
assert "swarm-net" in content, "Should define swarm network"
|
||||
assert "ollama-data" in content, "Should define ollama-data volume"
|
||||
assert "timmy-data" in content, "Should define timmy-data volume"
|
||||
|
||||
|
||||
def test_dockerfile_health_check():
|
||||
"""Test that Dockerfile includes health check."""
|
||||
dockerfile = Path(__file__).parent.parent.parent / "Dockerfile"
|
||||
|
||||
with open(dockerfile) as f:
|
||||
content = f.read()
|
||||
|
||||
assert "HEALTHCHECK" in content, "Dockerfile should include HEALTHCHECK"
|
||||
assert "/health" in content, "Health check should use /health endpoint"
|
||||
|
||||
|
||||
def test_dockerfile_non_root_user():
|
||||
"""Test that Dockerfile runs as non-root user."""
|
||||
dockerfile = Path(__file__).parent.parent.parent / "Dockerfile"
|
||||
|
||||
with open(dockerfile) as f:
|
||||
content = f.read()
|
||||
|
||||
assert "USER timmy" in content, "Dockerfile should run as non-root user"
|
||||
assert "groupadd -r timmy" in content, "Dockerfile should create timmy user"
|
||||
def test_docker_image_build(self):
|
||||
result = subprocess.run(
|
||||
["docker", "build", "-t", "timmy-time:test", "."],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"Docker build failed: {result.stderr}")
|
||||
|
||||
@@ -8,17 +8,6 @@ import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
"""Point swarm SQLite to a temp directory for test isolation."""
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.learner.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
# ── Coordinator: Agent lifecycle ─────────────────────────────────────────────
|
||||
|
||||
def test_coordinator_spawn_agent():
|
||||
|
||||
@@ -10,14 +10,6 @@ import pytest
|
||||
|
||||
# ── Tasks CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
"""Point swarm SQLite to a temp directory for test isolation."""
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
def test_create_task():
|
||||
from swarm.tasks import create_task
|
||||
|
||||
@@ -7,15 +7,6 @@ import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
"""Point swarm SQLite to a temp directory for test isolation."""
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
def _make_node(agent_id="node-1", name="TestNode"):
|
||||
from swarm.comms import SwarmComms
|
||||
from swarm.swarm_node import SwarmNode
|
||||
|
||||
@@ -4,18 +4,6 @@ import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
# ── Fixture: redirect SQLite DB to a temp directory ──────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.learner.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
# ── personas.py ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_all_nine_personas_defined():
|
||||
|
||||
@@ -3,16 +3,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
"""Isolate SQLite writes to a temp directory."""
|
||||
db = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db)
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db)
|
||||
yield db
|
||||
|
||||
|
||||
# ── reconcile_on_startup: return shape ───────────────────────────────────────
|
||||
|
||||
def test_reconcile_returns_summary_keys():
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
# ── record_bid ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_record_bid_returns_id():
|
||||
|
||||
@@ -11,14 +11,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
def test_agent_runner_module_is_importable():
|
||||
"""The agent_runner module should import without errors."""
|
||||
import swarm.agent_runner
|
||||
|
||||
Reference in New Issue
Block a user