blog

De GitHub Actions a Kubernetes sin ningún secreto

Para desplegar en Kubernetes, un pipeline necesita una credencial, y lo normal es guardarla en un secreto del repositorio. Con kubelatch, un job de GitHub Actions consigue la suya a cambio de un token que GitHub firma para cada ejecución. La credencial dura una hora por defecto, en GitHub no se guarda nada y no hay nada que rotar. Eso sí, solo vale para GitHub Actions, y los runners tienen que llegar a tu kubelatch por https.

Un secreto en cada repositorio

Ese secreto suele ser un kubeconfig: el de un administrador, o el token de una ServiceAccount que alguien creó a mano para el job. Funciona, y es una forma habitual de empezar.

La pega es que cada repositorio que despliega se queda con una credencial de larga duración. Todos los pipelines que tienen una copia entran en el cluster con la misma identidad, así que la auditoría no los distingue. Para rotarla, además, tienes que encontrar todas las copias y cambiarlas a la vez.

El canje

GitHub Actions puede emitir un id_token OIDC para cada ejecución. Lo firma GitHub y lleva dentro el repositorio, la rama, el run_id y quién lanzó el job. El workflow se lo pide a GitHub con la URL de kubelatch como audiencia y se lo pasa a kubelatch.

kubelatch lo revisa a fondo: comprueba la firma con las claves públicas de GitHub, que el emisor sea exactamente el que espera, que la audiencia sea su propia URL y que el token siga siendo válido. Cada token vale para un solo canje: si alguien lo intercepta, no puede volver a usarlo. Después busca la regla de confianza que asocia tu repositorio a un bot. La crea un administrador, y puede limitarla a una rama, a un tag o a un entorno de GitHub. kubelatch compara ids numéricos y no nombres, porque un repositorio o una organización pueden cambiar de nombre, y el nombre que dejan lo puede coger otro.

Si una regla encaja, kubelatch devuelve la credencial de ese bot y su kubeconfig, una sola vez. Por defecto dura una hora, que da para un job; un administrador puede cambiarlo con KUBELATCH_CI_TOKEN_TTL, sin pasar del máximo de un bot. Si el job dura más, tiene que canjear un token nuevo. No hay nada que rotar, porque cada ejecución tiene la suya (qué se verifica).

El workflow

Aquí tienes el ejemplo de la documentación, el mismo que viene en el repositorio de kubelatch, sin los comentarios de cabecera. Va en .github/workflows/kubelatch.yml:

name: kubectl via kubelatch

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write   # required: lets the job request the OIDC token
  contents: read

env:
  KUBELATCH_URL: https://kubelatch.example.com   # exactly KUBELATCH_BASE_URL: it is the token's audience

jobs:
  pods:
    runs-on: ubuntu-latest
    # environment: prod   # uncomment if the trust rule sets an environment
    steps:
      - name: Request the OIDC token for kubelatch
        run: |
          ID_TOKEN=$(curl -sSf -G \
            -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            --data-urlencode "audience=$KUBELATCH_URL" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r .value)
          echo "::add-mask::$ID_TOKEN"
          echo "ID_TOKEN=$ID_TOKEN" >> "$GITHUB_ENV"

      - name: Exchange it for a kubelatch credential
        run: |
          RESPONSE=$(curl -sS -w '\n%{http_code}' -H 'Content-Type: application/json' \
            -d "$(jq -n --arg token "$ID_TOKEN" '{token:$token}')" "$KUBELATCH_URL/v1/ci/github-actions/token")
          STATUS=$(printf '%s' "$RESPONSE" | tail -n1)
          BODY=$(printf '%s' "$RESPONSE" | sed '$d')
          if [ "$STATUS" != "201" ]; then
            echo "kubelatch answered $STATUS: $(printf '%s' "$BODY" | jq -r '.error // .')" >&2
            exit 1
          fi
          # The token and kubeconfig are shown exactly once: keep them out of the log.
          printf '%s' "$BODY" | jq -r .token | xargs -I{} echo "::add-mask::{}"
          # Only this job's user may read it; never ~/.kube/config.
          (umask 077 && printf '%s' "$BODY" | jq -r .kubeconfig > "$RUNNER_TEMP/kubeconfig")
          chmod 600 "$RUNNER_TEMP/kubeconfig"
          echo "KUBECONFIG=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_ENV"
          echo "credential expires at $(printf '%s' "$BODY" | jq -r .expires_at)"

      - name: kubectl through kubelatch
        run: |
          kubectl auth whoami          # bot:<name> with the kubelatch:* groups of its grants
          kubectl get pods -n default

Fíjate en tres cosas. id-token: write permite que el job pida el token; sin esa línea no hay token. KUBELATCH_URL tiene que coincidir letra por letra con el KUBELATCH_BASE_URL del servidor, porque es la audiencia que exige kubelatch. Y el kubeconfig va a $RUNNER_TEMP, nunca al ~/.kube/config del runner, y el token sale enmascarado en el log. Si la regla fija un entorno, quita el # de la línea environment: y pon su nombre. Un administrador también te puede pasar una versión ya rellena para tu regla: al crearla, kubelatch le enseña el «Fragmento del workflow», con la URL de tu kubelatch, el disparador de su rama o tag y su entorno. El último paso sí lo cambias tú por lo que tenga que hacer el job en el cluster.

Lo que muestra la auditoría

En el job, kubectl auth whoami imprime bot:<nombre>: ese es el usuario que ve el cluster. Cada petición del workflow queda en la auditoría de kubelatch como una fila a nombre de ese bot.

Cada fila lleva la credencial que se usó, y por la credencial sabes de dónde viene la petición. En «Credenciales» aparece con el nombre github-actions y el texto «token de CI · run 1234 · octocat», y su nota lleva el repositorio, la rama, la ejecución y quién la lanzó: owner/repo@refs/heads/main · run <id> · <actor>. En la pestaña «Plano de control» de «Auditoría» verás su emisión, un credential.issue con via: github-actions, y cada canje fallido, como ci.exchange.failure. En «CI», el «Último canje» de cada regla dice cuándo la usó un workflow por última vez.

Así, de una petición llegas a la credencial, y de la credencial al repositorio, la rama, la ejecución y quien la lanzó (comprueba que funciona).

Lo que no cubre

El canje funciona con GitHub Actions, tanto en GitHub como en GitHub Enterprise Server. Los demás sistemas de CI, y herramientas como Argo CD, usan un bot con una credencial emitida a mano. Ahí sí vuelves a tener un secreto, que guardas en el gestor de secretos de esa herramienta, pero sabes quién lo emitió, caduca y cada uso queda en la auditoría. La credencial de un bot dura 90 días como mucho por defecto. En «Credenciales», las que caducan antes salen arriba, en ámbar cuando les quedan 7 días o menos y en rojo cuando les quedan menos de 24 horas (usuarios y bots).

Los runners también tienen que llegar a kubelatch por https, con un certificado en el que confíen: si tu kubelatch está en un portátil o detrás de una red privada, los runners que pone GitHub no llegan a él. Ojo con las reglas de «Cualquier workflow», que no fijan ninguna ref: también valen para los workflows de una pull request abierta desde una rama del mismo repositorio. Para todo lo que no sea de solo lectura, fija una rama o un tag (cómo se elige la regla).

Pruébalo

Si primero quieres ver kubelatch en tu máquina, el tutorial en local te lleva unos 15 minutos. Para un pipeline te hace falta un kubelatch al que lleguen tus runners: un administrador crea un bot, le da permisos en el cluster y crea una regla de confianza (CI: reglas de confianza), y tú añades el workflow (credenciales para GitHub Actions). En Free puedes tener todos los bots y reglas de CI que quieras; los límites son 2 clusters, 5 personas y 24 horas de auditoría (precios). kubelatch lo hace una sola persona, en España; si algo de esto no cuadra con lo que ves, escríbeme.