diff --git a/.github/workflows/deploy-check.yml b/.github/workflows/deploy-check.yml index 9d5f51d5..9e5193e9 100644 --- a/.github/workflows/deploy-check.yml +++ b/.github/workflows/deploy-check.yml @@ -40,6 +40,10 @@ on: permissions: contents: read + # Las imagenes del Core son PRIVADAS en GHCR. Sin esto el `docker pull` del + # paso `core-up` responde `unauthorized`, que es exactamente como fallo la + # primera prueba local de este cableado. + packages: read concurrency: group: deploy-${{ github.workflow }}-${{ github.ref }} @@ -81,6 +85,32 @@ jobs: - name: Levantar el stack (kind + build + load + Helm + migraciones) run: bash product/infra/helm/local-test.sh up + # El Core, desde GHCR, en el SHA fijado en product/infra/core-image.sha. + # + # Hasta ahora `core-integration` — el unico robot que asevera las SEIS + # interfaces del par Tracker↔Core — estaba excluido de este pipeline porque + # necesitaba el Core, que no se desplegaba. Se probaba a mano o no se + # probaba; la primera vez que corrio contra un stack real encontro tres + # defectos invisibles para toda otra suite, dos de ellos de producto. + # + # El SHA es fijo y NO `latest`: seguir la punta del Core haria que un + # defecto ajeno, mergeado minutos antes, pusiera rojos los PR de este + # repositorio. El coste aceptado es que alguien tiene que subirlo, en un + # diff y con revisor. Ver product/infra/core-image.sha. + # Login ANTES del pull: los paquetes del Core son privados y el + # `GITHUB_TOKEN` de este repositorio los alcanza porque comparten + # organizacion. Si un dia dejaran de compartirla, esto falla aqui, con el + # nombre del registro, y no como un pod que no arranca. + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Desplegar el Core (imagenes GHCR, SHA fijado) + run: bash product/infra/helm/local-test.sh core-up + - name: Smoke contra el despliegue vivo env: SMOKE_OBSERVE_SECONDS: "25" @@ -90,11 +120,28 @@ jobs: # (dev-bypass). Corre aqui, mientras el perfil local esta desplegado y antes del # redespliegue de produccion, porque los robots actuan por `x-tenant-id` (dev-bypass). # Ejercitan el funnel SDLC completo, el ciclo ProviderConnection y el aislamiento de - # tenant contra el tracker-api + Postgres reales. `core-integration` se excluye: necesita - # el Core (otro producto), que no se despliega en este pipeline. Este paso MUERDE. + # tenant contra el tracker-api + Postgres reales. Desde 2026-08-04 incluye ademas + # `core-integration`, porque el paso anterior despliega el Core: el robot detecta su + # presencia y entra en la lista solo entonces. Este paso MUERDE. - name: RoboSoft E2E (agentes deterministicos contra el despliegue local) run: bash product/infra/helm/local-test.sh robosoft + # El Core se retira EN CUANTO deja de hacer falta. + # + # Es un fixture del paso anterior, no parte del despliegue del Tracker, y + # dejarlo puesto rompe el redespliegue de produccion: ese perfil activa + # `networkPolicy`, que bloquea la salida hacia el namespace del Core, asi + # que el health check `core-capabilities` de la sonda de readiness se queda + # colgado hasta expirar — el pod nunca llega a Ready y helm --wait se + # rinde a los 5 minutos. Sin Core, esa URL simplemente no resuelve y falla + # rapido, que es la condicion bajo la que ese paso se escribio. + # + # Visto de verdad: con el Core puesto, `Error: context deadline exceeded`; + # el mismo paso lleva meses en verde en develop, donde no hay Core. + - name: Retirar el Core (era el fixture del paso anterior) + if: always() + run: bash product/infra/helm/local-test.sh core-down + # GT-464 — hasta aqui el despliegue se probaba SOLO con el perimetro abierto: # `values-local.yaml` corre en Development con `dev-bypass`, que autentica cualquier # peticion como administrador con todos los permisos. Lo de abajo lo prueba cerrado, diff --git a/product/infra/core-image.sha b/product/infra/core-image.sha new file mode 100644 index 00000000..6adcd5fb --- /dev/null +++ b/product/infra/core-image.sha @@ -0,0 +1,34 @@ +# The Evolith Core commit this repository's CI deploys and tests against. +# +# ## Why a pinned SHA and not `latest` +# +# The Core publishes all three service images to GHCR on every merge to its +# `main`, tagged with BOTH the commit SHA and `latest`. Following `latest` would +# make this repository's pull requests turn red for a defect in another product, +# merged minutes earlier, by someone who has never seen this pipeline. A pinned +# SHA moves that failure to where it can be read: a deliberate bump, in a diff, +# with a reviewer. +# +# The cost is honest and accepted: this file goes stale on purpose, and somebody +# has to raise it. That is the same discipline the Helm charts already use for +# their own image tags. +# +# ## How to bump it +# +# 1. Pick a Core commit whose images exist: +# gh api orgs/beyondnetcode/packages/container/evolith-core-api/versions \ +# --jq '.[0].metadata.container.tags' +# 2. Check the other two carry the SAME tag — they are published together and +# a mismatch means one build failed: +# for p in evolith-core-api evolith-mcp evolith-agent-runtime; do … +# 3. Replace the line below, open a pull request, and let `Deploy check` prove +# the Tracker still talks to that Core before it merges. +# +# ## What breaks if it is wrong +# +# `deploy-check.yml` pulls these three by digest-less tag. A SHA with no +# published image fails the pull loudly, in the pipeline, naming the tag — which +# is the failure mode to want. A SHA that exists but predates a contract change +# fails later, in `core-integration`, naming the interface that moved. + +ce85b14d07e3b0684025718039c77840e3e1cfa1 diff --git a/product/infra/helm/local-test.sh b/product/infra/helm/local-test.sh index ecc13afa..25b0aedf 100755 --- a/product/infra/helm/local-test.sh +++ b/product/infra/helm/local-test.sh @@ -30,6 +30,8 @@ CLUSTER="${KIND_CLUSTER:-evolith-tracker}" API_IMAGE="evolith-tracker-api:$IMAGE_TAG" WEB_IMAGE="evolith-tracker-web:$IMAGE_TAG" +# El gateway es la puerta que usa `core-integration` para llegar al Core. +GW_IMAGE="evolith-tracker-gateway:$IMAGE_TAG" kind_create() { if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then @@ -46,11 +48,67 @@ build() { docker build -f "$SRC/apps/tracker-api/Dockerfile" -t "$API_IMAGE" "$SRC" echo "==> Building tracker-web image ($WEB_IMAGE)" docker build -f "$SRC/apps/tracker-web/Dockerfile" -t "$WEB_IMAGE" "$SRC" + echo "==> Building tracker-gateway image ($GW_IMAGE)" + docker build -f "$SRC/apps/tracker-gateway/Dockerfile" -t "$GW_IMAGE" "$SRC" +} + +# ── El Core, dentro de ESTE clúster ────────────────────────────────────────── +# +# Trae las tres imágenes del Core desde GHCR en el SHA fijado, las carga en kind +# y las despliega con un manifiesto mínimo propio. Existe para que +# `core-integration` — el único robot que asevera las seis interfaces del par +# Tracker↔Core — pueda correr en CI en vez de a mano en un portátil. +# +# El SHA es FIJO a propósito: seguir `latest` haría que un defecto del Core, +# mergeado minutos antes por alguien que no ha visto este pipeline, pusiera rojos +# los PR de este repositorio. El razonamiento completo está en +# `product/infra/core-image.sha`. +CORE_NS=evolith-core-local +CORE_SHA_FILE="$ROOT/product/infra/core-image.sha" +CORE_MANIFEST="$ROOT/product/infra/kind/core-stack.local.yaml" + +core_sha() { + # Primera línea no vacía que no sea comentario. Un fichero de pin que se lee + # con `cat` acaba arrastrando su propia documentación al tag. + grep -vE '^\s*(#|$)' "$CORE_SHA_FILE" | head -1 | tr -d '[:space:]' +} + +core_up() { + local sha; sha="$(core_sha)" + if [ -z "$sha" ]; then + echo "==> ERROR: no hay SHA legible en $CORE_SHA_FILE"; return 1 + fi + echo "==> Core fijado en $sha" + + local reg="ghcr.io/beyondnetcode" + for img in evolith-core-api evolith-mcp evolith-agent-runtime; do + echo "==> docker pull $reg/$img:$sha" + # Sin `|| true`: un tag que no existe debe MORDER aquí, nombrando el tag, + # y no diez minutos después como un pod que no arranca. + docker pull "$reg/$img:$sha" + kind load docker-image "$reg/$img:$sha" --name "$CLUSTER" + done + + echo "==> Desplegando el Core en $CORE_NS" + sed -e "s|IMAGE_CORE_API|$reg/evolith-core-api:$sha|" \ + -e "s|IMAGE_CORE_MCP|$reg/evolith-mcp:$sha|" \ + -e "s|IMAGE_CORE_AGENT_RUNTIME|$reg/evolith-agent-runtime:$sha|" \ + "$CORE_MANIFEST" | kubectl apply -f - + + for d in core-api core-mcp core-agent-runtime; do + kubectl -n "$CORE_NS" rollout status "deploy/$d" --timeout=300s + done + kubectl -n "$CORE_NS" get pods +} + +core_down() { + kubectl delete namespace "$CORE_NS" --ignore-not-found } kind_load() { echo "==> Loading images into kind cluster '$CLUSTER'" kind load docker-image "$API_IMAGE" --name "$CLUSTER" + kind load docker-image "$GW_IMAGE" --name "$CLUSTER" kind load docker-image "$WEB_IMAGE" --name "$CLUSTER" # Pre-pull the Postgres image into the cluster so it installs even offline. echo "==> Ensuring postgres:16-alpine is available in the cluster" @@ -68,11 +126,24 @@ kind_load() { # entorno local desechable, y un valor que parezca real invita a copiarlo a uno que no lo es. ensure_secrets() { echo "==> Asegurando los secretos locales (CD-15)" + # UNA sola clave local para todo el entorno. + # + # Habia TRES para el mismo despliegue: estos dos secretos con un valor cada + # uno, y `coreApiKey: local-dev-key` por defecto en el chart del gateway. Con + # el Core fuera del clusterr nunca se notaba —nadie llegaba a autenticar— y en + # cuanto el Core entro, los pasos que van POR el gateway pasaron y el que va + # directo al agent-runtime dio 401. Que costo verlo: ese 401 sale al llamante + # como 502 (LV-26), o sea, un problema de credencial disfrazado de problema de + # disponibilidad. + # + # Es una credencial de prueba local y lo dice su nombre; el valor concreto da + # igual mientras sea EL MISMO en los tres sitios. + local key="local-dev-key" kubectl -n "$NS" create secret generic tracker-core-auth \ - --from-literal=EVOLITH_API_KEY=local-only-not-a-real-key-core \ + --from-literal=EVOLITH_API_KEY="$key" \ --dry-run=client -o yaml | kubectl apply -f - kubectl -n "$NS" create secret generic tracker-runtime-auth \ - --from-literal=AGENT_RUNTIME_API_KEY=local-only-not-a-real-key-runtime \ + --from-literal=AGENT_RUNTIME_API_KEY="$key" \ --dry-run=client -o yaml | kubectl apply -f - } @@ -102,9 +173,35 @@ install() { kubectl -n "$NS" rollout status statefulset/evolith-tracker-postgres --timeout=180s echo "==> Installing evolith-tracker-api (perfil: $PROFILE; migraciones como Job pre-install)" + # Apuntado al Core que despliega `core-up`, no al del chart del Core. + # + # `values-local.yaml` trae `evolith-runtime-evolith-agent-runtime.evolith-local` + # — los nombres que genera el chart del OTRO repositorio, que aqui no existe. + # Sin esto el tracker-api llama a un host que no resuelve y devuelve 502: el + # mismo 502 que el paso 6 de `core-integration` reportaba, y que parece un + # problema del runtime cuando en realidad es una URL a ninguna parte. + # + # Se fija SIEMPRE, tambien cuando el Core no esta: es configuracion, no una + # dependencia de arranque, y un host inexistente falla igual que el de antes. helm upgrade --install tracker-api "$HELM/evolith-tracker-api" \ -n "$NS" -f "$(api_values)" \ - --set image.tag="$IMAGE_TAG" --wait --timeout 5m + --set image.tag="$IMAGE_TAG" \ + --set coreApi.baseUrl=http://core-api.$CORE_NS.svc.cluster.local/api/v1 \ + --set agentRuntime.baseUrl=http://core-agent-runtime.$CORE_NS.svc.cluster.local/v1 \ + --wait --timeout 5m + + # El gateway NO estaba en el install local: `core-integration` lo necesita + # (sus pasos 1-4 van gateway → Core) y por eso el robot quedaba excluido. Aquí + # el Core vive en el MISMO clúster, así que la URL es DNS interno — no hace + # falta el rodeo cross-cluster que sí necesita un portátil con dos clústeres. + echo "==> Installing evolith-tracker-gateway" + helm upgrade --install tracker-gateway "$HELM/evolith-tracker-gateway" \ + -n "$NS" -f "$HELM/evolith-tracker-gateway/values-local.yaml" \ + --set image.repository=evolith-tracker-gateway \ + --set image.pullPolicy=IfNotPresent \ + --set image.tag="$IMAGE_TAG" \ + --set env.CORE_API_BASE_URL=http://core-api.evolith-core-local.svc.cluster.local/api/v1 \ + --set env.CORE_MCP_URL=http://core-mcp.evolith-core-local.svc.cluster.local/ echo "==> Installing evolith-tracker-web" helm upgrade --install tracker-web "$HELM/evolith-tracker-web" \ @@ -447,12 +544,44 @@ robosoft() { return 0 fi echo "==> RoboSoft E2E (agentes deterministicos) contra el despliegue local (dev-bypass)" + + # `core-integration` entra en la lista SOLO si el Core esta desplegado. + # + # Estuvo excluido desde que existe porque necesitaba el Core, otro producto, + # que este pipeline no levantaba. Ahora `core-up` lo trae desde GHCR en un SHA + # fijado, asi que el robot que asevera las SEIS interfaces del par puede correr + # aqui en vez de a mano. La primera vez que se ejecuto contra un stack real + # encontro tres defectos que ninguna otra suite podia ver, dos de ellos de + # producto — uno en cada repositorio. + # + # La condicion se mira, no se asume: si el Core no esta, la lista queda como + # estaba y se DICE, en vez de correr un robot que fallaria por ausencia y + # pareceria un defecto del seam. + local lista="governance-journey,provider-connections,tenant-isolation,gate-enforcement,exception-governance,audit-trail,intake,qa-quality-gate,scorecard,phase-artifact-catalog" + local gw_pf="" + if kubectl -n "$CORE_NS" get deploy core-api >/dev/null 2>&1; then + echo "==> Core presente en $CORE_NS: se incluye core-integration" + lista="$lista,core-integration" + # El robot llega al Core POR el gateway (pasos 1-4), asi que necesita su + # propio port-forward ademas del de tracker-api. + kubectl -n "$NS" port-forward svc/tracker-gateway-evolith-tracker-gateway 3000:80 >/dev/null 2>&1 & + gw_pf=$! + sleep 4 + else + echo "==> Core AUSENTE en $CORE_NS: core-integration queda fuera de esta corrida." + echo " Esto NO es una corrida completa del seam; es la lista de siempre." + fi + # Hold a port-forward to the tracker-api for the whole run, then run the robots. # `_pf` returns the node exit code (0 pass / 1 fail), so this MORDS in CI. + local rc=0 _pf tracker-api-evolith-tracker-api 5100 \ env ROBOSOFT_API_BASE=http://localhost:5100 \ - ROBOSOFT_ONLY=governance-journey,provider-connections,tenant-isolation,gate-enforcement,exception-governance,audit-trail,intake,qa-quality-gate,scorecard,phase-artifact-catalog \ - node "$ROOT/robosoft/run.mjs" + ROBOSOFT_GATEWAY_BASE=http://localhost:3000 \ + ROBOSOFT_ONLY="$lista" \ + node "$ROOT/robosoft/run.mjs" || rc=$? + [ -n "$gw_pf" ] && kill "$gw_pf" >/dev/null 2>&1 + return $rc } # GT-604 — the evidence-deposit loop, end to end: a REAL `evolith evaluate` run @@ -495,6 +624,8 @@ case "${1:-up}" in install) install ;; smoke) smoke ;; smoke-prod) smoke_prod ;; + core-up) core_up ;; + core-down) core_down ;; robosoft) robosoft ;; robosoft-ingest) robosoft_ingest ;; verify-failclosed) verify_failclosed ;; diff --git a/product/infra/kind/core-stack.local.yaml b/product/infra/kind/core-stack.local.yaml new file mode 100644 index 00000000..915de78a --- /dev/null +++ b/product/infra/kind/core-stack.local.yaml @@ -0,0 +1,162 @@ +# The Evolith Core, deployed INTO this repository's kind cluster so the +# Tracker↔Core seam can be exercised by CI. +# +# ## Why a hand-written manifest and not the Core's Helm charts +# +# The charts live in the Core repository and this pipeline has no copy of them. +# Vendoring them would give this repo a second, drifting source of truth for +# somebody else's deployment — and it would bring OPA sidecars, HPAs, PDBs, +# IngressRoutes and NetworkPolicies that this test does not need and cannot +# maintain. What the seam needs is three reachable services. +# +# So this is deliberately minimal and deliberately NOT a deployment reference. +# It is a fixture: the smallest Core that answers the interfaces +# `core-integration` asserts. +# +# ## Images +# +# `IMAGE_TAG` is substituted by `local-test.sh core-up` from +# `product/infra/core-image.sha` — a pinned Core commit, never `latest`. The +# reasoning for pinning is in that file. +# +# ## The API key +# +# One key for all three, matching what the Tracker's own secrets carry. It is a +# local test credential and it says so; the Tracker's `tracker-core-auth` and +# `tracker-runtime-auth` must hold the SAME value or the seam answers 401 — +# which is exactly how this was first found, and the failure surfaced as a 502. +apiVersion: v1 +kind: Namespace +metadata: + name: evolith-core-local +--- +apiVersion: v1 +kind: Secret +metadata: + name: core-auth + namespace: evolith-core-local +type: Opaque +stringData: + EVOLITH_API_KEY: local-dev-key + AGENT_RUNTIME_API_KEY: local-dev-key +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: core-api + namespace: evolith-core-local + labels: { app: core-api } +spec: + replicas: 1 + selector: { matchLabels: { app: core-api } } + template: + metadata: + labels: { app: core-api } + spec: + # 1001 is the uid the Core's images create and chown their corpus to. + # Pinning 1000 here would reproduce the defect that made the deployed MCP + # unable to read its own policy.wasm and fail-close every tool call + # (evolith_arch32#425). + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + containers: + - name: core-api + image: IMAGE_CORE_API + imagePullPolicy: IfNotPresent + ports: [{ containerPort: 3000, name: http }] + envFrom: [{ secretRef: { name: core-auth } }] + readinessProbe: + httpGet: { path: /health, port: http } + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 24 +--- +apiVersion: v1 +kind: Service +metadata: + name: core-api + namespace: evolith-core-local +spec: + selector: { app: core-api } + ports: [{ port: 80, targetPort: http, name: http }] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: core-mcp + namespace: evolith-core-local + labels: { app: core-mcp } +spec: + replicas: 1 + selector: { matchLabels: { app: core-mcp } } + template: + metadata: + labels: { app: core-mcp } + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + containers: + - name: core-mcp + image: IMAGE_CORE_MCP + imagePullPolicy: IfNotPresent + ports: [{ containerPort: 3000, name: http }] + envFrom: [{ secretRef: { name: core-auth } }] + readinessProbe: + httpGet: { path: /health, port: http } + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 24 +--- +apiVersion: v1 +kind: Service +metadata: + name: core-mcp + namespace: evolith-core-local +spec: + selector: { app: core-mcp } + ports: [{ port: 80, targetPort: http, name: http }] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: core-agent-runtime + namespace: evolith-core-local + labels: { app: core-agent-runtime } +spec: + replicas: 1 + selector: { matchLabels: { app: core-agent-runtime } } + template: + metadata: + labels: { app: core-agent-runtime } + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + containers: + - name: core-agent-runtime + image: IMAGE_CORE_AGENT_RUNTIME + imagePullPolicy: IfNotPresent + ports: [{ containerPort: 3000, name: http }] + envFrom: [{ secretRef: { name: core-auth } }] + readinessProbe: + httpGet: { path: /health, port: http } + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 24 +--- +apiVersion: v1 +kind: Service +metadata: + name: core-agent-runtime + namespace: evolith-core-local +spec: + selector: { app: core-agent-runtime } + ports: [{ port: 80, targetPort: http, name: http }]