Skip to content

Plugin Deployment

Martha supports first-party plugins — standalone FastAPI services that register via OpenAPI specs and are accessible through the admin UI's BFF proxy.

This guide covers deploying a new plugin service end-to-end.


Architecture

nginx (443)
  /api/*         → martha-api:8000
  /docs, /redoc  → martha-api:8000
  /*             → slo-pwa:80 (SPA)

martha-api:8000
  /api/admin/plugins/{id}/{path}        → BFF proxy → plugin:8000/{path}
  /api/admin/definitions/integrations   → health probe → plugin:8000/health

plugin:8000  (Docker-internal only, not exposed to nginx)
  /health, /openapi.json
  /rubrics/*  (resource endpoints)
  /score      (action endpoints)

Plugins are not exposed directly through nginx. The admin UI reaches them via the BFF proxy, which injects X-Tenant-Id and enforces auth.


Step 1: OpenAPI Extensions

The plugin's OpenAPI spec must include martha extensions at the root level of the schema:

python
# openapi_overrides.py
def apply_openapi_overrides(app):
    original_openapi = app.openapi

    def custom_openapi():
        if app.openapi_schema:
            return app.openapi_schema
        schema = original_openapi()

        # Root level — NOT under schema["info"]
        schema["x-martha-integration"] = {
            "type": "plugin",
            "name": "Scoring Engine",
            "icon": "target",
            "color": "#8b5cf6",
        }
        schema["x-martha-plugin"] = {
            "resources": [
                {"name": "rubrics", "path": "/rubrics"},
            ],
        }

        app.openapi_schema = schema
        return schema

    app.openapi = custom_openapi

!!! warning "Extensions must be at the spec root" extract_plugin_manifest() reads from spec.get("x-martha-plugin"). Nesting under schema["info"] causes sync to wipe the manifest.

Action endpoints should include x-martha-action: true so they get imported as Martha functions during sync.


Step 2: Dockerfile

dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=15s \
    CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

curl is required for the Docker HEALTHCHECK command.


Step 3: Docker Compose

Add the service to deployment/docker-compose.yml:

yaml
martha-scoring:
  image: ghcr.io/colivetree/martha-scoring:latest
  container_name: martha-scoring
  restart: always
  environment:
    - SCORING_DB_HOST=${DB_HOST}
    - SCORING_DB_PORT=${DB_PORT:-25060}
    - SCORING_DB_NAME=martha_scoring
    - SCORING_DB_USER=${DB_USER:-martha}
    - SCORING_DB_PASSWORD=${DB_PASSWORD}
    - SCORING_DB_SSLMODE=${DB_SSLMODE:-require}
  networks:
    - martha-network
  expose:
    - "8000"
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
    interval: 30s
    timeout: 10s
    retries: 3
    start_period: 15s

!!! note "Use expose, not ports" Plugins are internal services. They're only reached via the BFF proxy on martha-network.

The plugin can share the same managed database server as Martha but should use a separate database (e.g. martha_scoring vs martha) to avoid migration conflicts.


Step 4: CI/CD

Add to .github/workflows/deploy.yml:

Test job (runs in parallel with existing tests):

yaml
test-scoring:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        repository: colivetree/martha-scoring
        token: ${{ secrets.GHCR_PAT }}
    - uses: actions/setup-python@v5
      with: { python-version: '3.11' }
    - run: pip install -r requirements.txt
    - run: pytest tests/ -v

Docker image build (add to existing docker-images job):

yaml
- uses: actions/checkout@v4
  with:
    repository: colivetree/martha-scoring
    token: ${{ secrets.GHCR_PAT }}
    path: martha-scoring
- uses: docker/build-push-action@v5
  with:
    context: martha-scoring
    push: true
    tags: ghcr.io/colivetree/martha-scoring:latest

Update the needs array to include the new test job.


Step 5: Deploy Script

Add a stage to deployment/deploy-prod.sh after Martha's migrations:

bash
# Wait for container health
echo "Waiting for scoring container..."
for i in {1..30}; do
  if docker inspect --format='{{.State.Health.Status}}' martha-scoring 2>/dev/null \
     | grep -q healthy; then
    echo "Scoring service healthy"
    break
  fi
  sleep 2
done

# Auto-create database on shared server
docker compose exec -T martha-db psql -U $DB_USER -d postgres \
  -tc "SELECT 1 FROM pg_database WHERE datname = 'martha_scoring'" | grep -q 1 \
  || docker compose exec -T martha-db psql -U $DB_USER -d postgres \
     -c "CREATE DATABASE martha_scoring OWNER $DB_USER"

# Run migrations
docker compose exec -T martha-scoring alembic upgrade head

# Register plugin in Martha
docker compose exec -T martha-api python scripts/seed_plugins.py

Step 6: Plugin Seed

Add an entry to scripts/seed_plugins.py:

python
PLUGINS = [
    {
        "name": "martha-scoring",
        "source_url": "http://martha-scoring:8000/openapi.json",
        "plugin_manifest": {
            "plugin": {"name": "martha-scoring", "version": "0.1.0"},
            "integration": {
                "name": "Scoring Engine",
                "icon": "target",
                "color": "#8b5cf6",
                "description": "Rubric-based scoring and ranking",
            },
            "resources": [{"name": "rubrics", "path": "/rubrics"}],
        },
    },
]

The seed is idempotent — it skips if the manifest already matches, and restores it if sync wiped it.

!!! warning "The name field matters" The frontend's PLUGIN_ID must match this name exactly. The BFF proxy does WHERE name = plugin_id to look up the service URL.


Step 7: Frontend Plugin

Register the plugin in martha-admin/src/plugins/:

=== "manifest.ts" ```typescript import { lazy } from "react"; import type { PluginManifest } from "../types";

const RubricsListPage = lazy(() => import("./pages/RubricsListPage"));

export const scoringPlugin: PluginManifest = {
  id: "scoring",
  name: "Scoring",
  icon: "Target",
  color: "#8b5cf6",
  routes: [
    { path: "plugins/scoring/rubrics", element: RubricsListPage, label: "Rubrics" },
  ],
};
```

=== "hooks/useRubrics.ts" ```typescript const PLUGIN_ID = "martha-scoring"; // must match DB name, NOT the manifest id

export function useRubrics() {
  return useQuery({
    queryKey: ["scoring-rubrics", tenantId()],
    queryFn: () => scoringAPI.rubrics.list(PLUGIN_ID, tenantId()),
  });
}
```

=== "registry.ts" typescript import { scoringPlugin } from "./scoring"; export const plugins: PluginManifest[] = [scoringPlugin];

The id in the manifest ("scoring") is used for frontend routing (/plugins/scoring/...). The PLUGIN_ID in hooks ("martha-scoring") is the DB spec name used for BFF proxy lookups.


Known Gotchas

IssueCauseFix
"Plugin not found" 404Frontend PLUGIN_ID doesn't match DB openapi_specs.nameUse exact DB name (e.g. "martha-scoring")
"Plugin has invalid source URL"Docker DNS resolves to 172.x.x.x (blocked by SSRF)SSRF is skipped for specs with plugin_manifest
Plugin reverts to "connected" after syncx-martha-plugin extensions not at spec rootMove extensions to root of OpenAPI schema
Proxy requests go to /openapi.json/rubricssource_url includes spec filenameProxy strips .json suffix before building target URL
/docs shows frontend instead of API docsnginx SPA catch-all serves all non-/api/ pathsAdd explicit proxy rules for /docs, /redoc, /openapi.json
Manifest disappears after syncimport_from_url() overwrites with NoneGuard: only overwrite when extract_plugin_manifest() returns non-None

Martha is built by aiaiai-pt.