sync: workflow CI self-contained + imagen Harbor t-<short> (ADR-061)
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:
@@ -1,5 +1,12 @@
|
|||||||
name: __APP_NAME__ — build & deploy
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -7,25 +14,83 @@ on:
|
|||||||
- 'code/**'
|
- 'code/**'
|
||||||
- 'helm/**'
|
- 'helm/**'
|
||||||
- '.gitea/workflows/__APP_NAME__-build.yaml'
|
- '.gitea/workflows/__APP_NAME__-build.yaml'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Tests + build + push a Harbor — patrón ADR-038 reusable workflow.
|
# ── Calidad + build + push de imagen a Harbor ────────────────────────────
|
||||||
build:
|
build:
|
||||||
uses: coralware/IDP/.gitea/workflows/_python-microservice.yaml@main
|
name: Calidad + build + push
|
||||||
with:
|
runs-on: [self-hosted, buildkit]
|
||||||
service_name: __APP_NAME__
|
outputs:
|
||||||
service_dir: code
|
image_tag: ${{ steps.meta.outputs.tag }}
|
||||||
uses_metrics: false
|
defaults:
|
||||||
coverage_threshold: 80 # menor que el IDP interno (90); el dev sube cuando madure
|
run:
|
||||||
trivy_exit_code: 0 # informacional en MVP; subir a 1 antes de producción
|
working-directory: code
|
||||||
enforce_rollback: false # rollout lo gestiona el job helm-deploy abajo
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Deploy al cluster del tenant — Helm upgrade --install con hook Alembic.
|
- name: Crear venv e instalar dependencias
|
||||||
# El runner corre executor=host (ci-system: container.enabled=false, ADR-043):
|
# --system-site-packages reutiliza ruff/black/bandit/pytest ya
|
||||||
# los steps se ejecutan directo sobre el pod runner, sin Docker daemon. Por eso
|
# preinstalados en la imagen del runner; el venv añade las deps de la app.
|
||||||
# NO se usa `container:` — kubectl ya viene en la imagen del runner; helm se
|
run: |
|
||||||
# instala en runtime (la NetworkPolicy de los runners permite egress 443).
|
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:
|
helm-deploy:
|
||||||
|
name: Helm upgrade --install
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: [self-hosted, buildkit]
|
runs-on: [self-hosted, buildkit]
|
||||||
steps:
|
steps:
|
||||||
@@ -84,10 +149,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke check post-deploy
|
- name: Smoke check post-deploy
|
||||||
# FQDN con tenant-id CON guiones — coincide con el A record que crea
|
# FQDN con tenant-id CON guiones — coincide con el A record que crea
|
||||||
# cloudflare-provisioner (Gap 5) y con el host del Ingress NGINX
|
# cloudflare-provisioner y con el host del Ingress NGINX
|
||||||
# (helm/APP_NAME/values.yaml). Antes usaba TENANT_ID_NODASHES, lo
|
# (helm/APP_NAME/values.yaml).
|
||||||
# que no resolvía al A record canónico — corregido APERTURA COVE
|
|
||||||
# Sesión 3 (TENANT-INGRESS-CONTROLLER-MISMATCH).
|
|
||||||
env:
|
env:
|
||||||
INGRESS_HOST: __APP_NAME__-${{ vars.TENANT_ID }}.apps.coralware.cloud
|
INGRESS_HOST: __APP_NAME__-${{ vars.TENANT_ID }}.apps.coralware.cloud
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ def create_app() -> FastAPI:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="__APP_NAME__",
|
title="__APP_NAME__",
|
||||||
version="0.1.0",
|
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")
|
@app.middleware("http")
|
||||||
|
|||||||
@@ -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 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
|
httpx==0.27.2
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==0.24.0
|
||||||
testcontainers[mariadb]==4.8.2
|
|
||||||
|
|||||||
@@ -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
|
Los tests corren contra una BD SQLite efímera en archivo temporal — el runner
|
||||||
contra una BD real (no mocks). El reusable workflow ADR-038 garantiza que
|
CI (executor=host, ADR-043) no tiene Docker daemon, por lo que no se usa
|
||||||
Docker está disponible en el runner.
|
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 os
|
||||||
|
import tempfile
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
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")
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def mariadb_url() -> Iterator[str]:
|
def _cleanup_sqlite() -> Iterator[None]:
|
||||||
"""MariaDB efímera por sesión de tests; URL exportada como DATABASE_URL."""
|
"""Borra el archivo SQLite temporal al terminar la sesión de tests."""
|
||||||
with MariaDbContainer("mariadb:11") as mdb:
|
yield
|
||||||
url = mdb.get_connection_url().replace("mysql://", "mysql+pymysql://", 1)
|
try:
|
||||||
os.environ["DATABASE_URL"] = url
|
os.unlink(_DB_PATH)
|
||||||
os.environ["MIGRATION_DATABASE_URL"] = url
|
except OSError:
|
||||||
yield url
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(mariadb_url: str) -> Iterator[TestClient]:
|
def client() -> Iterator[TestClient]:
|
||||||
"""TestClient con la app cargada contra la BD efímera."""
|
"""TestClient con la app cargada contra la BD SQLite efímera."""
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
|
||||||
image:
|
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
|
tag: latest
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
pullSecret: harbor-tenant-pull
|
pullSecret: harbor-tenant-pull
|
||||||
|
|||||||
Reference in New Issue
Block a user