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:
8
backend/.dockerignore
Normal file
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
85
backend/Dockerfile
Normal file
85
backend/Dockerfile
Normal 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
39
backend/README.md
Normal 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
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Metamorph backend API package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
14
backend/app/api/health.py
Normal file
14
backend/app/api/health.py
Normal 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
24
backend/app/api/v1.py
Normal 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)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
76
backend/app/core/config.py
Normal file
76
backend/app/core/config.py
Normal 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()
|
||||
34
backend/app/core/logging.py
Normal file
34
backend/app/core/logging.py
Normal 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)
|
||||
0
backend/app/i18n/__init__.py
Normal file
0
backend/app/i18n/__init__.py
Normal file
72
backend/app/main.py
Normal file
72
backend/app/main.py
Normal 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
60
backend/pyproject.toml
Normal 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"
|
||||
Reference in New Issue
Block a user