sync: workflow CI self-contained + imagen Harbor t-<short> (ADR-061)
Some checks failed
__APP_NAME__ — build & deploy / Calidad + build + push (push) Failing after 2m44s
__APP_NAME__ — build & deploy / Helm upgrade --install (push) Has been skipped

Workflow <app>-build.yaml self-contained — sin uses: cross-repo al repo
privado coralware/IDP. requirements-dev.txt inline. image.repository apunta
al proyecto Harbor dedicado del tenant t-<tenant-id-short> (ADR-019 D-03).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Coralware Template Sync
2026-05-21 22:08:14 -05:00
parent 5d51971270
commit 1c6b8ae182
5 changed files with 128 additions and 37 deletions

View File

@@ -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-<tenant-id-short>` (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: |

View File

@@ -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")

View File

@@ -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

View File

@@ -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()

View File

@@ -4,7 +4,8 @@
replicaCount: 1
image:
repository: registry.coralsafety.com/__TENANT_ID_NODASHES__/__APP_NAME__
# Proyecto Harbor dedicado del tenant: `t-<tenant-id-short>` (ADR-019 D-03).
repository: registry.coralsafety.com/t-__TENANT_ID_SHORT__/__APP_NAME__
tag: latest
pullPolicy: Always
pullSecret: harbor-tenant-pull