Skip to content

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

  1. Create feature branch:
git checkout -b feature/your-feature-name
  1. Start development environment:
APP_ENV=dev make service:start
  1. Make code changes ; services auto-reload.

  2. Run tests:

make test:unit SERVICE=api-gateway
  1. Run quality checks:
make quality:format
make quality:lint

Working with Services

Access a service shell:

make dev:shell SERVICE=api-gateway

View service logs:

make dev:logs                       # all services
make dev:logs SERVICE=api-gateway   # one service

Restart a single service after a config change:

make service:restart SERVICE=api-gateway

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:

uv sync --all-extras --all-groups

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):

make docker:build SERVICE=devtools
make service:restart SERVICE=devtools
make docs:openapi

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:

make docker:build-base
make docker:build
make service:restart

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:

  1. Validate ; lint, format check, type check.
  2. Build ; build Docker images.
  3. Test ; unit and integration tests.
  4. Deploy ; push images to Codeberg's registry.

The images on Docker Hub come from the GitLab pipeline's publish job (CI/CD).

Codeberg CI pipeline ; full Forgejo Actions run with the validate, build, test, scan, lint, type-check, format-check, publish, license and SAST jobs and the per-service artefact list

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:

# Edit .env
vim .env
# Change APP_ENV=prod to APP_ENV=dev

# Restart to apply
make service:restart

Or use a temporary override:

APP_ENV=dev make service:start

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

  1. Fork the repository.
  2. Create a feature branch.
  3. Make changes with tests.
  4. Run quality checks: make quality:lint && make quality:format-check.
  5. Run tests: make test.
  6. Commit with a clear message.
  7. 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

  1. Ensure all tests pass.
  2. Update documentation.
  3. Add a changelog entry.
  4. Request review.
  5. Address feedback.
  6. 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:

# Add to code
import pdb; pdb.set_trace()

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