Sprawa pakietu, którego nie było

Krótka historia detektywistyczna ze świata pakietów Pythona i proxy artefaktów.

 ┌────────────────────────────────────────────────────────────┐
 │                     TABLICA ŚLEDCZA                        │
 ├────────────────────────────────────────────────────────────┤
 │                                                            │
 │   [ publiczny: requests ]         [ wewnętrzny: lib-x ]    │
 │        HTTP 200  ✓                     HTTP 404  ✗         │
 │              \                          /                  │
 │               \                        /                   │
 │                \                      /                    │
 │                 ┌────────────────────┐                     │
 │                 │ TEN SAM URL REPO   │                     │
 │                 │  "pp-pypi-public"  │                     │
 │                 └────────────────────┘                     │
 │                                                            │
 │        Werdykt: odzwierciedla tylko PUBLICZNY PyPI.        │
 │        Wewnętrznego pakietu nigdy tam nie było.            │
 └────────────────────────────────────────────────────────────┘

Zgłoszenie

Zaczęło się od rutynowej kontynuacji: „jest stara gałąź, która dodaje pobieranie pakietów Pythona przez proxy kurujące artefakty — możesz ją podjąć, zaktualizować i sprawdzić, czy to w ogóle działa?”

Na papierze dość proste. Gałąź miała kilka miesięcy. Gałąź main poszła dalej bez niej. I nikt nie dokończył dowodu, że pomysł działa od początku do końca.

Rozdział 1 — Gałąź z tajemnicami

Pierwszy krok był przyziemny: fetch, porównanie, rebase. Stara gałąź była jeden commit przed main i kilka commitów za nim — czysty, nudny rebase, a przynajmniej tak to wyglądało.

Potem przyszedł push. Git odmówił.

! [rejected]   feat-branch -> feat-branch (stale info)

To jedno słowo — stale — było pierwszym prawdziwym tropem. Fetch samej gałęzi funkcji (a nie tylko main) ujawnił zwrot akcji: kolega po cichu wypchnął dwa kolejne commity na dokładnie tę samą gałąź, już po ostatniej synchronizacji lokalnego klona. Jeden z nich wygenerował na nowo plik lock zależności. Drugi wprowadzał bardzo konkretną, bardzo przemyślaną zmianę: kierował źródło instalacji każdego wewnętrznego pakietu na nowe proxy kurujące artefakty i całkowicie usuwał stare źródło zapasowe.

Zabezpieczenie gita — odmowa pusha, kiedy zdalna gałąź się przesunęła — właśnie powstrzymało rebase przed po cichu wymazaniem pracy kolegi. Pierwsza zagadka rozwiązana, zanim prawdziwe śledztwo w ogóle się zaczęło: zawsze rób fetch gałęzi, na którą zamierzasz zrobić force-push, a nie tylko trunka, na który rebase'ujesz.

Rozdział 2 — Scalanie dwóch linii czasu

Z prawdziwym, aktualnym czubkiem gałęzi rebase ruszył ponownie. Tym razem trafił na prawdziwy konflikt: main podbił te same wewnętrzne pakiety do nowszych wersji, a commit kolegi zmienił, skąd te same pakiety mają być pobierane. Obie zmiany były poprawne; po prostu dotykały tych samych linii.

Rozwiązanie w izolacji było proste — zachowaj nowsze wersje, zachowaj nowe źródło — ale dało coś, czego nikt nigdy nie przetestował: konfigurację, której jeszcze nikt nie uruchomił od początku do końca.

   MAIN MÓWI:                          GAŁĄŹ MÓWI:
  lib-x = 2.2.2  (źródło gitlab)      lib-x = 2.1.0  (źródło new-proxy)
        \                                    /
         \                                  /
          ▼                                ▼
       lib-x = 2.2.2  (źródło new-proxy)   <- scalone, nieprzetestowane

Rozdział 3 — Pakiet, którego nie było

Czas to faktycznie uruchomić. Świeże zablokowanie zależności na scalonej konfiguracji padło od razu — nie na konflikcie wersji, tylko na czymś bardziej fundamentalnym. Każdy wewnętrzny pakiet, od którego zależał projekt, wracał tak samo:

Authorization error accessing .../pp-pypi-public/simple/lib-python-internal-service-client/

Błąd autoryzacji jest z natury niejednoznaczny — może znaczyć „nie wolno ci tego zobaczyć” albo coś zupełnie innego przebranego za błąd autoryzacji. Skonfigurowanie prawdziwych poświadczeń zamieniło niejednoznaczny błąd w precyzyjny: to samo żądanie wróciło z 404, a nie 403. Nie „zabronione”. Nie znaleziono.

Szybki, celowy test rozstrzygnął sprawę. Zapytaj to samo proxy o pakiet, o którym wszyscy wiedzą, że jest publiczny:

curl .../pp-pypi-public/simple/requests/           -> 200 OK
curl .../pp-pypi-public/simple/lib-python-internal-x/ -> 404 Not Found

Proxy robiło dokładnie to, do czego je zbudowano — odzwierciedlało publiczny indeks PyPI, z korzyściami kuracji i skanowania, jakie to daje. Po prostu nikt nigdy nie poprosił go o hostowanie ani odzwierciedlanie czegokolwiek wewnętrznego. Commit kolegi skierował cały zestaw wewnętrznych pakietów do sklepu, który nigdy ich nie miał, a potem rozebrał jedyną półkę, na której leżały.

To łatwa pułapka: z zewnątrz narzędzie wygląda jak jeden zunifikowany indeks pakietów — jeden URL, jedno API simple/, ten sam kształt co każde inne repozytorium pakietów Pythona. Nic w interfejsie nie sugeruje, że obejmuje tylko połowę pakietów, o które możesz poprosić.

Rozdział 4 — Rozdzielenie z powrotem

Poprawka cofnęła dokładnie tę część, która była błędna, i zachowała tę, która była dobra:

Została jeszcze jedna luka: zapisany w repo plik lock był teraz nieaktualny względem poprawionej konfiguracji, a jego lokalne odtworzenie wymagało poświadczeń, których w tej chwili nie było. Zamiast się na tym blokować, poprawkę uczyniono samonaprawiającą się w miejscu, które naprawdę ma znaczenie — krok budowy kontenera zmieniono ze zwykłej instalacji na relock, potem instalacja, żeby plik lock rozjeżdżający się z własnym manifestem nie psuł po cichu potoku budującego obraz produkcyjny.

   PRZEDTEM                                  POTEM
  tylko instalacja                          relock, potem instalacja
  ┌─────────────────┐                       ┌─────────────────┐
  │ ślepo ufa       │                       │ każdy build     │
  │ zapisanemu      │  ── naprawione ──►    │ najpierw tworzy │
  │ plikowi lock    │                       │ lock od nowa    │
  └─────────────────┘                       └─────────────────┘

Poprawiony potok wypchnięto i obserwowano na żywo. Build przeszedł od początku do końca: samo narzędzie do pakietów pobrało się przez nowe proxy, publiczne zależności projektu też rozwiązały się przez nie, wewnętrzne pakiety wróciły ze swojego pierwotnego miejsca dokładnie jak wcześniej, a końcowy obraz czysto trafił do rejestru.

Podsumowanie — Wskazówki dla kolegów śledczych

  1. Proxy artefaktów, które wygląda na uniwersalne, zwykle nim nie jest. Jeden URL i znajome API (simple/<package>/) nic nie mówią o zakresie. Zanim skierujesz cokolwiek na nowy indeks — publiczny czy prywatny — przetestuj go czymś, o czym już wiesz, że powinno tam być, i czymś, czego nie powinno.
  2. 404 i 403 to różne błędy w podobnym przebraniu. „Authorization error” może znaczyć „brak ci uprawnień” albo „nigdy nie było tu nic, do czego można by dać uprawnienie”. Wprowadź prawdziwe poświadczenia jak najwcześniej, żeby komunikat, który debugujesz, był tym prawdziwym.
  3. Rób fetch gałęzi, nie tylko trunka. Rebase na zaktualizowany main to rutyna. Zapomnienie o odświeżeniu gałęzi funkcji, na którą zaraz zrobisz force-push, to sposób na prawie nadpisanie już wypchniętej pracy kolegi. Niech odrzucony push sprawi, że się zatrzymasz i popatrzysz, zamiast sięgać po --force.
  4. Spraw, żeby nudny tryb awarii sam się naprawiał. Plik lock po cichu rozjeżdżający się z własnym manifestem to powolna, powracająca awaria. Generowanie go jako krok buildu, zamiast ufania temu, co ostatnio zapisano, zamienia całą klasę przyszłych awarii w nie-zdarzenie.
  5. Narzędzie, które robi tylko połowę tego, co zakładałeś, nadal warto trzymać — do tej połowy, w której jest dobre. Prawidłową poprawką nie było porzucenie nowego proxy, tylko właściwe zawężenie jego zakresu i pozwolenie mu robić jedyną rzecz, do której faktycznie je zbudowano.