Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
567 changes: 567 additions & 0 deletions docs/spec/vla-manager-api.yaml

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions vla-manager-api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
FROM python:3.12-slim AS build

# Install uv (mirrors dva-processing/Dockerfile style)
COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /uvx /bin/

WORKDIR /app/

# Install dependencies (cached layer)
RUN \
--mount=type=cache,target=/root/.cache/uv2 \
--mount=type=bind,source=./vla-manager-api/pyproject.toml,target=pyproject.toml \
uv pip install --system \
"fastapi~=0.136.3" \
"asyncpg>=0.30.0" \
"pydantic>=2.10.6" \
"pydantic-settings>=2.5.0" \
"structlog>=25.1.0" \
"uvicorn>=0.34.3" \
"chevron>=0.14"

# Copy app files
COPY ./vla-manager-api/ /app/

# ----------------------------------------------------------------
FROM python:3.12-slim

# netcat for healthcheck
RUN apt-get update && \
apt-get install -y --no-install-recommends netcat-openbsd=1.* \
&& rm -rf /var/lib/apt/lists/

# Install uvicorn in the runtime stage (mirrors build stage)
COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn

# Copy installed packages + source from build stage
COPY --from=build --chown=app:app /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=build --chown=app:app /app/src /app/src
COPY --from=build --chown=app:app /app/pyproject.toml /app/pyproject.toml

WORKDIR /app

ENV PYTHONPATH=/app/src \
PYTHONUNBUFFERED=1

# Hand-written OpenAPI spec — served at /swagger/openapi.yaml
COPY ./docs/spec/vla-manager-api.yaml /app/openapi.yaml
ENV VLA_MANAGER_OPENAPI_FILE=/app/openapi.yaml

# Run app via uvicorn. ``vla_manager_api.main:app`` is the FastAPI
# instance at module scope (see ``main.py``).
ENTRYPOINT ["uvicorn", "vla_manager_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

EXPOSE 8000/tcp
52 changes: 52 additions & 0 deletions vla-manager-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# VLA Manager API

VLA Manager API is a FastAPI service hosted at the **Data Intermediary** that owns the
Veracity Level Agreements (VLAs) on behalf of all participants.

## Why

In the refactored DVA topology this is the **only** place VLAs live. Each participant's
DVA API no longer stores VLAs in its own Postgres — instead, during the synchronous
attestation flow, the DVA API calls `GET /vla/{id}` over HTTP on this service
to resolve a VLA from its ID.

Separating VLA ownership from the attestation gateway means:
- DVA API shrinks to pure orchestration (HTTP gateway; one role)
- VLAs are authored once and shared across participants
- The VLA Manager Vue UI talks to a single dedicated backend

## Role

| Endpoint | Persona | Purpose |
|---|---|---|
| `GET /vla` | VLA Manager UI, admin | List all VLAs |
| `GET /vla/{id}` | DVA API, UI | Retrieve a VLA by its UUID — used during VLA resolution in the synchronous attestation flow |
| `POST /vla` | VLA Manager UI | Create a VLA from a partial ODCS payload |
| `POST /vla/from-templates` | VLA Manager UI | *Reserved (501)* — implemented in a later refactor step |
| `DELETE /vla` | Admin only | Wipe all VLAs (guarded by `VLA_MANAGER_API_KEY` bearer auth; disabled when key is empty) |

This service intentionally does **not** do evaluation, attestation, or credential issuance —
those are concerns of `dva-processing` and the `dva-vc-manager` respectively.

## Run locally (dev)

```bash
cd data-veracity-main/vla-manager-api
uv sync
uv run pytest # tests (FakeVLARepo, no Postgres needed)
uv run vla-manager-api # boot the gateway on :8000
```

## Run in docker-compose

See `test-env/compose.yml` — the service is wired as `vla-manager-api` on port `9099`
(Data Intermediary) with `VLA_MANAGER_DB_URL=postgresql://postgres-vla:5432/vla`.

## Configuration (.env)

| Var | Default | Purpose |
|---|---|---|
| `VLA_MANAGER_DB_URL` | *(empty)* | Postgres DSN, e.g. `postgresql://vla:vla@postgres:5432/vla` |
| `VLA_MANAGER_API_KEY` | *(empty)* | Shared-secret bearer for `DELETE /vla`. When empty, the endpoint is disabled. |
| `VLA_MANAGER_API_PORT` | `8000` | Listen port |
| `VLA_MANAGER_API_LOG_LEVEL` | `INFO` | Standard Python log-level name |
31 changes: 31 additions & 0 deletions vla-manager-api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[project]
name = "vla-manager-api"
version = "0.1.0"
description = "VLA Manager API — sole owner of Veracity Level Agreements, hosted at the Data Intermediary"
readme = "README.md"
authors = [{ name = "FTSRG", email = "bpeter@edu.bme.hu" }]
license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"asyncpg>=0.30.0",
"fastapi~=0.136.3",
"pydantic>=2.10.6",
"pydantic-settings>=2.5.0",
"structlog>=25.1.0",
"uvicorn>=0.34.3",
"chevron>=0.14",
]
classifiers = ["Private :: Do Not Upload"]

[project.scripts]
vla-manager-api = "vla_manager_api.main:cli"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/vla_manager_api"]

[dependency-groups]
dev = ["pytest>=8.3.5", "httpx>=0.27.0"]
13 changes: 13 additions & 0 deletions vla-manager-api/src/vla_manager_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""VLA Manager API — sole owner of Veracity Level Agreements.

Hosted at the Data Intermediary. Serves the VLA authoring UI and answers
``GET /vla/{id}`` requests from each participant's DVA API during VLA
resolution in the synchronous attestation flow.

This module deliberately does *not* perform any evaluation, attestation
or credential issuance — those are concerns of other components. VLA
Manager API owns only VLAs (and, eventually, VLA templates and the
"test requirements while building VLAs" proxy endpoint).
"""

__version__ = "0.1.0"
27 changes: 27 additions & 0 deletions vla-manager-api/src/vla_manager_api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Minimal Bearer-token auth for destructive endpoints.

Mirrors the Kotlin guard at ``vlaRoutes.kt:129-146``: ``DELETE /vla``
is **disabled entirely** when ``VLA_MANAGER_API_KEY`` is empty, and
requires ``Authorization: Bearer <key>`` otherwise.

This module is intentionally minimal — shared-secret Bearer only. It is
reused by the VLA Manager Vue UI when deleting all VLAs.
"""

from __future__ import annotations

from fastapi import Header, HTTPException, status

from .config import cfg


def require_api_key(authorization: str | None = Header(default=None)) -> None:
"""Dependency that fails-closed when no API key is configured."""
if cfg.api_key == "":
# Gate removed: refuse all wipe attempts rather than allowing
# unauthenticated mass deletion during dev/test runs.
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "API key not configured")

header = (authorization or "").removeprefix("Bearer ").strip()
if header != cfg.api_key:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
62 changes: 62 additions & 0 deletions vla-manager-api/src/vla_manager_api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Runtime configuration for the VLA Manager API.

Read from environment variables (mirrors dva-processing's config style —
plain module-level constants instead of a config dataclass, so the rest
of the codebase can ``from .config import cfg``).
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from sys import stderr


def _truthy(value: str | None) -> bool:
return value is not None and value.lower() in {"1", "true", "yes", "on"}


def _log_level(value: str | None) -> int:
import logging

return getattr(logging, (value or "INFO").upper(), logging.INFO)


@dataclass
class Config:
host: str = os.getenv("VLA_MANAGER_API_HOST", "0.0.0.0")
port: int = int(os.getenv("VLA_MANAGER_API_PORT", "8000"))
log_level: int = _log_level(os.getenv("VLA_MANAGER_API_LOG_LEVEL", "INFO"))

# Postgres DSN. Required for the production (asyncpg) repository.
# Example: postgresql://vla:vla@postgres-vla:5432/vla
postgres_dsn: str = os.getenv("VLA_MANAGER_DB_URL", "")

postgres_user: str = os.getenv("VLA_MANAGER_DB_USER", "")
postgres_password: str = os.getenv("VLA_MANAGER_DB_PASSWORD", "")

# Optional shared-secret bearer token guarding destructive endpoints
# (DELETE /vla). When empty (default), ``DELETE /vla`` is disabled
# entirely — mirrors the dva-api guard at vlaRoutes.kt:129-146.
api_key: str = os.getenv("VLA_MANAGER_API_KEY", "")


cfg = Config()


def setup_logging() -> None:
# Defer structlog import until called so unit tests importing `config`
# don't drag structlog in (keeps test-time imports minimal).
import structlog
from structlog import make_filtering_bound_logger
from structlog.dev import ConsoleRenderer
from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper
from structlog.stdlib import add_log_level

shared = [add_log_level, StackInfoRenderer(), TimeStamper(fmt="iso")]
processors = shared + ([ConsoleRenderer()] if stderr.isatty() else [JSONRenderer()])
structlog.configure(
processors=processors,
context_class=dict,
wrapper_class=make_filtering_bound_logger(cfg.log_level),
)
74 changes: 74 additions & 0 deletions vla-manager-api/src/vla_manager_api/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""FastAPI dependency providers.

Kept separate from :mod:`.main` and :mod:`.routes` to avoid circular
imports — :mod:`.main` imports :mod:`.routes` which imports
:mod:`.dependencies`. Tests override providers via
``app.dependency_overrides``.
"""

from __future__ import annotations

import logging
import os

from .config import cfg
from .repo import FakeTemplateRepo, FakeVLARepo, TemplateRepo, VLARepo

logger = logging.getLogger(__name__)

# Lazy singleton — populated on first request. Tests override via
# ``app.dependency_overrides[get_repo] = lambda: FakeVLARepo()``.
_repo_singleton: VLARepo | None = None


async def get_repo() -> VLARepo:
global _repo_singleton
if _repo_singleton is None:
if cfg.postgres_dsn:
from .main import _build_production_repo

_repo_singleton = await _build_production_repo()
else:
# Dev convenience: boot with an in-memory repo so a bare
# ``uvicorn vla_manager_api.main:app`` works without a
# Postgres. State is lost on restart, so do NOT use this
# mode in production — set VLA_MANAGER_DB_URL.
logger.warning(
"VLA_MANAGER_DB_URL is not set — falling back to FakeVLARepo "
"(in-memory). State will be lost on restart. Configure "
"VLA_MANAGER_DB_URL for production use."
)
_repo_singleton = FakeVLARepo()
return _repo_singleton


def install_repo_for_tests(repo: VLARepo) -> None:
"""Bypass the lazy path and inject a fixed repo for tests."""
global _repo_singleton
_repo_singleton = repo


# --- Template repo (same lazy-singleton pattern) ---
_template_repo_singleton: TemplateRepo | None = None


async def get_template_repo() -> TemplateRepo:
global _template_repo_singleton
if _template_repo_singleton is None:
if cfg.postgres_dsn:
from .main import _build_production_template_repo

_template_repo_singleton = await _build_production_template_repo()
else:
logger.warning(
"VLA_MANAGER_DB_URL is not set — falling back to FakeTemplateRepo "
"(in-memory). State will be lost on restart."
)
_template_repo_singleton = FakeTemplateRepo()
return _template_repo_singleton


def install_template_repo_for_tests(repo: TemplateRepo) -> None:
"""Bypass the lazy path and inject a fixed template repo for tests."""
global _template_repo_singleton
_template_repo_singleton = repo
Loading
Loading