Developer Guide¶
This guide covers local development, testing, and contributing to the SPOT Platform.
Not a deployment guide
The clone-and-build workflow described here is for contributing to the platform. It builds images from your working tree and is not suitable for production, staging, or any environment that is not a pure development sandbox. Operators must follow the Deployment guide instead.
Prerequisites¶
- Docker and Docker Compose
- GNU Make
- Git
The dev loop is driven entirely by make targets that wrap docker compose. The operator-facing spot-cli (auth, plugin, workflow, analyze, config, benchmark) is a separate tool and is not needed to develop the platform.
Getting Started¶
Initial Setup¶
# Clone the platform repository
git clone https://codeberg.org/SPOT_Project/spot.git
cd spot
# Create .env and set SECRET_KEY, SPOT_INTERNAL_API_KEY and ENCRYPTION_KEY
# in it, each to a value of its own (the README gives the commands)
cp .env.example .env
# List available targets
make help
# Start the platform
make service:start
Development Environment¶
Start the development environment with hot reload:
# Set APP_ENV=dev in .env (edit the line: never overwrite .env, which
# holds the keys), then start all services
make service:start
# Or use a temporary override
APP_ENV=dev make service:start
Development mode includes:
- Hot reload (code changes auto-restart services)
- Read-write volume mounts
- Debug tools (Adminer, MailHog)
- Detailed logging
- Development dependencies installed
Core Commands¶
The Makefile wraps docker compose so the everyday loop ; start a stack, get a shell, tail logs, run a linter ; is a single short target rather than a remembered compose incantation. Pass SERVICE=<name> to scope a target to one service; without it the target applies to the whole stack.
# Platform management
make service:start # Start platform
make service:stop # Stop all services
make service:restart # Restart services
make service:status # Show service status
# Development tools
make dev:shell SERVICE=api-gateway # Shell into a service container
make dev:logs # View logs (follow mode)
make dev:logs SERVICE=api-gateway # Service-specific logs
# Code quality
make quality:lint # Run linting
make quality:format # Format code
make quality:type-check # Run type checking
Development Workflow¶
Making Changes¶
- Create feature branch:
- Start development environment:
-
Make code changes ; services auto-reload.
-
Run tests:
- Run quality checks:
Working with Services¶
Access a service shell:
View service logs:
Restart a single service after a config change:
Testing¶
Each service ships with its own pytest suite under services/<name>/tests/; platform-wide tests live under tests/. The Makefile runs them inside the same containers CI uses, so a green run locally is a green run on the pipeline.
Quick Start¶
Pick a service to iterate quickly, or run the whole suite before opening a merge request:
# Test specific service
make test:unit SERVICE=api-gateway
# Test all services
make test:unit
# Integration tests
make test:integration
# Unit + integration
make test
# Unit tests with coverage gate (--cov-fail-under=100)
make test:coverage
Test Structure¶
Each service has its own tests:
services/{service}/
├── src/ # Source code
└── tests/
├── conftest.py # Service fixtures
└── test_*.py # Unit tests
Platform-level tests:
tests/
├── conftest.py # Global configuration
├── fixtures/ # Shared fixtures
├── unit/ # Shared library tests (make test:shared)
├── integration/ # Cross-service tests
└── mocks/ # Mock analyzers and the suite's spot.yaml
Writing Tests¶
A unit test exercises one module of a service against the SDK's models. The analyzer-orchestrator's policy tests, for example, build analyzer outcomes and check the verdict the workflow decides:
# services/analyzer-orchestrator/tests/test_policy.py
def test_decisive_signal_wins_over_score():
misp = outcome(
"misp", "suspicious", score=10, signals=[Signal(name="misp.ioc_match", weight=40)]
)
d = verdict_of([misp, outcome("nlp", "benign", score=5)], decisive=[("misp.*", "malicious")])
assert (d.verdict, d.decided_by, d.score) == ("malicious", "signal:misp.ioc_match", 10)
An integration test submits a raw RFC 5322 message to the running stack and waits for the verdict of the mock analyzers:
# tests/integration/test_email_analysis.py, shortened
async def test_basic_email_analysis(self, authenticated_client, sample_emails):
response = await TestHelpers.submit_message(authenticated_client, sample_emails[0])
assert response.status_code == 200
job = await TestHelpers.wait_for_analysis_completion(
authenticated_client, response.json()["job_id"], timeout=60
)
assert job["status"] == "completed"
assert job["verdict"] == "benign" # a legitimate email
See Testing for the mock analyzers, the fixtures and the test environment.
Test Configuration¶
pytest.ini at the repository root holds the markers, the per-test timeout (60 s) and the coverage settings; the Makefile passes the coverage flags per service.
Coverage¶
make test:coverage runs every service's tests with the --cov-fail-under=100 gate, so any line the tests do not exercise fails the run. The HTML report is the easiest place to see which branches are missing.
# Run tests with coverage (gates at 100% per service)
make test:coverage
# View HTML report
open services/api-gateway/htmlcov/index.html
Coverage files:
- HTML:
services/{service}/htmlcov/index.html - XML:
services/{service}/coverage.xml - Terminal: shown after the run
Test Environments¶
The test targets set APP_ENV=test. Unit tests need no running service; integration tests run against a running stack with two mock analyzers built on the SDK, as Testing describes.
Code Quality¶
Formatting, linting, and type checks all run via the Makefile and use the same configuration CI uses. Running them locally before pushing keeps the pipeline green.
Formatting¶
Ruff handles formatting in place; the format-check variant is the non-mutating check CI runs.
# Format all services
make quality:format
# Format a specific service
make quality:format SERVICE=api-gateway
# Check formatting without modifying
make quality:format-check
Linting¶
Ruff runs the same lint ruleset CI uses; failures here are failures in the pipeline.
# Lint all services
make quality:lint
# Lint a specific service
make quality:lint SERVICE=api-gateway
Type Checking¶
mypy checks every service and the shared library in strict mode, with the one configuration of pyproject.toml ([tool.mypy]). New code is expected to type-check cleanly; CI rejects untyped or mistyped changes.
# Type-check all services and shared
make quality:type-check
# Type-check a specific service, or shared
make quality:type-check SERVICE=api-gateway
make quality:type-check SERVICE=shared
Project Structure¶
Each service lives under services/<name>/ with its source and tests. All services share one pyproject.toml and uv.lock at the root, one Dockerfile (shared/dockerfiles/Dockerfile.service) and the library spot-shared (shared/src/spot_shared/, imported as spot_shared); the cross-service integration tests live at the repository root.
spot/
├── services/
│ ├── api-gateway/ # Users' and plugins' API, intake, plugin installer
│ │ ├── src/ # Source code
│ │ └── tests/ # Unit tests
│ ├── analyzer-orchestrator/ # Workflows, verdict policy, responder dispatch
│ ├── mail-orchestrator/ # Queues retrievers' messages, waits for verdicts
│ ├── knowledge/ # Knowledge Store
│ ├── plugin-manager/ # Runs the plugin containers
│ ├── cli/ # Operator CLI
│ └── devtools/ # Dev and CI tooling image
├── shared/ # spot-shared, the library the services share
│ ├── src/spot_shared/
│ │ ├── config/ # spot.yaml settings, workflows
│ │ ├── database/ # Models, repositories, Alembic migrations
│ │ ├── messaging/ # RabbitMQ
│ │ └── security/ # Encryption, plugin tokens
│ └── dockerfiles/ # Dockerfile.base, Dockerfile.service
├── config/defaults/spot.yaml # Defaults each image ships
├── tests/ # Shared library and integration tests
│ ├── unit/
│ ├── integration/
│ └── mocks/ # Mock analyzers (plugin contract 2)
├── docs/api/ # OpenAPI documents, generated from the code
├── docker-compose.yml # Includes compose/*.yml
├── compose/ # Infrastructure, services, dev tools, test mocks
├── web-dashboard/ # The web dashboard, with its own pyproject.toml and tests
├── deploy/ # The deployment files: compose, .env.example, scripts/spot-cli
├── docs/ # This documentation site
└── Makefile # Dev-loop targets
The plugins themselves (analyzers, context providers, mail retrievers, responders) live in repositories of their own and are built on spot-sdk: see the SDK's Writing plugins guide, and the API documents for the routes core calls and those a plugin may call.
From SPOT 2.1 a plugin, of every egress class, gets no proxy variable and no proxy credentials file: the egress gateway goes through the operator's proxy for it, to the hosts its settings of type url name. The credentials file, /run/secrets/spot_proxy_credentials, is SPOT 2.0's only: 2.0 gave a plugin of class indicators or content the proxy variables, and their user and password in that file, which the SDK puts back into the variables.
Dependencies¶
The repository is one uv workspace, with one uv.lock: the root project and its members shared (spot-shared), services/cli (spot-cli) and web-dashboard. Each service's own dependencies are an extra of the root project, named after the service, which its image installs.
Core's make targets run in the devtools container, whose environment holds every extra and group: each target first syncs it to the checkout, so a change to shared/ or to the lock needs no new image. To work outside the container, in an editor or with a single test, install uv 0.12.22 (the version CI and the images use) and sync a local environment the same way. The root .python-version makes uv use Python 3.11, the images' Python, and download it when the host has none:
The web dashboard's targets (make test, lint, format in web-dashboard/) run with uv on the host, in an environment of their own (web-dashboard/.venv, without core's test plugins), which uv syncs first.
After a change to a dependency, run uv lock and commit uv.lock with the pyproject.toml.
A new SDK release is the exception. Bump spot-sdk-python in the three projects that pin it (the root, shared and services/cli), run uv lock, and move the SDK checkout beside this repository (../sdk) to the new release too: the devtools container and the dev stack mount its sdk/python/spot_sdk over the installed spot_sdk, so local tests and the services run that checkout, not the version in the lock. The mount is read-only, so the devtools container's sync cannot replace the package: rebuild the devtools image and recreate its container. Then regenerate the OpenAPI documents, which embed the SDK's models (the openapi job fails on any difference):
A development stack whose images were built before the uv workspace (2.0.0-beta.8 and earlier) needs new ones once. make docker:build reuses an existing spot-base:latest, and an older one has no uv, so rebuild the base first:
Building and Docker¶
Images are built with multi-stage Dockerfiles on top of the shared base image. Day to day you rarely need to build by hand ; make service:start builds anything that is out of date ; but the targets below are useful when the base image has changed or when you want to push a one-off tag.
Build Images¶
# Build all services
make docker:build
# Build a specific service
make docker:build SERVICE=api-gateway
# Build the shared base image
make docker:build-base
Docker Compose¶
These are the most common compose operations during development, wrapped behind Makefile targets so they look the same on every workstation:
# Start services
make service:start
# View logs
make dev:logs
# Stop services
make service:stop
# Clean up caches and dangling artefacts
make dev:clean
CI/CD¶
Every push runs the pipeline; running it locally first catches the failures that would otherwise show up minutes later in the runner.
Local CI Testing¶
Run GitLab CI locally:
# Run the default pipeline
make ci:local
# Run a specific job
make ci:local JOB="test-services: [api-gateway]"
# Run the full pipeline including manual jobs
make ci:local FULL=1
See CI/CD for details.
Codeberg CI¶
Codeberg runs a port of the pipeline with Forgejo Actions (.forgejo/workflows/ci.yml) on each push to main:
- Validate ; lint, format check, type check.
- Build ; build Docker images.
- Test ; unit and integration tests.
- Deploy ; push images to Codeberg's registry.
The images on Docker Hub come from the GitLab pipeline's publish job (CI/CD).
Environment Management¶
SPOT uses APP_ENV to control the environment:
prod; production (default).dev; development with hot reload.test; testing (set automatically by the test targets).
Switch environments:
Or use a temporary override:
See Environment Configuration for details.
Contributing¶
Patches land via merge requests against main. The expectations below are what reviewers look for; staying inside them keeps reviews fast.
Workflow¶
- Fork the repository.
- Create a feature branch.
- Make changes with tests.
- Run quality checks:
make quality:lint && make quality:format-check. - Run tests:
make test. - Commit with a clear message.
- Push and create a merge request.
Commit Messages¶
Use conventional commits ; the type tag tells reviewers and changelog tooling at a glance whether a commit is a feature, a fix, docs, or a refactor.
feat: Add support for attachment analysis
fix: Resolve authentication timeout issue
docs: Update API documentation
test: Add integration tests for workflows
refactor: Simplify configuration loading
Code Style¶
- Follow PEP 8.
- Use type hints.
- Write docstrings for public APIs.
- Keep functions small and focused.
- Add tests for new features.
Pull Request Process¶
- Ensure all tests pass.
- Update documentation.
- Add a changelog entry.
- Request review.
- Address feedback.
- Merge after approval.
Debugging¶
Most problems show up first in the logs; the rest typically need either debug-level logging or an interactive debugger. The recipes below cover both.
Debug Mode¶
Raising the log level surfaces the trace and debug lines that are silenced by default. Useful when a request is failing without an obvious error in the standard logs.
# Edit .env
LOG_LEVEL=DEBUG
LOG_FORMAT=text # a readable line instead of JSON
# Restart
make service:restart
Interactive Debugging¶
Use the Python debugger:
Or use an IDE debugger (VS Code, PyCharm).
Common Issues¶
A handful of failures account for most of the "why won't this start" moments. The recipes below diagnose them without needing to dig into the platform internals.
Service won't start:
# Check logs
make dev:logs SERVICE=api-gateway
# Check status
make service:status
# Restart service
make service:restart
Tests failing:
# The test targets set APP_ENV=test themselves: run them through make,
# never pytest directly
make test:unit SERVICE=api-gateway
make test:integration
Hot reload not working:
# Ensure dev environment
grep APP_ENV .env # Should be dev
# Check volumes are mounted
docker compose config | grep volumes
Additional Resources¶
- API Reference ; API documentation.
- Environment Configuration ; environment variables.
- Configuration ; configuration options.
- Architecture ; system architecture.
