Token, który umarł, gdy nikt nie wdrażał

Krótka historia detektywistyczna ze świata CI/CD.

Objaw

Pull request zostaje scalony i rusza potok wdrożeniowy. Wszystko zielone: siedem zbudowanych obrazów Dockera, testy jednostkowe, testy integracyjne, pełny zestaw testów end-to-end w przeglądarce. A potem ostatni job, ten, który przekazuje wydanie do repozytorium GitOps, robi się czerwony po niecałej sekundzie:

RequestError [HttpError]: Bad credentials
  status: '401'
  url: 'https://api.github.com/repos/<org>/<gitops-repo>/dispatches'

Kod był w porządku. I tak nigdy nie miał trafić do środowiska dev.

Kształt potoku

Projekt dzieli „budowanie” i „wdrażanie” na dwa repozytoria, co jest typowym układem GitOps:

 repo aplikacji                            repo GitOps
┌──────────────────────────┐            ┌──────────────────────────┐
│ budowa obrazów           │            │ podbicie wersji obrazu   │
│ uruchomienie testów      │            │ dla env=dev|stg|prod     │
│ trigger_the_deployment ──┼── POST ───►│ ArgoCD synchronizuje     │
│   repository_dispatch    │  (token)   │ klaster                  │
└──────────────────────────┘            └──────────────────────────┘

Ostatni krok wysyła zdarzenie repository_dispatch do repo GitOps z małym ładunkiem JSON: które środowisko, która usługa, która wersja. Wywołanie API innego repozytorium wymaga poświadczenia sięgającego poza repo, w którym działa workflow. Wbudowany GITHUB_TOKEN tego nie potrafi, więc lata temu ktoś utworzył personal access token i zapisał go jako sekret.

Zły zakręt: uruchomienie, którego nie było

Pierwsza próba przeczytania logów nieudanego joba z terminala zwróciła to:

failed to get run: HTTP 404: Not Found

Uruchomienie było wyraźnie widoczne w przeglądarce. Repozytorium jednak nie zniknęło. CLI gh miało zalogowane trzy konta GitHuba, a aktywne należało do innej organizacji. GitHub dla prywatnych repo, których nie widzisz, odpowiada 404 zamiast 403, więc „brak dostępu” wygląda dokładnie jak „nie ma czegoś takiego”. Uruchomienie tego jednego polecenia z tokenem innego konta, bez globalnego przełączania czegokolwiek, rozwiązało sprawę:

GH_TOKEN=$(gh auth token -u <right-account>) gh run view --job <id> --log-failed

Drobiazg, ale to już drugi raz, kiedy dokładnie to przebranie kosztowało czas na tej maszynie.

Właściwe czytanie 401

Kod statusu mówi więcej niż komunikat. GitHub zwraca:

To więc nie była łamigłówka z uprawnieniami. Nikt nie musiał porównywać zakresów. Token był po prostu martwy.

Zwrot akcji: oś czasu

Historia uruchomień wyjaśniła, jak umarł niezauważony:

ostatnie udane wdrożenie ──────────── ~11 miesięcy, zero uruchomień ───────────►  pierwsza porażka
        ●                                                                          ✗ ✗ ✗

Projekt był cicho przez prawie rok. Token przestał działać gdzieś w tej przerwie i nic nie próbowało go użyć aż do pierwszego scalenia po przerwie. Wszystkie testy przeszły, a padł sam ostatni krok.

Drugim zwrotem było szybkie przeszukanie plików workflow. Ten sam sekret zasilał workflow stagingu (dwa razy, ścieżka standardowa i hotfix) oraz workflow produkcji, które uruchamiają się tylko przy tagu i wydaniu. Od przerwy nikt ich też nie uruchomił. Głośna porażka dev to była ta szczęśliwa wersja: następne wydanie produkcyjne zbudowałoby wszystko, a potem niczego nie wdrożyło.

Poprawka: przestać w ogóle posiadać token

Oczywistą poprawką jest nowy personal access token. Właściciel obciążenia tego nie chciał i słusznie. PAT zawsze uwierzytelnia się jako jeden człowiek, a długowieczny PAT to dokładnie to, jak do tego w ogóle doszło.

Zamiennikiem jest GitHub App, najbliższy odpowiednik konta serwisowego, jaki ma GitHub:

  1. Utwórz ją na poziomie organizacji, nie w repozytorium. Webhook wyłączony, bez OAuth i dokładnie jedno uprawnienie: Contents: Read and write, którego wymaga repository_dispatch.
  2. Zainstaluj ją tylko w repozytorium docelowym (GitOps).
  3. Zapisz jej ID i klucz prywatny jako sekrety w repozytorium źródłowym (aplikacji), tym, które uruchamia workflow.
  4. Przed każdym dispatchem wybij krótkotrwały token, ograniczony do tego jednego docelowego repo:
- name: Generate the GitOps token
  id: gitops_token
  uses: actions/create-github-app-token@v2
  with:
    app-id: ${{ secrets.APP_ID }}
    private-key: ${{ secrets.APP_PRIVATE_KEY }}
    owner: <org>
    repositories: <gitops-repo>
- name: Emit repository_dispatch
  uses: <dispatch-action>
  with:
    token: ${{ steps.gitops_token.outputs.token }}

Token żyje godzinę i jest tworzony od nowa przy każdym uruchomieniu. Nie zostaje nic długowiecznego, co mogłoby po cichu wygasnąć w trakcie następnego cichego roku. Wszystkie cztery kroki dispatch dostały zmianę w jednym pull requeście.

Ostatnia niespodzianka: scalenie poprawki niczego nie wdrożyło

PR został scalony. Żaden potok nie ruszył.

Workflow dev miał paths-ignore: [".github/**", ...], co jest rozsądne, bo edycja konfiguracji CI nie powinna ponownie wdrażać aplikacji. Ale ten PR dotykał tylko .github/. Poprawka workflow wdrożeniowego była dokładnie tym rodzajem zmiany, który workflow wdrożeniowy ignoruje. Przetestowanie jej wymaga ręcznego uruchomienia workflow_dispatch albo następnej prawdziwej zmiany w kodzie. W chwili pisania to uruchomienie wciąż czeka, więc historia uczciwie kończy się na „powinno działać”, a nie „działa”.

Wskazówki dla czytelnika