gitea-saas-core#38: assemble one create_app() + entrypoints (wsgi, console script, python -m)
This commit is contained in:
parent
71cc2634ce
commit
0f9d2a9b30
6
gitea_saas/__main__.py
Normal file
6
gitea_saas/__main__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""``python -m gitea_saas`` dev server (issue #38)."""
|
||||
|
||||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
gitea_saas/app.py
Normal file
147
gitea_saas/app.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""Assembly: one ``create_app()`` + the entrypoints for the whole service (#38).
|
||||
|
||||
The 15 modules on ``main`` compose into a single deployable Flask app in
|
||||
exactly one place:
|
||||
|
||||
billing API ``gitea_saas.server.create_app`` (issues #1/#2/#6)
|
||||
factory ``gitea_saas.intake_factory.register_factory_routes`` (#4)
|
||||
dashboard ``gitea_saas.dashboard.mount_dashboard`` (issue #9)
|
||||
|
||||
Assembly only: this module makes no behaviour changes to billing, factory,
|
||||
dashboard or review. If composing them ever surfaces a genuine
|
||||
incompatibility it is reported on the issue, not papered over here.
|
||||
|
||||
Fail-closed assembly: when ``SAAS_API_KEY`` is unset the app still starts
|
||||
(``python -m gitea_saas``, ``gitea-saas`` console script, gunicorn via the
|
||||
repo-root ``wsgi.py``) but every authed route returns 503
|
||||
``SERVICE_UNAVAILABLE``. No second auth path is invented here: key
|
||||
validation stays in ``gitea_saas.auth`` (issue #24) and each front-end's
|
||||
own ``auth.key_is_valid`` guard (canonical 401) applies unchanged as soon
|
||||
as a key IS configured. The 503 gate exists only at this assembly layer,
|
||||
and only for the unconfigured state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
from .auth import PUBLIC_PATHS
|
||||
from .billing_core import BillingCore
|
||||
from .config import ServerConfig, load_config
|
||||
from .dashboard import mount_dashboard
|
||||
from .gitea_client import GiteaClient
|
||||
from .intake_factory import register_factory_routes
|
||||
from .server import create_app as create_billing_app
|
||||
|
||||
# Canonical body for the unconfigured-deployment gate (status 503). Same
|
||||
# ``{"error": {"code", "message"}}`` shape as ``auth.UNAUTHORIZED_BODY`` so
|
||||
# clients parse every front-end's auth failure identically.
|
||||
UNAVAILABLE_BODY = {
|
||||
"error": {
|
||||
"code": "SERVICE_UNAVAILABLE",
|
||||
"message": "SAAS_API_KEY is unset: the service is not configured "
|
||||
"for authenticated use (fail-closed)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_db_path(cfg: ServerConfig) -> Path:
|
||||
"""The runtime SQLite path shared by billing, factory and dashboard.
|
||||
|
||||
``config.py`` defaults an unset ``SAAS_DB_PATH`` to in-memory, but the
|
||||
factory and the dashboard open their OWN connections to the same file,
|
||||
so an ephemeral FILE (in a fresh temp dir) keeps all three components
|
||||
on one coherent dataset for the no-env dev run. No new env vars: this
|
||||
is the assembler's default for the existing unset state.
|
||||
"""
|
||||
if cfg.db_path is not None:
|
||||
return Path(cfg.db_path)
|
||||
scratch = Path(tempfile.mkdtemp(prefix="gitea-saas-"))
|
||||
return scratch / "saas.db"
|
||||
|
||||
|
||||
def _unconfigured_guard(configured: bool):
|
||||
"""before_request that fail-closes an UNCONFIGURED deployment.
|
||||
|
||||
Registered ONLY when no API key is configured, and inserted ahead of
|
||||
``server.create_app``'s own before_request so the unconfigured state
|
||||
is reported as 503 (this issue's acceptance 2: not 500, not 200)
|
||||
instead of the front-ends' 401s. It performs no key comparison of its
|
||||
own -- validation stays in ``gitea_saas.auth.key_is_valid`` (issue
|
||||
#24) -- and it fires only for non-public paths, so the liveness routes
|
||||
in ``auth.PUBLIC_PATHS`` (/health, /healthz, /api/factory/health)
|
||||
keep serving. With a configured key this guard is never installed.
|
||||
"""
|
||||
if configured:
|
||||
return None
|
||||
|
||||
def _guard():
|
||||
if request.path.rstrip("/") in PUBLIC_PATHS:
|
||||
return None
|
||||
resp = jsonify(UNAVAILABLE_BODY)
|
||||
resp.status_code = 503
|
||||
return resp
|
||||
|
||||
return _guard
|
||||
|
||||
|
||||
def create_app(config: Optional[ServerConfig] = None) -> Flask:
|
||||
"""Build the ONE Flask app: billing + factory + dashboard mounted.
|
||||
|
||||
``config`` is a :class:`~gitea_saas.config.ServerConfig` (or any object
|
||||
exposing ``api_key`` / ``port`` / ``db_path``); when None the
|
||||
environment is loaded through the existing ``config.load_config``
|
||||
loader (no new env vars).
|
||||
|
||||
The DB is opened via ``gitea_saas.db`` (through ``BillingCore``, which
|
||||
owns the connection + idempotent schema bootstrap) and shared by all
|
||||
three front-ends. With no ``SAAS_API_KEY`` the app still starts;
|
||||
authed routes return 503 until a key is configured (module docstring).
|
||||
"""
|
||||
cfg = config if config is not None else load_config()
|
||||
api_key = getattr(cfg, "api_key", None) or ""
|
||||
db_path = _resolve_db_path(cfg)
|
||||
core = BillingCore(db_path)
|
||||
|
||||
# The factory's IntakeFactory is built per request and reads
|
||||
# ``config.db_path`` (env fallback is the pre-assembly behaviour);
|
||||
# hand it the RESOLVED path so the per-request construction cannot
|
||||
# raise "IntakeFactory requires config.db_path" for the no-env case.
|
||||
runtime = SimpleNamespace(db_path=db_path, api_key=api_key)
|
||||
|
||||
app = create_billing_app(core, api_key)
|
||||
register_factory_routes(app, core, GiteaClient(), runtime)
|
||||
mount_dashboard(app, core=core, db_path=db_path, api_key=api_key)
|
||||
|
||||
guard = _unconfigured_guard(bool(api_key))
|
||||
if guard is not None:
|
||||
# Global before_request funcs run in list order; position 0 puts
|
||||
# the unconfigured gate ahead of server.create_app's own guard.
|
||||
# A returning response short-circuits the rest of the request, so
|
||||
# authed routes report the deployment state before any front-end
|
||||
# 401 logic runs. When a key IS configured this list is never
|
||||
# touched and the front-ends' auth.key_is_valid guards decide.
|
||||
app.before_request_funcs.setdefault(None, []).insert(0, guard)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Dev entrypoint (``python -m gitea_saas`` / ``gitea-saas`` script).
|
||||
|
||||
Starts even with no ``SAAS_API_KEY`` (fail-closed 503, per the issue);
|
||||
binds loopback like the pre-assembly server entrypoint. Production
|
||||
runs under gunicorn against the repo-root ``wsgi.py`` instead.
|
||||
"""
|
||||
cfg = load_config()
|
||||
app = create_app(cfg)
|
||||
app.run(host="127.0.0.1", port=cfg.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -3,6 +3,12 @@ name = "gitea-saas-core"
|
||||
version = "0.1.0"
|
||||
description = "Shared billing core (clients/ledger/holds, idempotent, fail-closed) for gitea-saas"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"flask>=3.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gitea-saas = "gitea_saas.app:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
163
tests/test_app.py
Normal file
163
tests/test_app.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""Tests for the assembled app (issue #38): one ``create_app()`` + entrypoints.
|
||||
|
||||
Hermetic: temp SQLite DBs, Flask test client, no network / Stripe / Gitea.
|
||||
|
||||
Acceptance contract exercised here:
|
||||
1. ``create_app()``'s URL map contains the billing routes, the
|
||||
``/api/factory/*`` routes, ``/dashboard`` and ``/healthz`` (asserted
|
||||
on the URL map, not by requesting each one).
|
||||
2. Starting the app with NO ``SAAS_API_KEY`` still starts, and every
|
||||
authed route returns 503 -- not 500 and not 200; the liveness routes
|
||||
stay public.
|
||||
3. ``python -m gitea_saas`` serves ``/health`` locally (verified live as
|
||||
part of the PR's test evidence; not a subprocess test here).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gitea_saas import app as app_module
|
||||
from gitea_saas.app import create_app
|
||||
from gitea_saas.auth import API_KEY_HEADER, UNAUTHORIZED_BODY
|
||||
from gitea_saas.config import ServerConfig
|
||||
|
||||
API_KEY = "test-api-key-secret"
|
||||
AUTH = {API_KEY_HEADER: API_KEY}
|
||||
|
||||
|
||||
def _cfg(tmp_path: Path, api_key: str | None = API_KEY) -> ServerConfig:
|
||||
return ServerConfig(api_key=api_key, port=8080, db_path=tmp_path / "saas.db")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(tmp_path: Path):
|
||||
application = create_app(_cfg(tmp_path))
|
||||
application.config.update(TESTING=True)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _rules(app) -> set[str]:
|
||||
return {str(r.rule) for r in app.url_map.iter_rules()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Acceptance 1: the URL map composes every front-end
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_url_map_contains_billing_routes(app):
|
||||
rules = _rules(app)
|
||||
for expected in (
|
||||
"/api/clients",
|
||||
"/api/clients/<int:client_id>",
|
||||
"/api/clients/<int:client_id>/credits",
|
||||
"/api/clients/<int:client_id>/debits",
|
||||
"/api/clients/<int:client_id>/holds",
|
||||
"/api/holds/<int:hold_id>/release",
|
||||
"/api/clients/<int:client_id>/ledger",
|
||||
):
|
||||
assert expected in rules, f"missing billing route {expected}"
|
||||
|
||||
|
||||
def test_url_map_contains_factory_routes(app):
|
||||
rules = _rules(app)
|
||||
for expected in (
|
||||
"/api/factory/health",
|
||||
"/api/factory/provision",
|
||||
"/api/factory/<int:client_id>/issues",
|
||||
"/api/factory/<int:client_id>/unlock",
|
||||
"/api/factory/<int:client_id>/lock",
|
||||
):
|
||||
assert expected in rules, f"missing factory route {expected}"
|
||||
|
||||
|
||||
def test_url_map_contains_dashboard_and_liveness_routes(app):
|
||||
rules = _rules(app)
|
||||
for expected in ("/dashboard", "/api/dashboard/summary",
|
||||
"/healthz", "/health"):
|
||||
assert expected in rules, f"missing route {expected}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Acceptance 2: no SAAS_API_KEY -> app starts, authed routes fail closed 503
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unset_api_key_fails_closed_503(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("SAAS_API_KEY", raising=False)
|
||||
application = create_app(_cfg(tmp_path, api_key=None))
|
||||
application.config.update(TESTING=True)
|
||||
c = application.test_client()
|
||||
|
||||
# authed routes: 503 -- not 500, not 200
|
||||
assert c.get("/api/clients/1").status_code == 503
|
||||
assert c.post("/api/clients", json={"name": "X"}).status_code == 503
|
||||
assert c.get("/dashboard").status_code == 503
|
||||
assert c.get("/api/dashboard/summary").status_code == 503
|
||||
assert c.post("/api/factory/provision",
|
||||
json={"client_id": 1}).status_code == 503
|
||||
|
||||
# liveness routes in auth.PUBLIC_PATHS stay reachable without a key
|
||||
for path in ("/health", "/healthz", "/api/factory/health"):
|
||||
assert c.get(path).status_code == 200, path
|
||||
|
||||
|
||||
def test_default_create_app_with_no_env_starts(monkeypatch):
|
||||
monkeypatch.delenv("SAAS_API_KEY", raising=False)
|
||||
monkeypatch.delenv("SAAS_DB_PATH", raising=False)
|
||||
application = create_app() # nothing set: config loader defaults
|
||||
application.config.update(TESTING=True)
|
||||
c = application.test_client()
|
||||
|
||||
assert c.get("/health").status_code == 200
|
||||
assert c.get("/healthz").status_code == 200
|
||||
assert c.get("/api/clients/1").status_code == 503
|
||||
rules = _rules(application)
|
||||
assert "/dashboard" in rules
|
||||
assert "/api/factory/provision" in rules
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configured key: the front-ends' own auth.key_is_valid guards are untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configured_key_serves_billing(client):
|
||||
created = client.post("/api/clients", json={"name": "Assembled"},
|
||||
headers=AUTH)
|
||||
assert created.status_code == 201
|
||||
cid = created.get_json()["id"]
|
||||
resp = client.get(f"/api/clients/{cid}", headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["name"] == "Assembled"
|
||||
|
||||
|
||||
def test_wrong_key_is_canonical_401(client):
|
||||
resp = client.post("/api/clients", json={"name": "X"},
|
||||
headers={API_KEY_HEADER: "wrong"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json() == UNAUTHORIZED_BODY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoints exist and are wired to the same main()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_console_entrypoint_target_exists():
|
||||
# [project.scripts] gitea-saas = "gitea_saas.app:main"
|
||||
assert callable(app_module.main)
|
||||
|
||||
|
||||
def test_python_m_gitea_saas_uses_app_main():
|
||||
from gitea_saas import __main__ as main_module
|
||||
|
||||
assert main_module.main is app_module.main
|
||||
10
wsgi.py
Normal file
10
wsgi.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Gunicorn entrypoint (issue #38).
|
||||
|
||||
``gunicorn wsgi:application`` — the assembled app built by
|
||||
``gitea_saas.app.create_app`` from the environment (SAAS_API_KEY,
|
||||
SAAS_DB_PATH, SAAS_PORT).
|
||||
"""
|
||||
|
||||
from gitea_saas.app import create_app
|
||||
|
||||
application = create_app()
|
||||
Loading…
Reference in New Issue
Block a user