WP-0001-T014: minimal HTTP app and CLI

src/artifactstore/app.py (new): composition root. build_registry(settings)
wires AsyncEngine + LocalBackend + InProcessDataPlane + RegistryViewWriter
into a Registry. Used by both the HTTP app and the CLI.

src/artifactstore/registry/__init__.py: adds db_health() (SELECT 1 probe),
backend_health() (pass-through to dataplane), and dispose() (engine
shutdown) helpers so the HTTP /health endpoint and CLI commands can talk
to the registry without reaching for private state.

src/artifactstore/api/http/__init__.py:
- create_app(settings=None) factory; lifespan owns the registry instance
  and disposes it on shutdown.
- GET / returns the scaffold banner.
- GET /health reports overall status + db {healthy, detail} + backend
  {backend_id, healthy, detail, free_bytes, total_bytes}. Uses
  FastAPI Depends() with a request->state.registry helper rather than
  reaching app.state directly.
- Module-level `app = create_app()` so `uvicorn artifactstore.api.http:app`
  keeps working.

src/artifactstore/cli/__init__.py:
- migrate: `alembic upgrade head` via the alembic command API.
- replay: drops + rebuilds materialised views from the event log; prints
  the highest applied sequence.
- health: prints the same payload as the HTTP /health endpoint, as JSON.
- version unchanged.

Tests:
- tests/integration/test_http_health.py (TestClient-based): /
  scaffold banner; /health reports ok with db.healthy + backend.healthy
  + free_bytes populated.
- tests/integration/test_cli_commands.py (typer CliRunner): version
  prints; migrate creates the schema (events + retention_classes +
  alembic_version); replay against an empty log exits ok with
  "replayed up to sequence 0"; health prints a status=ok JSON payload.

Gates: ruff clean, mypy --strict clean on 48 files, 83 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 08:56:13 +02:00
parent f59213f512
commit fe47058e1f
7 changed files with 357 additions and 23 deletions

View File

@@ -1,24 +1,80 @@
"""FastAPI application entry point. """FastAPI application — HTTP surface for the registry.
The full registry-aware ``/health`` endpoint lands in T014 ships a minimal app with two routes:
ARTIFACT-STORE-WP-0001-T014. This scaffold exposes a minimal root route so
``make dev`` has something to serve while the registry layer is being built. * ``GET /`` — service banner.
* ``GET /health`` — registry liveness + DB connectivity + storage backend.
Richer endpoints (package CRUD, file upload, manifest retrieval, event
stream) land in workplan WP-0002. The app is built through
:func:`create_app` so tests can inject their own settings.
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI from contextlib import asynccontextmanager
from typing import Any
from fastapi import Depends, FastAPI, Request
from artifactstore import __version__ from artifactstore import __version__
from artifactstore.app import build_registry
from artifactstore.config import Settings
from artifactstore.registry import Registry
app = FastAPI(title="artifact-store", version=__version__) __all__ = ["app", "create_app"]
@app.get("/") def get_registry(request: Request) -> Registry:
def root() -> dict[str, str]: return request.app.state.registry # type: ignore[no-any-return]
"""Return a service banner indicating the scaffold is up."""
return {
"service": "artifact-store", def create_app(settings: Settings | None = None) -> FastAPI:
"version": __version__, """Build the FastAPI app. Lifespan owns the registry instance."""
"status": "scaffold",
} @asynccontextmanager
async def lifespan(application: FastAPI) -> Any:
registry = build_registry(settings)
application.state.registry = registry
try:
yield
finally:
await registry.dispose()
application = FastAPI(
title="artifact-store",
version=__version__,
lifespan=lifespan,
)
@application.get("/")
def root() -> dict[str, str]:
return {
"service": "artifact-store",
"version": __version__,
"status": "scaffold",
}
@application.get("/health")
async def health(registry: Registry = Depends(get_registry)) -> dict[str, Any]:
db_ok, db_detail = await registry.db_health()
backend_status = await registry.backend_health()
overall = "ok" if db_ok and backend_status.healthy else "degraded"
return {
"service": "artifact-store",
"version": __version__,
"status": overall,
"db": {"healthy": db_ok, "detail": db_detail},
"backend": {
"backend_id": backend_status.backend_id,
"healthy": backend_status.healthy,
"detail": backend_status.detail,
"free_bytes": backend_status.free_bytes,
"total_bytes": backend_status.total_bytes,
},
}
return application
app = create_app()

27
src/artifactstore/app.py Normal file
View File

@@ -0,0 +1,27 @@
"""Composition root: build the runtime registry from settings.
The HTTP server, the CLI, and any future host all instantiate the
:class:`Registry` through :func:`build_registry`. Wiring lives here so the
control-plane consumers stay thin (per ADR-0004).
"""
from __future__ import annotations
from artifactstore.config import Settings, get_settings
from artifactstore.dataplane import InProcessDataPlane
from artifactstore.db.engine import create_engine
from artifactstore.events import RegistryViewWriter
from artifactstore.registry import Registry
from artifactstore.storage import LocalBackend
__all__ = ["build_registry"]
def build_registry(settings: Settings | None = None) -> Registry:
"""Wire engine, local FS backend, in-process data plane, and registry."""
effective = settings or get_settings()
engine = create_engine(effective)
backend = LocalBackend(effective.storage_local_root, backend_id="local")
dataplane = InProcessDataPlane(backend)
view_writer = RegistryViewWriter()
return Registry(engine, dataplane, view_writer)

View File

@@ -1,15 +1,28 @@
"""artifact-store command-line interface. """artifact-store command-line interface.
The CLI is a thin consumer of :mod:`artifactstore.registry` (per ADR-0005). The CLI is a thin consumer of :mod:`artifactstore.registry` (per ADR-0005).
The scaffold exposes only ``version``; richer subcommands land in later T014 ships ``version``, ``migrate``, ``replay``, and ``health`` subcommands;
tasks of ARTIFACT-STORE-WP-0001 and follow-on workplans. richer subcommands (e.g. ``push``, ``manifest``) land alongside the HTTP API
in WP-0002.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
import typer import typer
from artifactstore import __version__ from artifactstore import __version__
from artifactstore.config import Settings, get_settings
from artifactstore.db.engine import create_engine
from artifactstore.events import RegistryViewWriter
from artifactstore.events import replay as events_replay
from artifactstore.registry import Registry
__all__ = ["app"]
app = typer.Typer( app = typer.Typer(
help="artifact-store: artifact registry and storage gateway", help="artifact-store: artifact registry and storage gateway",
@@ -19,11 +32,7 @@ app = typer.Typer(
@app.callback() @app.callback()
def main() -> None: def main() -> None:
"""Top-level CLI entry point. """Top-level CLI entry point."""
Forces typer into multi-command mode so subcommands behave consistently
even while the scaffold only ships ``version``.
"""
@app.command() @app.command()
@@ -32,5 +41,74 @@ def version() -> None:
typer.echo(__version__) typer.echo(__version__)
@app.command()
def migrate(
alembic_ini: Path = typer.Option(
Path("alembic.ini"),
"--alembic-ini",
help="Path to alembic.ini (defaults to the repo root).",
),
) -> None:
"""Run ``alembic upgrade head`` against the configured database."""
from alembic import command
from alembic.config import Config
cfg = Config(str(alembic_ini))
command.upgrade(cfg, "head")
typer.echo("alembic upgrade head: ok")
@app.command()
def replay() -> None:
"""Truncate materialised views and replay every event from sequence 1."""
settings = get_settings()
last_seq = asyncio.run(_replay_async(settings))
typer.echo(f"replayed up to sequence {last_seq}")
@app.command()
def health() -> None:
"""Print a JSON liveness summary (db, backend)."""
settings = get_settings()
payload = asyncio.run(_health_async(settings))
typer.echo(json.dumps(payload, indent=2))
# ---- internals -------------------------------------------------------------
async def _replay_async(settings: Settings) -> int:
engine = create_engine(settings)
try:
return await events_replay(engine, RegistryViewWriter(), reset=True)
finally:
await engine.dispose()
async def _health_async(settings: Settings) -> dict[str, Any]:
from artifactstore.app import build_registry
registry: Registry = build_registry(settings)
try:
db_ok, db_detail = await registry.db_health()
backend_status = await registry.backend_health()
finally:
await registry.dispose()
overall = "ok" if db_ok and backend_status.healthy else "degraded"
return {
"service": "artifact-store",
"version": __version__,
"status": overall,
"db": {"healthy": db_ok, "detail": db_detail},
"backend": {
"backend_id": backend_status.backend_id,
"healthy": backend_status.healthy,
"detail": backend_status.detail,
"free_bytes": backend_status.free_bytes,
"total_bytes": backend_status.total_bytes,
},
}
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
app() app()

View File

@@ -25,7 +25,7 @@ from typing import Any
from uuid import UUID from uuid import UUID
import cbor2 import cbor2
from sqlalchemy import select from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
from artifactstore.dataplane.spi import DataPlane from artifactstore.dataplane.spi import DataPlane
@@ -61,6 +61,7 @@ from artifactstore.manifest import (
) )
from artifactstore.manifest.codec import decode as manifest_decode from artifactstore.manifest.codec import decode as manifest_decode
from artifactstore.manifest.projection import jcs_projection from artifactstore.manifest.projection import jcs_projection
from artifactstore.storage.spi import BackendStatus
__all__ = ["Registry"] __all__ = ["Registry"]
@@ -316,6 +317,29 @@ class Registry:
poll_interval_seconds=poll_interval_seconds, poll_interval_seconds=poll_interval_seconds,
) )
# ---- health / lifecycle -------------------------------------------------
async def db_health(self) -> tuple[bool, str]:
"""Probe the database with ``SELECT 1``.
Returns ``(healthy, detail)``; ``detail`` is "ok" on success and the
exception message on failure.
"""
try:
async with self._engine.connect() as conn:
await conn.execute(text("SELECT 1"))
except Exception as exc:
return False, f"{type(exc).__name__}: {exc}"
return True, "ok"
async def backend_health(self) -> BackendStatus:
"""Probe the configured storage backend through the data plane."""
return await self._dataplane.backend_health()
async def dispose(self) -> None:
"""Release the engine's connection pool. Idempotent."""
await self._engine.dispose()
# ---- internals ---------------------------------------------------------- # ---- internals ----------------------------------------------------------
async def _validate_retention_class(self, retention_class: str) -> None: async def _validate_retention_class(self, retention_class: str) -> None:

View File

@@ -0,0 +1,86 @@
"""CLI command tests (ARTIFACT-STORE-WP-0001-T014)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from sqlalchemy import create_engine, insert, inspect
from typer.testing import CliRunner
from artifactstore.cli import app as cli_app
from artifactstore.db.schema import metadata, retention_classes
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
REPO_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def env_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
db_path = tmp_path / "cli-test.db"
storage_root = tmp_path / "storage"
storage_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("ARTIFACTSTORE_DATABASE_URL", f"sqlite+aiosqlite:///{db_path}")
monkeypatch.setenv("ARTIFACTSTORE_STORAGE_LOCAL_ROOT", str(storage_root))
return db_path
def test_cli_version_prints_version(runner: CliRunner) -> None:
result = runner.invoke(cli_app, ["version"])
assert result.exit_code == 0
assert result.output.strip()
def test_cli_migrate_creates_schema(
runner: CliRunner,
env_db: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(REPO_ROOT)
result = runner.invoke(cli_app, ["migrate"])
assert result.exit_code == 0, result.output
assert "ok" in result.output
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
names = set(inspect(sync_engine).get_table_names())
sync_engine.dispose()
assert {"events", "retention_classes", "alembic_version"}.issubset(names)
def test_cli_replay_against_empty_log(
runner: CliRunner,
env_db: Path,
) -> None:
# Pre-create schema (without using migrate command to keep the test
# focused on the replay surface).
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
result = runner.invoke(cli_app, ["replay"])
assert result.exit_code == 0, result.output
assert "replayed up to sequence 0" in result.output
def test_cli_health_reports_ok(
runner: CliRunner,
env_db: Path,
) -> None:
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
metadata.create_all(sync_engine)
sync_engine.dispose()
result = runner.invoke(cli_app, ["health"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "ok"
assert payload["db"]["healthy"] is True
assert payload["backend"]["healthy"] is True

View File

@@ -0,0 +1,63 @@
"""HTTP /health endpoint tests (ARTIFACT-STORE-WP-0001-T014)."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, insert
from artifactstore.api.http import create_app
from artifactstore.config import Settings
from artifactstore.db.schema import metadata, retention_classes
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
db_path = tmp_path / "http-test.db"
storage_root = tmp_path / "storage"
storage_root.mkdir(parents=True, exist_ok=True)
# Build a fresh schema synchronously so the app starts against a
# ready-to-use database.
sync_engine = create_engine(f"sqlite:///{db_path}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
return Settings(
database_url=f"sqlite+aiosqlite:///{db_path}",
storage_local_root=str(storage_root),
log_level="INFO",
)
def test_root_route_returns_banner(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 200
body = resp.json()
assert body["service"] == "artifact-store"
assert body["status"] == "scaffold"
def test_health_endpoint_reports_db_and_backend(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["db"]["healthy"] is True
assert body["db"]["detail"] == "ok"
assert body["backend"]["healthy"] is True
assert body["backend"]["backend_id"] == "local"
assert body["backend"]["free_bytes"] is not None
# NB: a "degraded backend" path test was considered but the LocalBackend
# constructor recreates its root in mkdir(parents=True, exist_ok=True) when
# the lifespan instantiates it. The unhealthy-root branch is covered
# directly in tests/unit/test_storage_local.py without that re-creation.

View File

@@ -262,7 +262,7 @@ Acceptance:
```task ```task
id: ARTIFACT-STORE-WP-0001-T014 id: ARTIFACT-STORE-WP-0001-T014
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "a43628ab-8b53-45fa-852a-ff0118dd12e7" state_hub_task_id: "a43628ab-8b53-45fa-852a-ff0118dd12e7"
``` ```