Files
deploy/astrololo/README.md
T
gitea f2ea8e26c0 image-updater: definicja wdrażana przez ArgoCD zamiast wklepywanej ręcznie
Astroklient zbudował się poprawnie, ale jego obraz nie schodził do klastra.
Przyczyna nie leżała ani w CI, ani w rejestrze: CRD ImageUpdater w klastrze miał
CZTERY obrazy (data, logic, presentation, render), a plik w gicie sześć.
Brakowało astrodemo I astroklienta.

`image-updater.yaml` nie był w kustomization.yaml, więc ArgoCD go nie nakładał.
Dopisanie usługi do pliku nie robiło zatem nic — listy nikt nie stosował.

ODWRACAM DECYZJĘ UDOKUMENTOWANĄ W TYM REPO. README twierdziło, że wyłączenie jest
celowe: resource stoi w ns argocd, poza namespace docelowym aplikacji, i jest
konfiguracją kontrolera, a nie aplikacji. Rozumowanie nie było głupie, ale nie
wytrzymało praktyki — ręczny krok zapomniano dwa razy w ciągu dwóch tygodni,
najpierw dla astrodemo, potem dla astroklienta.

Najgorsza była CICHOŚĆ rozjazdu. Plik w gicie wyglądał poprawnie, git log
pokazywał dopisanie usługi, a objawem był obraz, który „się nie deployuje" — co
kieruje podejrzenia na CI albo na rejestr, czyli wszędzie poza właściwe miejsce.
Diagnoza wymaga porównania pliku ze stanem klastra, a nikt nie porównuje rzeczy,
o której nie wie, że mogą się różnić.

Przeszkody technicznej nie było: projekt ArgoCD dopuszcza dowolną przestrzeń nazw
(destinations: '*'), a dry-run serwerowy przyjmuje resource bez zastrzeżeń.

W README zostaje wyjaśnienie, dlaczego jest w kustomization mimo wcześniejszego
uzasadnienia — razem z prośbą, żeby przy ewentualnym wyjmowaniu usunąć też ten
akapit. Inaczej następna osoba przeczyta powód, którego już nie ma.

Zweryfikowane: kustomize build 38 obiektów (było 37), dry-run serwerowy
aktualizuje istniejący CRD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:18:45 +02:00

19 KiB

astrololo — deploy (namespace astrololo)

Manifesty k8s składane Kustomize. Obrazy podbija automatycznie image-updater (kustomization.yamlimages: newTag) po każdym buildzie z repo aplikacji.

Warstwy: presentation (wejście z przeglądarki przez Ingress po https) → logicdata (pliki Excel montowane z NFS, nie z obrazu).

Adres aplikacji: https://astrololo.czernobog.pl — patrz TLS.

Co śledzi image-updater — image-updater.yaml

Lista obrazów podbijanych automatycznie jest jawna i żyje w CRD ImageUpdater (ns argocd). Obraz, którego na niej nie ma, nigdy się nie podbije, choćby CI go budowało — tak przez chwilę wisiał render na :latest.

Dodając nową usługę, dopisz jej wpis w image-updater.yaml. Nic więcej nie trzeba: plik jest w kustomization.yaml, więc ArgoCD nakłada go razem z resztą.

Dlaczego jest w kustomization, skoro kiedyś celowo nie był

Wcześniej ten plik był z niej wyłączony z uzasadnieniem, że resource stoi w ns argocd, poza namespace docelowym aplikacji, i że to konfiguracja kontrolera, a nie samej aplikacji — więc nakłada się go ręcznie.

Rozumowanie nie było głupie, ale nie wytrzymało praktyki. Ręczny krok zapomniano dwa razy w ciągu dwóch tygodni: najpierw dla astrodemo, potem dla astroklient. Obie usługi miały wpis w pliku i obu brakowało w klastrze, więc ich obrazy stały w miejscu mimo poprawnie działającego CI.

Najgorsza w tym była cichość rozjazdu: plik w gicie wyglądał poprawnie, git log pokazywał dopisanie usługi, a objawem był obraz, który „się nie deployuje" — co kieruje podejrzenia na CI albo na rejestr, czyli wszędzie poza właściwe miejsce. Diagnoza wymaga porównania pliku ze stanem klastra, a nikt nie porównuje rzeczy, o której nie wie, że mogą się różnić.

Przeszkody technicznej nie było: projekt ArgoCD dopuszcza dowolną przestrzeń nazw (destinations: [{namespace: "*", server: "*"}]), więc resource w ns argocd nakłada się bez zastrzeżeń.

Jeśli kiedyś trzeba będzie go stamtąd wyjąć, wyjmij razem z tym akapitem — inaczej następna osoba przeczyta uzasadnienie, którego już nie ma.

Pliki baz — udział otwarty na zapis (DAN-27)

Runbook: README-pliki.md. Ekran „Pliki" wymaga zapisu do udziału z bazami, więc znosi gwarancję z DAN-25, że baz nie da się zmienić przez NFS. Co zostaje z zabezpieczeń i jak to wdrożyć — w runbooku.

astrodemo — wersja demonstracyjna (PRE-28/29)

Osobna usługa o dwóch funkcjach: dodanie pliku i pytanie o interpretację urodzeniową. Runbook: README-astrodemo.md.

Ma własną warstwę danych i logiki (astrodemo-stack.yaml) oraz własny udział NFS, a każde konto dostaje w nim odrębną, niewidoczną dla pozostałych pulę plików (PRE-29). Nie sięga do produkcyjnych baz.

Konta są osobne (sekret astrololo-astrodemo), więc usługę odcina się jedną zmianą, bez ruszania kont głównej aplikacji. Wchodzi się przez własny host astrodemo.czernobog.pl — nie przez ścieżkę pod adresem astrololo, bo wspólne pochodzenie oznaczałoby wspólne ciasteczka i wzajemne wylogowywanie.

⚠️ Wymóg węzłów: klient NFS

Wszystkie warstwy montują udziały z NAS-a, więc każdy węzeł, na którym może wylądować pod, musi mieć klienta NFS. Bez niego kubelet nie zamontuje wolumenu:

mount: ... bad option; for several filesystems (e.g. nfs, cifs) you might need
a /sbin/mount.<type> helper program

Ten komunikat nie oznacza problemu z udziałem ani z uprawnieniami — serwer w ogóle nie został zapytany. Jądro nie znalazło programu pomocniczego /sbin/mount.nfs.

Sprawdzenie

for N in 192.168.1.73 192.168.1.80 192.168.1.81 192.168.1.82; do
  printf "%-15s " "$N"
  ssh hammer@$N 'test -x /sbin/mount.nfs && echo MA-KLIENTA || echo BRAK-KLIENTA'
done

Instalacja

ssh hammer@<węzeł> 'sudo apt-get update && sudo apt-get install -y nfs-common'

Na WSZYSTKICH węzłach, nie tylko na tym, gdzie pod stoi teraz. Braku na pozostałych nie widać, dopóki scheduler tam czegoś nie przeniesie — a wtedy awaria wygląda na nagłą, choć przyczyna leżała od dawna. Dokładnie tak wyszło za pierwszym razem: pody działały miesiącami na jednym węźle, aż zejście do zera replik przy innej naprawie przeplanowało je gdzie indziej.

Skąd wiadomo, że to TO

komunikat co znaczy
bad option ... mount.<type> helper program brak nfs-common na węźle — serwer niepytany
access denied by server while mounting serwer odmawia: eksport nieprzeładowany, węzła nie ma na liście hosts, albo udział wyłączony
Permission denied przy zapisie montowanie działa, brakuje praw — patrz mapall_user i właściciel katalogu

⚠️ Wymóg węzłów: rozszerzenia procesora (x86-64-v2)

Warstwa danych używa pandas, a przez nią NumPy. Koła NumPy z PyPI są budowane z bazą x86-64-v2, więc na węźle bez tych rozszerzeń kontener nie wstaje:

RuntimeError: NumPy was built with baseline optimizations:
(X86_V2) but your machine doesn't support: (X86_V2).

Komunikat mówi o „optymalizacjach", czyli o niczym, po czym dałoby się poznać, że chodzi o węzeł, a nie o obraz. Pod wpada w pętlę restartów i wygląda to na zepsuty build.

To NIE jest ograniczenie sprzętu

Węzły k3s są maszynami wirtualnymi. Proxmox z domyślnym modelem CPU (kvm64, „Common KVM processor") maskuje flagi procesora — gość nie widzi SSE4.2 ani POPCNT, mimo że fizyczny i7-3770 obsługuje nawet x86-64-v3. Stąd bierze się złudzenie, że „węzły mają różne procesory": mają ten sam sprzęt i różne maski.

Sprawdzenie: które węzły to udźwigną

for N in $(kubectl get nodes -o jsonpath='{.items[*].metadata.name}'); do
  kubectl run cpucheck-$N --image=busybox --restart=Never --rm -i --quiet \
    --overrides="{\"spec\":{\"nodeName\":\"$N\",\"tolerations\":[{\"operator\":\"Exists\"}]}}" \
    --command -- sh -c '
      F=$(grep -m1 ^flags /proc/cpuinfo); BRAK=""
      for f in cx16 lahf_lm popcnt pni ssse3 sse4_1 sse4_2; do
        echo "$F" | grep -qw "$f" || BRAK="$BRAK $f"
      done
      echo "$(grep -m1 "model name" /proc/cpuinfo | cut -d: -f2-)"
      [ -z "$BRAK" ] && echo "  x86-64-v2: TAK" || echo "  x86-64-v2: NIE —$BRAK"
    ' 2>/dev/null | sed "s/^/$N /"
done

Pułapka przy czytaniu flag: Linux raportuje SSE3 jako pni (Prescott New Instructions), nie sse3. Szukanie sse3 daje fałszywy alarm na maszynie, która SSE3 ma — pierwsza wersja tej kontroli wskazała w ten sposób trzy zdrowe węzły jako niesprawne.

Naprawa u źródła (Proxmox)

Na węźle Proxmox, dla każdej maszyny k3s:

qm set <VMID> --cpu host
qm stop <VMID> && qm start <VMID>

Sam restart z wnętrza gościa nie wystarczy — zmiana modelu CPU wchodzi dopiero przy pełnym wyłączeniu i włączeniu maszyny.

--cpu host przekazuje pełny zestaw rozszerzeń i daje przy okazji realnie szybszą arytmetykę. Ma jeden koszt: blokuje migrację na żywo między hostami o różnych procesorach. Jeśli kiedyś zaczniemy migrować maszyny między pve2 a NUC-iem, właściwym wyborem będzie wspólny mianownik x86-64-v2-AES zamiast host.

Zabezpieczenie: etykieta zamiast wykluczania po nazwie

data i data-demo wymagają etykiety astrololo.czernobog.pl/cpu-x86-64-v2. Nowy węzeł jest domyślnie nieoznaczony, więc nie dostanie tych podów, dopóki ktoś go nie sprawdzi i nie oznaczy świadomie:

kubectl label node <węzeł> astrololo.czernobog.pl/cpu-x86-64-v2=true

Wykluczanie konkretnego węzła po nazwie znaczyłoby, że każdy KOLEJNY źle postawiony węzeł znów zbierze się przez awarię. Tak wyszedł agent3: dołożony 14 dni po pozostałych, bez kroku --cpu host, i nikt tego nie zauważył, dopóki scheduler nie postawił tam akurat warstwy danych.

KOLEJNOŚĆ MA ZNACZENIE. Oznacz węzły ZANIM to wdrożysz — bez etykiet data nie ma się gdzie uruchomić i pójdzie w Pending.

⚠️ Sekret astrololo-auth — utwórz PRZED wdrożeniem

Aplikacja wystawia treść oryginalnych baz interpretacyjnych, dlatego wymaga logowania i tokenu międzywarstwowego (LOG-32). Manifesty odwołują się do sekretu astrololo-auth i celowo nie zawierają jego wartości — to repo GitOps, więc cokolwiek by tu wpadło, zostałoby w historii gita na zawsze.

To ta sama konwencja co gitea-registry: sekret tworzymy poza repo.

Pody nie wstaną bez tego sekretu — i tak ma być. Wolimy widoczną awarię niż cichy start aplikacji bez ochrony.

# hasło wczytane bez zapisu w historii powłoki
read -rs -p "Hasło do aplikacji (APP_PASSWORD): " APP_PASSWORD; echo

kubectl -n astrololo create secret generic astrololo-auth \
  --from-literal=APP_PASSWORD="$APP_PASSWORD" \
  --from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
  --from-literal=SESSION_SECRET="$(openssl rand -hex 32)"

unset APP_PASSWORD

SESSION_SECRET podpisuje ciasteczka sesji (LOG-34). Pod bez niego celowo nie wstanie: usługa z kontami, ale bez klucza, nie odróżniłaby ważnej sesji od podrobionej. Nikt go nigdy nie musi oglądać.

⚠️ Masz już sekret sprzed LOG-34? Dołóż klucz, nie twórz od nowa

Komenda wyżej zakłada sekret od zera. Jeśli astrololo-auth już istnieje, merge manifestu sam klucza nie dołoży — pod zgłosi wtedy:

Error: couldn't find key SESSION_SECRET in Secret astrololo/astrololo-auth

Dokładamy klucz, nie ruszając pozostałych:

kubectl -n astrololo patch secret astrololo-auth --type=merge \
  -p "{\"stringData\":{\"SESSION_SECRET\":\"$(openssl rand -hex 32)\"}}"

kubectl -n astrololo rollout restart deploy/presentation

Sprawdzenie, że komplet kluczy jest na miejscu (bez pokazywania wartości):

kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data}' \
  | tr ',' '\n' | grep -o '"[A-Z_]*"'

Oczekiwane: APP_PASSWORD, INTERNAL_TOKEN, SESSION_SECRET.

--type=merge ze stringData dokłada albo nadpisuje i nie wymaga, żeby klucz wcześniej istniał — w odróżnieniu od JSON Patch z op: replace, który na brakującej ścieżce po prostu odmawia. Ta sama komenda służy więc i do dołożenia, i do rotacji.

Rotacja klucza sesji

Wylogowuje WSZYSTKICH. To nie usterka, tylko awaryjny wyłącznik: gdy podejrzewasz, że ktoś przechwycił cudzą sesję, podmiana klucza unieważnia je wszystkie naraz. Komenda ta sama, co dołożenie wyżej.

INTERNAL_TOKEN jest losowany i nikt go nigdy nie musi oglądać — służy tylko usługom do rozmowy między sobą. APP_PASSWORD wpisujesz w przeglądarce (użytkownik: astrololo, zmienny przez APP_USER w presentation.yaml).

Zmiana hasła

read -rs -p "Nowe hasło: " NEW; echo
kubectl -n astrololo create secret generic astrololo-auth \
  --from-literal=APP_PASSWORD="$NEW" \
  --from-literal=INTERNAL_TOKEN="$(kubectl -n astrololo get secret astrololo-auth \
      -o jsonpath='{.data.INTERNAL_TOKEN}' | base64 -d)" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n astrololo rollout restart deploy/presentation
unset NEW

(Zachowujemy istniejący INTERNAL_TOKEN; jego zmiana wymaga restartu wszystkich trzech usług naraz, inaczej przestaną się dogadywać.)

Sprawdzenie po wdrożeniu

kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
curl -s -o /dev/null -w "bez hasła: %{http_code}\n"  https://astrololo.czernobog.pl/
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/

Oczekiwane: 401 bez hasła, 200 z hasłem. /health zostaje publiczny (sondy k8s).

🔒 TLS — wymagane kroki przed wdrożeniem

Do tej pory Basic Auth szedł siecią po zwykłym http, czyli hasło dało się podsłuchać (base64 to nie szyfrowanie). Poza tym http blokował dwie funkcje, bo przeglądarki udostępniają je wyłącznie w tzw. secure context: geolokalizacja („Tu i teraz") i kopiowanie do schowka. Certyfikat załatwia oba tematy naraz.

Certyfikat wystawia własne CA trzymane w klastrze (cert-manager). Let's Encrypt odpada, bo klaster stoi w LAN — walidacja HTTP-01 nie ma jak do niego dojść z internetu, a DNS-01 wymagałby trzymania tu tokena API do domeny.

Kolejność ma znaczenie — oba kroki PRZED synchronizacją ArgoCD.

1. cert-manager (jednorazowo, cały klaster)

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook

Bez tego API odrzuci Certificate i Issuer jako nieznane rodzaje zasobów, a ArgoCD pokaże aplikację jako niezsynchronizowaną.

2. DNS: astrololo.czernobog.pl → adres Traefika

kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo

Wpis w routerze / lokalnym DNS-ie (albo doraźnie /etc/hosts). Service presentation jest teraz ClusterIP — NodePort był drugą, nieszyfrowaną drogą do aplikacji, więc został zdjęty. Zanim DNS zacznie odpowiadać, jedyne wejście to:

kubectl -n astrololo port-forward svc/presentation 8000:8000   # awaryjnie, http://localhost:8000

3. Zaufanie do własnego CA (raz na urządzenie)

Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń ważny 10 lat, więc import robisz raz:

kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
  | base64 -d > astrololo-ca.crt

# macOS — do systemowego zaufania (poprosi o hasło administratora)
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain astrololo-ca.crt

# Linux (Debian/Ubuntu)
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates

Firefox ma własny magazyn — tam import przez Ustawienia → Prywatność → Wyświetl certyfikaty → Organy certyfikacji.

Sprawdzenie samego TLS

# certyfikat wystawiony i gotowy
kubectl -n astrololo get certificate
# oczekiwane: astrololo-ca True, astrololo-tls True

# http odsyła na https
curl -sI http://astrololo.czernobog.pl/ | head -2       # 301 + Location: https://...

# HSTS obecny
curl -skI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport

Odnawianie

Certyfikat serwera żyje 90 dni i odnawia się sam 30 dni przed końcem. Termin jest krótki celowo — odnawianie sprawdza się wtedy w praktyce co kwartał, a nie dopiero za dziesięć lat, gdy nikt nie będzie pamiętał, jak to było skonfigurowane.

Token międzywarstwowy mówi kto pyta, ale nie ukrywa czego dotyczy odpowiedź — a płyną nią surowe wiersze oryginalnych baz. Kto podsłuchał ruch wewnątrz sieci (drugi pod, mirror portu na switchu, zrzut z węzła), miał je w całości. Łącza są więc szyfrowane AES-256-GCM, osobnym kluczem na każdą parę rozmówców.

kubectl -n astrololo create secret generic astrololo-link \
  --from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
  --from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"

Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Rozdzielenie jest celowe: przejęcie klucza prezentacji nie daje dostępu do warstwy danych, gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony; prezentacja i dane dostają przez secretKeyRef wyłącznie swój.

Pody nie wstaną bez tych kluczy (LINK_ENCRYPTION_REQUIRED=true) — i tak ma być. Usługa, która wstała i po cichu nie szyfruje, jest gorsza niż pod w CrashLoop, bo awarii nie widać.

Wymiana kluczy

kubectl -n astrololo create secret generic astrololo-link \
  --from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
  --from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n astrololo rollout restart deploy/presentation deploy/logic deploy/data

Restart wszystkich trzech naraz — w trakcie wymiany warstwy chwilowo się nie dogadują (klucze muszą być zgodne po obu stronach łącza).

Klucze do modeli w chmurze — sekret astrololo-llm (opcjonalny)

Domyślnie działa model lokalny i nic nie opuszcza sieci. Żeby móc wybrać w UI OpenAI lub Anthropic, potrzebne są ich klucze. Ten sekret jest opcjonalny (optional: true) — bez niego pody startują normalnie, tylko chmura jest niedostępna.

read -rs -p "OPENAI_API_KEY (Enter = pomiń): " OPENAI_KEY; echo
read -rs -p "ANTHROPIC_API_KEY (Enter = pomiń): " ANTHROPIC_KEY; echo

kubectl -n astrololo create secret generic astrololo-llm \
  --from-literal=OPENAI_API_KEY="$OPENAI_KEY" \
  --from-literal=ANTHROPIC_API_KEY="$ANTHROPIC_KEY"

unset OPENAI_KEY ANTHROPIC_KEY
kubectl -n astrololo rollout restart deploy/logic     # klucze wstrzykują się przy starcie

Podmiana pojedynczego klucza (bez kasowania drugiego):

kubectl -n astrololo create secret generic astrololo-llm \
  --from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm \
      -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
  --from-literal=ANTHROPIC_API_KEY='NOWY-KLUCZ' \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n astrololo rollout restart deploy/logic

Adres modelu lokalnego ustawia LOCAL_BASE_URL w logic.yaml — dopasuj do miejsca, gdzie faktycznie stoi Ollama/vLLM. Konfiguracja jest per dostawca (LOCAL_*, OPENAI_*, ANTHROPIC_*), więc ustawienia lokalnego modelu nie przejmują żądań do chmury.

Wybór dostawcy w chmurze oznacza, że oryginalne opisy z baz opuszczają naszą sieć. Prompt można obejrzeć przed wysłaniem. Warto zawnioskować u dostawcy o Zero Data Retention — patrz LOG-32.

Czego to nie załatwia

  • Własne CA to nie to samo co publiczne zaufanie — każde nowe urządzenie wymaga importu korzenia (krok 3 powyżej). Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest Let's Encrypt przez DNS-01.
  • Szyfrowane są ciała żądań, nie nagłówki — ścieżka (/search) i token międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje: bez klucza łącza każde żądanie kończy się odmową. Pełne ukrycie metadanych wymagałoby mTLS.
  • NFS 192.168.1.34:/mnt/Tank1/astrololo — kto ma dostęp do share'u, bierze pliki baz z pominięciem całej aplikacji. Do zamknięcia po stronie infrastruktury (eksport tylko dla IP węzłów, root_squash, najlepiej read-only).
  • Sekret w etcd jest tylko zakodowany base64. Docelowo: szyfrowanie etcd at-rest albo Sealed Secrets / SOPS, jeśli chcecie trzymać sekrety deklaratywnie w repo.

Profil ze swissephem

Wariant z silnikiem B: ../astrololo-swisseph/. Dziedziczy powyższe zmienne z tej bazy, więc też wymaga sekretu astrololo-auth.