feat(m0): bootstrap repo, design system, compose stack

- Repo scaffolding: .gitignore, .env.example, Makefile, docker-compose.yml,
  README.md, CHANGELOG.md, pre-commit config.
- Three-service stack: api (Flask 3), db (postgres:16-alpine), front (nginx
  serving the Vite bundle). Named volumes metamorph_db + metamorph_evidence.
- Backend skeleton: Flask app factory, JSON structured logging on stdout,
  GET /api/v1/health, multi-stage Dockerfile, pyproject.toml driven by uv,
  Pydantic Settings with secret guard rails (refuses to boot in non-dev with
  placeholders), APP_ENV gating.
- Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps
  design tokens from tasks/design.md, self-hosted JetBrains Mono / IBM Plex
  Sans via @fontsource, base UI primitives (Card/Tag/SectionHeader/FlowNode/
  Button), home page wired to /api/v1/health.
- Engine-agnostic Makefile: auto-detects docker or podman, picks the matching
  compose driver. Targets: up/down/build/rebuild/dev/lint/fmt/test/migrate/
  seed-mitre/print-install-token/e2e/inspect-health.
- Playwright suite: e2e/tests/m0-smoke.spec.ts (8 tests) + HTML + JUnit
  reports + traces on retry.
- Docs: tasks/spec.md (finalized after Q&A), tasks/design.md, tasks/todo.md
  (14 milestones), tasks/testing-m0.md, tasks/lessons.md.

DoD: make up + make health + make e2e all pass on podman 5.x (Fedora) and
docker. TLS terminated by external reverse proxy (spec §6 NF-network).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-11 06:16:00 +02:00
commit f1fdf27012
58 changed files with 5365 additions and 0 deletions

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.venv/
.env
.env.*
!.env.example

85
backend/Dockerfile Normal file
View File

@@ -0,0 +1,85 @@
# syntax=docker/dockerfile:1.7
# === Stage 1: install deps with uv ===
FROM docker.io/library/python:3.12-slim AS deps
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install uv (fast, reproducible Python package manager)
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml ./
# Resolve & install deps into a dedicated venv. After running `uv lock` locally,
# switch this to `uv sync --frozen --no-dev` for fully reproducible builds.
RUN uv venv /opt/venv \
&& uv pip install --python /opt/venv/bin/python --no-cache .
# === Stage 2: runtime ===
FROM docker.io/library/python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# Non-root user
RUN groupadd --gid 10001 metamorph \
&& useradd --uid 10001 --gid metamorph --shell /usr/sbin/nologin --create-home metamorph \
&& mkdir -p /data/evidence \
&& chown -R metamorph:metamorph /data
COPY --from=deps /opt/venv /opt/venv
WORKDIR /app
COPY --chown=metamorph:metamorph app ./app
COPY --chown=metamorph:metamorph alembic ./alembic
COPY --chown=metamorph:metamorph alembic.ini pyproject.toml ./
USER metamorph
EXPOSE 8000
# Healthcheck hits the local API.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; \
sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)"
CMD ["gunicorn", "app.main:app", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--threads", "4", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"--log-level", "info"]
# === Stage 3: test image — runtime deps + dev extras + tests dir ===
# Built only when explicitly targeted (`build --target test`). Not used in prod.
FROM docker.io/library/python:3.12-slim AS test
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
COPY --from=deps /opt/venv /opt/venv
WORKDIR /app
COPY pyproject.toml ./
# Install the dev extras (pytest, ruff, httpx) on top of the runtime venv.
RUN uv pip install --python /opt/venv/bin/python --no-cache ".[dev]"
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
COPY tests ./tests
CMD ["python", "-m", "pytest", "tests", "-v"]

39
backend/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Metamorph backend
Flask 3 API. See repo root `README.md` for the big picture.
## Layout
```
app/
├── api/ # HTTP layer (blueprints), versioned under /api/v1
├── core/ # config (env-driven), structured logging
├── db/ # SQLAlchemy session + Alembic (M1+)
├── models/ # ORM models (M1+)
├── services/ # domain logic (M2+)
└── i18n/ # message catalogs (M13)
tests/ # pytest
```
## Local dev
Requires [uv](https://github.com/astral-sh/uv) and a reachable Postgres (M1+; not needed yet for `/health`).
```bash
uv sync # install deps from pyproject.toml
uv run flask --app app.main run --debug --port 8000
curl http://localhost:8000/api/v1/health
```
## Tests
```bash
uv run pytest
```
## Lint
```bash
uv run ruff check .
uv run ruff format .
```

3
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Metamorph backend API package."""
__version__ = "0.1.0"

View File

14
backend/app/api/health.py Normal file
View File

@@ -0,0 +1,14 @@
"""Health endpoint — no DB dependency, used by orchestrators and the SPA."""
from __future__ import annotations
from flask import Blueprint, jsonify
from app import __version__
bp = Blueprint("health", __name__)
@bp.get("/health")
def health():
return jsonify({"status": "ok", "version": __version__})

24
backend/app/api/v1.py Normal file
View File

@@ -0,0 +1,24 @@
"""Aggregate v1 blueprint. Future blueprints (missions, ...) register here."""
from __future__ import annotations
from flask import Blueprint
from app.api.auth import bp as auth_bp
from app.api.diag import bp as diag_bp
from app.api.groups import bp as groups_bp
from app.api.health import bp as health_bp
from app.api.invitations import bp as invitations_bp
from app.api.permissions import bp as permissions_bp
from app.api.setup import bp as setup_bp
from app.api.users import bp as users_bp
bp = Blueprint("v1", __name__, url_prefix="/api/v1")
bp.register_blueprint(health_bp)
bp.register_blueprint(diag_bp)
bp.register_blueprint(setup_bp)
bp.register_blueprint(auth_bp)
bp.register_blueprint(invitations_bp)
bp.register_blueprint(users_bp)
bp.register_blueprint(groups_bp)
bp.register_blueprint(permissions_bp)

View File

View File

@@ -0,0 +1,76 @@
"""Runtime configuration loaded from environment variables."""
from __future__ import annotations
from typing import Literal
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Sentinel values that .env.example ships with. If the runtime is configured
# in a non-dev environment with one of these still in place, we refuse to boot.
_DEV_JWT_SECRET = "change-me-to-a-long-random-string"
_DEV_DB_PASSWORD = "change-me-strong"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
)
# === Runtime mode ===
# Set to "dev" to allow the default placeholder secrets. Anything else
# (e.g. "prod", "staging") forces strong values.
APP_ENV: Literal["dev", "prod", "staging", "test"] = "prod"
# === Postgres ===
POSTGRES_DB: str = "metamorph"
POSTGRES_USER: str = "metamorph"
POSTGRES_PASSWORD: str = ""
POSTGRES_HOST: str = "db"
POSTGRES_PORT: int = 5432
# === API ===
JWT_SECRET: str = Field(default="", min_length=0)
LOG_LEVEL: str = "INFO"
FRONT_ORIGIN: str = "http://localhost:8080"
EVIDENCE_DIR: str = "/data/evidence"
@property
def cors_origins(self) -> list[str]:
return [o.strip() for o in self.FRONT_ORIGIN.split(",") if o.strip()]
@property
def database_url(self) -> str:
"""SQLAlchemy URL using the psycopg3 driver."""
return (
f"postgresql+psycopg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@model_validator(mode="after")
def _enforce_secret_strength(self) -> "Settings":
"""Refuse to boot in prod/staging if secrets are missing or default.
`dev` and `test` are explicitly exempted so workstations and the
ephemeral test container don't need real secrets.
"""
if self.APP_ENV in ("dev", "test"):
return self
if not self.JWT_SECRET or self.JWT_SECRET == _DEV_JWT_SECRET or len(self.JWT_SECRET) < 32:
raise ValueError(
"JWT_SECRET is missing, default, or shorter than 32 chars. "
"Set APP_ENV=dev to bypass for local development."
)
if not self.POSTGRES_PASSWORD or self.POSTGRES_PASSWORD == _DEV_DB_PASSWORD:
raise ValueError(
"POSTGRES_PASSWORD is missing or default. "
"Set APP_ENV=dev to bypass for local development."
)
return self
settings = Settings()

View File

@@ -0,0 +1,34 @@
"""JSON structured logging on stdout."""
from __future__ import annotations
import logging
import sys
from pythonjsonlogger import jsonlogger
def configure_logging(level: str = "INFO") -> None:
"""Replace the root handler with a single JSON stdout handler.
Fields emitted: ts, level, name, msg, plus any extras passed via `logger.X(..., extra={...})`.
"""
root = logging.getLogger()
root.setLevel(level.upper())
# Drop any pre-existing handlers (uvicorn/gunicorn add their own).
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler(sys.stdout)
formatter = jsonlogger.JsonFormatter(
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
rename_fields={"asctime": "ts", "levelname": "level", "name": "logger"},
json_ensure_ascii=False,
)
handler.setFormatter(formatter)
root.addHandler(handler)
# Tame the noisy third parties unless explicitly debugging.
if level.upper() != "DEBUG":
logging.getLogger("werkzeug").setLevel(logging.WARNING)

View File

72
backend/app/main.py Normal file
View File

@@ -0,0 +1,72 @@
"""Flask application factory and WSGI entry point."""
from __future__ import annotations
import logging
from flask import Flask
from flask_cors import CORS
from app.api.v1 import bp as v1_bp
from app.core.config import settings
from app.core.install_token import (
ensure_install_token,
log_install_token_banner,
)
from app.core.logging import configure_logging
from app.core.rate_limit import limiter
from app.services.bootstrap import ensure_system_groups
from app.services.permissions_seed import seed_all as seed_permissions_and_bindings
def _try_bootstrap_at_boot(log: logging.Logger) -> None:
"""Best-effort: seed system groups + mint an install token if needed.
Wrapped in try/except because the DB may not be ready (or schema not
migrated yet) at the very first boot — gunicorn must still come up so the
operator can run `make migrate` and curl /setup afterwards.
"""
try:
ensure_system_groups()
seed_permissions_and_bindings()
token = ensure_install_token()
if token is not None:
log_install_token_banner(token)
else:
log.info("metamorph.bootstrap.skipped")
except Exception as e:
log.warning("metamorph.bootstrap.deferred", extra={"error": str(e)})
def create_app() -> Flask:
configure_logging(settings.LOG_LEVEL)
log = logging.getLogger("metamorph.boot")
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 MB hard cap; per-file limit is 25 MB.
CORS(
app,
origins=settings.cors_origins,
supports_credentials=True,
max_age=600,
)
limiter.init_app(app)
app.register_blueprint(v1_bp)
log.info(
"metamorph.api.boot",
extra={
"cors_origins": settings.cors_origins,
"log_level": settings.LOG_LEVEL,
"evidence_dir": settings.EVIDENCE_DIR,
},
)
_try_bootstrap_at_boot(log)
return app
# WSGI entry point used by gunicorn (`gunicorn app.main:app`).
app = create_app()

60
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,60 @@
[project]
name = "metamorph-api"
version = "0.1.0"
description = "Metamorph backend API — collaborative purple-team platform."
requires-python = ">=3.12"
license = { text = "Proprietary" }
dependencies = [
"flask>=3.0,<4.0",
"flask-cors>=4.0,<5.0",
"flask-limiter>=3.7,<4.0",
"pydantic[email]>=2.6,<3.0",
"pydantic-settings>=2.2,<3.0",
"python-json-logger>=2.0,<3.0",
"gunicorn>=21.2,<22.0",
"sqlalchemy>=2.0,<3.0",
"alembic>=1.13,<2.0",
"psycopg[binary]>=3.1,<4.0",
"argon2-cffi>=23.1,<25.0",
"pyjwt>=2.8,<3.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0,<9.0",
"pytest-cov>=5.0,<6.0",
"ruff>=0.4,<1.0",
"httpx>=0.27,<1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"SIM", # flake8-simplify
"RUF", # ruff-specific
]
ignore = ["E501"] # line length handled by formatter
[tool.ruff.format]
quote-style = "double"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"