Sync desde coralware/IDP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
276 lines
12 KiB
YAML
276 lines
12 KiB
YAML
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]
|
|
paths:
|
|
- 'code/**'
|
|
- 'helm/**'
|
|
- 'manifests/**'
|
|
- 'scripts/**'
|
|
- '.gitea/workflows/__APP_NAME__-build.yaml'
|
|
workflow_dispatch: {}
|
|
|
|
jobs:
|
|
# ── Gate de credenciales de build (GOLDEN-PATH-BUILD-DEPLOY-ORDER-RACE) ───
|
|
# Los secrets HARBOR_ROBOT_USER/TOKEN los siembra tenant-manager en su fase 1
|
|
# (build-ready). El commit de render dispara este workflow on:push en segundos;
|
|
# si los secrets aún no están, el job build se SKIPEA (no falla, no rojo) y
|
|
# tenant-manager dispara un run completo (workflow_dispatch) tras la fase 1+2.
|
|
# Simétrico con cluster-gate del helm-deploy.
|
|
build-gate:
|
|
name: ¿Credenciales de build listas?
|
|
runs-on: [self-hosted, buildkit]
|
|
outputs:
|
|
ready: ${{ steps.check.outputs.ready }}
|
|
steps:
|
|
- name: Verificar secrets Harbor sembrados
|
|
id: check
|
|
env:
|
|
ROBOT_USER: ${{ secrets.HARBOR_ROBOT_USER }}
|
|
run: |
|
|
if [ -z "$ROBOT_USER" ]; then
|
|
echo "::notice::Credenciales Harbor aún no sembradas (tenant-manager fase 1 build-ready). Este run se omite; el deploy completo se dispara automáticamente cuando estén listas (run #2)."
|
|
echo "ready=false" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "ready=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# ── Calidad + build + push de imagen a Harbor ────────────────────────────
|
|
build:
|
|
name: Calidad + build + push
|
|
needs: build-gate
|
|
if: needs.build-gate.outputs.ready == 'true'
|
|
runs-on: [self-hosted, buildkit]
|
|
outputs:
|
|
image_tag: ${{ steps.meta.outputs.tag }}
|
|
defaults:
|
|
run:
|
|
working-directory: code
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- 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: |
|
|
# build-gate ya garantizó que HARBOR_ROBOT_USER está presente (si no,
|
|
# este job se habría skipeado) — GOLDEN-PATH-BUILD-DEPLOY-ORDER-RACE.
|
|
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 .
|
|
|
|
# ── Gate de cluster listo (GOLDEN-PATH-BUILD-DEPLOY-ORDER-RACE) ──────────
|
|
# El kubeconfig (secret KUBECONFIG_TENANT_B64) lo siembra tenant-manager en su
|
|
# fase 2 (deploy-ready) cuando el cluster RKE2 del tenant está Ready (~8 min).
|
|
# En el primer run (disparado por el commit de bootstrap) el cluster aún se
|
|
# está aprovisionando: este gate marca `ready=false` → el job helm-deploy se
|
|
# SKIPEA (no falla, no rojo). La imagen ya quedó publicada en el job build.
|
|
# tenant-manager dispara automáticamente un segundo run (workflow_dispatch) al
|
|
# sembrar el kubeconfig → ahí helm-deploy corre y la app queda viva.
|
|
cluster-gate:
|
|
name: ¿Cluster del tenant listo?
|
|
needs: build
|
|
runs-on: [self-hosted, buildkit]
|
|
outputs:
|
|
ready: ${{ steps.check.outputs.ready }}
|
|
steps:
|
|
- name: Verificar kubeconfig sembrado
|
|
id: check
|
|
env:
|
|
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_TENANT_B64 }}
|
|
run: |
|
|
if [ -z "$KUBECONFIG_B64" ]; then
|
|
echo "::notice::Imagen publicada en Harbor ✅. El cluster del tenant se está aprovisionando (~8 min); el deploy se ejecutará automáticamente cuando esté listo (run #2)."
|
|
echo "ready=false" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "ready=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# ── 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.
|
|
# Solo corre cuando el cluster está listo (cluster-gate). En caso contrario el
|
|
# job aparece como skipped (no failure) — separa "imagen publicada" de
|
|
# "app desplegada" como dos hitos.
|
|
helm-deploy:
|
|
name: Helm upgrade --install
|
|
needs: [build, cluster-gate]
|
|
if: needs.cluster-gate.outputs.ready == 'true'
|
|
runs-on: [self-hosted, buildkit]
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Instalar helm
|
|
run: |
|
|
HELM_VERSION=v3.14.4
|
|
case "$(uname -m)" in
|
|
x86_64) ARCH=amd64 ;;
|
|
aarch64) ARCH=arm64 ;;
|
|
*) echo "Arquitectura no soportada: $(uname -m)"; exit 1 ;;
|
|
esac
|
|
mkdir -p "${HOME}/.local/bin"
|
|
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" \
|
|
| tar -xz -C /tmp "linux-${ARCH}/helm"
|
|
mv "/tmp/linux-${ARCH}/helm" "${HOME}/.local/bin/helm"
|
|
chmod +x "${HOME}/.local/bin/helm"
|
|
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
|
|
"${HOME}/.local/bin/helm" version
|
|
|
|
- name: Decodificar kubeconfig del tenant
|
|
env:
|
|
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_TENANT_B64 }}
|
|
run: |
|
|
if [ -z "$KUBECONFIG_B64" ]; then
|
|
echo "ERROR: secret KUBECONFIG_TENANT_B64 no configurado en el proyecto Gitea."
|
|
echo "tenant-manager debe provisionarlo al crear el repo."
|
|
exit 1
|
|
fi
|
|
mkdir -p "${HOME}/.kube"
|
|
echo "$KUBECONFIG_B64" | base64 -d > "${HOME}/.kube/config"
|
|
chmod 600 "${HOME}/.kube/config"
|
|
|
|
# ── ADR-066: AddonClaim vive FUERA del chart Helm ───────────────────
|
|
# El claim lo gestiona addon-provisioner via finalizer cleanup-logical;
|
|
# no debe acoplarse al lifecycle del release Helm para no rotar
|
|
# credenciales en cada upgrade.
|
|
|
|
- name: De-adoptar AddonClaim legacy (migracion chart 1.0.0 -> 1.1.0)
|
|
# Idempotente: skip silencioso si AddonClaim no existe (primera
|
|
# instalacion) o si ya esta de-adoptado (segundo run post-migracion).
|
|
# Si tiene annotations Helm de un release v1.0.0, las quita para que
|
|
# el siguiente helm upgrade no intente borrarlo (no esta en el chart).
|
|
# Sunset del step tras una rotacion completa (~2 semanas).
|
|
run: |
|
|
RELEASE_NAME=__APP_NAME__ bash scripts/pre-helm-detach.sh
|
|
|
|
- name: Aplicar AddonClaim
|
|
# Sustitucion de placeholders con sed (evita dependencia de envsubst,
|
|
# que no esta garantizado en la imagen del runner). kubectl apply es
|
|
# idempotente: primer push lo crea, push subsiguientes son no-op si
|
|
# el spec no cambia. helm upgrade --install nunca lo trackea ni lo borra.
|
|
env:
|
|
RELEASE_NAME: __APP_NAME__
|
|
run: |
|
|
# databaseName = release name con guiones a underscores + sufijo _db.
|
|
# Coincide con el default del chart 1.0.0 (.Release.Name | replace
|
|
# "-" "_" | printf "%s_db").
|
|
DATABASE_NAME="$(echo "${RELEASE_NAME}" | tr '-' '_')_db"
|
|
sed -e "s|\${RELEASE_NAME}|${RELEASE_NAME}|g" \
|
|
-e "s|\${DATABASE_NAME}|${DATABASE_NAME}|g" \
|
|
manifests/addonclaim.yaml | kubectl apply -f -
|
|
|
|
- name: Esperar rancher-webhook ready (RANCHER-WEBHOOK-RACE-RECOVERY)
|
|
# Race condition durante el bootstrap del cluster tenant: el
|
|
# `rancher-webhook` Deployment en cattle-system aún no tiene endpoints
|
|
# cuando el primer `helm upgrade --install` intenta crear el namespace
|
|
# `apps`, lo que dispara el webhook
|
|
# `rancher.cattle.io.namespaces.create-non-kubesystem` y aborta el
|
|
# release con `no endpoints available for service rancher-webhook`.
|
|
# El re-dispatch ~5-10min después recupera natural, pero degrada el smoke
|
|
# E2E. Espera explícita evita el flake.
|
|
run: |
|
|
kubectl wait deployment rancher-webhook \
|
|
-n cattle-system \
|
|
--for=condition=Available \
|
|
--timeout=600s
|
|
|
|
- name: Helm upgrade --install
|
|
env:
|
|
TENANT_ID: ${{ vars.TENANT_ID }}
|
|
TENANT_ID_NODASHES: ${{ vars.TENANT_ID_NODASHES }}
|
|
TENANT_TIER: ${{ vars.TENANT_TIER }}
|
|
IMAGE_TAG: ${{ needs.build.outputs.image_tag }}
|
|
# ADR-054: en cluster tenant los namespaces son funcionales (apps/addons),
|
|
# sin tenant-id en el nombre — el cluster ya es el sandbox del tenant.
|
|
run: |
|
|
helm upgrade --install __APP_NAME__ ./helm/__APP_NAME__ \
|
|
--namespace apps \
|
|
--create-namespace \
|
|
--set image.tag=${IMAGE_TAG} \
|
|
--set tenantId=${TENANT_ID} \
|
|
--set tenantIdNodashes=${TENANT_ID_NODASHES} \
|
|
--set tier=${TENANT_TIER} \
|
|
--wait --timeout 10m
|
|
# 10m absorbe el peor caso del reconcile de addon-provisioner
|
|
# (RECONCILE_INTERVAL 60s + initial_delay 60s + 3 ticks ≈ 240s) +
|
|
# StatefulSet MariaDB ready (~60s) + Job DDL (~10s) +
|
|
# InitContainer wait-for-secret 360s + Alembic. Lección 2026-05-28:
|
|
# con RECONCILE_INTERVAL=300s y wait 240s el init container moria
|
|
# antes (delay observado 577s, ticket
|
|
# ADDON-PROVISIONER-DB-SECRET-NOT-PUBLISHED). AddonClaim ya fue
|
|
# aplicado en el step previo (ADR-066).
|
|
|
|
- name: Verificar rollout
|
|
run: |
|
|
kubectl -n apps rollout status deploy/__APP_NAME__ --timeout=3m
|
|
|
|
- name: Smoke check post-deploy
|
|
# FQDN con tenant-id CON guiones — coincide con el A record que crea
|
|
# 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: |
|
|
for i in $(seq 1 12); do
|
|
if wget -q -O - --timeout=10 "https://${INGRESS_HOST}/health" | grep -q '"status":"ok"'; then
|
|
echo "Smoke OK: /health responde 200 con db:up"
|
|
exit 0
|
|
fi
|
|
echo "Intento ${i}/12 — /health aún no responde, esperando 5s…"
|
|
sleep 5
|
|
done
|
|
echo "ERROR: /health no respondió 200 dentro del timeout"
|
|
exit 1
|