diff --git a/.gitea/workflows/APP_NAME-build.yaml b/.gitea/workflows/APP_NAME-build.yaml index ae7b2d6..86638eb 100644 --- a/.gitea/workflows/APP_NAME-build.yaml +++ b/.gitea/workflows/APP_NAME-build.yaml @@ -1,5 +1,12 @@ name: __APP_NAME__ — build & deploy +# Workflow self-contained del golden path (ADR-061): calidad + build + push + +# helm-deploy inline, SIN `uses:` cross-repo al repo privado `coralware/IDP`. +# El repo del tenant es un repo cliente — su CI no comparte el ciclo de release +# del IDP ni el GitOps de Fleet; se construye leyendo solo su propio repo. +# Corre sobre los runners `[self-hosted, buildkit]` de ci-system (executor=host, +# buildkit remoto — sin Docker daemon local; ADR-043). + on: push: branches: [main] @@ -7,25 +14,83 @@ on: - 'code/**' - 'helm/**' - '.gitea/workflows/__APP_NAME__-build.yaml' + workflow_dispatch: {} jobs: - # Tests + build + push a Harbor — patrón ADR-038 reusable workflow. + # ── Calidad + build + push de imagen a Harbor ──────────────────────────── build: - uses: coralware/IDP/.gitea/workflows/_python-microservice.yaml@main - with: - service_name: __APP_NAME__ - service_dir: code - uses_metrics: false - coverage_threshold: 80 # menor que el IDP interno (90); el dev sube cuando madure - trivy_exit_code: 0 # informacional en MVP; subir a 1 antes de producción - enforce_rollback: false # rollout lo gestiona el job helm-deploy abajo + name: Calidad + build + push + runs-on: [self-hosted, buildkit] + outputs: + image_tag: ${{ steps.meta.outputs.tag }} + defaults: + run: + working-directory: code + steps: + - name: Checkout + uses: actions/checkout@v4 - # Deploy al cluster del tenant — Helm upgrade --install con hook Alembic. - # El runner corre executor=host (ci-system: container.enabled=false, ADR-043): - # los steps se ejecutan directo sobre el pod runner, sin Docker daemon. Por eso - # NO se usa `container:` — kubectl ya viene en la imagen del runner; helm se - # instala en runtime (la NetworkPolicy de los runners permite egress 443). + - name: Crear venv e instalar dependencias + # --system-site-packages reutiliza ruff/black/bandit/pytest ya + # preinstalados en la imagen del runner; el venv añade las deps de la app. + run: | + python3 -m venv --system-site-packages /tmp/venv-__APP_NAME__ + /tmp/venv-__APP_NAME__/bin/pip install --no-cache-dir -r requirements-dev.txt + + - name: Lint (ruff) + run: ruff check app + + - name: Formato (black --check) + run: black --check app + + - name: Seguridad (bandit) + run: bandit -c pyproject.toml -r app --severity-level medium + + - name: Tests con cobertura + # Los tests corren sobre SQLite efímero (ver code/tests/conftest.py) — + # el runner no tiene Docker daemon. El deploy real usa MariaDB. + run: | + /tmp/venv-__APP_NAME__/bin/python -m pytest tests/ -v \ + --cov=app --cov-report=term-missing --cov-fail-under=80 + + - name: Metadata de imagen + id: meta + run: echo "tag=${{ gitea.sha }}" >> "$GITHUB_OUTPUT" + + - name: Build y push a Harbor + # Proyecto Harbor del tenant: `t-` (ADR-019 D-03). + # Credenciales: robot scoped push/pull SOLO a ese proyecto, sembrado + # por tenant-manager en el repo del tenant (paso 3 del reconcile). + env: + REGISTRY: registry.coralsafety.com + ROBOT_USER: ${{ secrets.HARBOR_ROBOT_USER }} + ROBOT_TOKEN: ${{ secrets.HARBOR_ROBOT_TOKEN }} + IMAGE: t-__TENANT_ID_SHORT__/__APP_NAME__ + TAG: ${{ steps.meta.outputs.tag }} + run: | + if [ -z "$ROBOT_USER" ] || [ -z "$ROBOT_TOKEN" ]; then + echo "ERROR: secrets HARBOR_ROBOT_USER/HARBOR_ROBOT_TOKEN no configurados." + echo "tenant-manager debe sembrarlos al crear el repo (paso 3 del reconcile)." + exit 1 + fi + printf '%s' "$ROBOT_TOKEN" | docker login "$REGISTRY" -u "$ROBOT_USER" --password-stdin + # Builder buildx remoto — apunta al buildkit daemon de ci-system + # (el runner no tiene Docker daemon local). + if ! docker buildx inspect tenant-builder >/dev/null 2>&1; then + docker buildx create --name tenant-builder --driver remote --use \ + "${BUILDKIT_HOST:-tcp://buildkitd.ci-system.svc.cluster.local:2375}" + else + docker buildx use tenant-builder + fi + docker buildx build --platform linux/amd64 \ + -t "${REGISTRY}/${IMAGE}:${TAG}" \ + --push . + + # ── Deploy al cluster del tenant — Helm upgrade --install con hook Alembic ─ + # El runner corre executor=host: los steps se ejecutan directo sobre el pod + # runner, sin `container:`. helm se instala en runtime. helm-deploy: + name: Helm upgrade --install needs: build runs-on: [self-hosted, buildkit] steps: @@ -84,10 +149,8 @@ jobs: - name: Smoke check post-deploy # FQDN con tenant-id CON guiones — coincide con el A record que crea - # cloudflare-provisioner (Gap 5) y con el host del Ingress NGINX - # (helm/APP_NAME/values.yaml). Antes usaba TENANT_ID_NODASHES, lo - # que no resolvía al A record canónico — corregido APERTURA COVE - # Sesión 3 (TENANT-INGRESS-CONTROLLER-MISMATCH). + # cloudflare-provisioner y con el host del Ingress NGINX + # (helm/APP_NAME/values.yaml). env: INGRESS_HOST: __APP_NAME__-${{ vars.TENANT_ID }}.apps.coralware.cloud run: | diff --git a/code/app/main.py b/code/app/main.py index 556fa42..0a236d0 100644 --- a/code/app/main.py +++ b/code/app/main.py @@ -56,7 +56,9 @@ def create_app() -> FastAPI: app = FastAPI( title="__APP_NAME__", version="0.1.0", - dependencies=[Depends(_require_auth_dep)] if settings.OPENAPI_REQUIRE_AUTH else [], + dependencies=( + [Depends(_require_auth_dep)] if settings.OPENAPI_REQUIRE_AUTH else [] + ), ) @app.middleware("http") diff --git a/code/requirements-dev.txt b/code/requirements-dev.txt index 6f03657..3ca0548 100644 --- a/code/requirements-dev.txt +++ b/code/requirements-dev.txt @@ -1,6 +1,16 @@ +# Dependencias de desarrollo y CI del golden path web-backend/python. +# Self-contained (ADR-061): el repo del tenant no tiene acceso al monorepo IDP, +# así que las versiones de tooling se inlinean aquí en vez de referenciar +# code/requirements-dev-base.txt. Mantener alineadas a ese archivo (ADR-038). -r requirements.txt --r ../../code/requirements-dev-base.txt +# Tooling de calidad — versiones canónicas ADR-038 +ruff==0.9.10 +black==25.1.0 +bandit==1.8.3 +pytest==8.3.5 +pytest-cov==6.0.0 + +# Específicas del shape — los tests corren sobre SQLite efímero (sin Docker). httpx==0.27.2 pytest-asyncio==0.24.0 -testcontainers[mariadb]==4.8.2 diff --git a/code/tests/conftest.py b/code/tests/conftest.py index 4551ada..e40c2af 100644 --- a/code/tests/conftest.py +++ b/code/tests/conftest.py @@ -1,32 +1,47 @@ """ -Fixtures para los tests del shape. +Fixtures para los tests del shape web-backend/python. -Levanta una MariaDB efímera con testcontainers para que los tests corran -contra una BD real (no mocks). El reusable workflow ADR-038 garantiza que -Docker está disponible en el runner. +Los tests corren contra una BD SQLite efímera en archivo temporal — el runner +CI (executor=host, ADR-043) no tiene Docker daemon, por lo que no se usa +testcontainers (ADR-061). El deploy real usa MariaDB (claim-mariadb.yaml + hook +Alembic); los tests del scaffold solo ejercen /health, /demo y /metrics, sin +tocar Alembic ni modelos de dominio. Al añadir modelos reales, agrega tests de +integración contra tu propia infra. """ import os +import tempfile from collections.abc import Iterator import pytest from fastapi.testclient import TestClient -from testcontainers.mariadb import MariaDbContainer + +# La configuración debe existir ANTES de importar app.settings: Settings() exige +# DATABASE_URL al cargar el módulo. Se usa un archivo SQLite temporal (no +# :memory:) para evitar problemas de hilos con el TestClient. +_DB_FD, _DB_PATH = tempfile.mkstemp(suffix=".sqlite", prefix="webapp-test-") +os.close(_DB_FD) +_SQLITE_URL = f"sqlite:///{_DB_PATH}" +os.environ.setdefault("DATABASE_URL", _SQLITE_URL) +os.environ.setdefault("MIGRATION_DATABASE_URL", _SQLITE_URL) +# El frontend estático /demo se sirve desde el árbol del repo durante los tests. +_DEMO_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "app", "static", "demo")) +os.environ.setdefault("STATIC_DEMO_PATH", _DEMO_DIR) -@pytest.fixture(scope="session") -def mariadb_url() -> Iterator[str]: - """MariaDB efímera por sesión de tests; URL exportada como DATABASE_URL.""" - with MariaDbContainer("mariadb:11") as mdb: - url = mdb.get_connection_url().replace("mysql://", "mysql+pymysql://", 1) - os.environ["DATABASE_URL"] = url - os.environ["MIGRATION_DATABASE_URL"] = url - yield url +@pytest.fixture(scope="session", autouse=True) +def _cleanup_sqlite() -> Iterator[None]: + """Borra el archivo SQLite temporal al terminar la sesión de tests.""" + yield + try: + os.unlink(_DB_PATH) + except OSError: + pass @pytest.fixture -def client(mariadb_url: str) -> Iterator[TestClient]: - """TestClient con la app cargada contra la BD efímera.""" +def client() -> Iterator[TestClient]: + """TestClient con la app cargada contra la BD SQLite efímera.""" from app.main import create_app app = create_app() diff --git a/helm/APP_NAME/values.yaml b/helm/APP_NAME/values.yaml index 8076483..cc4537e 100644 --- a/helm/APP_NAME/values.yaml +++ b/helm/APP_NAME/values.yaml @@ -4,7 +4,8 @@ replicaCount: 1 image: - repository: registry.coralsafety.com/__TENANT_ID_NODASHES__/__APP_NAME__ + # Proyecto Harbor dedicado del tenant: `t-` (ADR-019 D-03). + repository: registry.coralsafety.com/t-__TENANT_ID_SHORT__/__APP_NAME__ tag: latest pullPolicy: Always pullSecret: harbor-tenant-pull