Przejdź do treści
Lekcja 34 z 117

Kurs › Moduł 3 › Lekcja 34

Udało się czy nie?

⏱ ok. 10 min czytania· moduł 3: Pisanie skryptów

Po tej lekcji: kursant czyta kody wyjścia komend przez $?, kończy swoje skrypty własnym kodem przez exit i łączy komendy warunkowo przez && i ||, żeby jedna awaria nie ciągnęła za sobą następnych.

Wstęp

Prawdziwa historia, która zdarza się co roku w setkach firm. Skrypt do sprzątania starych logów:

cd /var/log/aplikacja/stare
rm -rf *

Działał latami. Aż ktoś zmienił nazwę folderu. cd się nie udał, wypisał błąd, którego nikt nie czytał, bo skrypt chodził w nocy z crona. A rm -rf * wykonało się dalej. W folderze, w którym skrypt akurat był. Na przykład w domowym. Albo w głównym.

Nie odpalaj tego, nawet na maszynie wirtualnej. Pokażę to samo bezpiecznie, z ls zamiast rm:

cd /tmp/stare_logi ; ls
bash: cd: /tmp/stare_logi: No such file or directory
a.txt
b.txt
notatki.md

cd się nie udał, a ls i tak pokazał pliki. Ale nie z /tmp/stare_logi, tylko z miejsca, w którym stałem. Z rm zamiast ls właśnie straciłbym te pliki. Dziś nauczysz się jednego znaczka, który by temu zapobiegł. Dwa znaki. Ampersand, ampersand.

Kod wyjścia, czyli kciuk w górę albo w dół

Każda komenda, kiedy kończy pracę, pokazuje kciuk. W górę: udało się. W dół: coś poszło nie tak. Ten kciuk to kod wyjścia, po angielsku exit status albo exit code. To liczba od 0 do 255.

I tu jest pułapka dla wszystkich, którzy kiedyś programowali albo chodzili do szkoły: zero oznacza sukces. Wszystko inne to porażka. Dlaczego odwrotnie niż w szkole? Bo sukces jest jeden, a porażek wiele rodzajów. Zero mówi „wszystko gra”. Jedynka, dwójka, sto dwadzieścia siedem mówią, co dokładnie nie gra. Jak lekarz: „zdrowy” jest jedno, a choroby mają numery w całej księdze.

Kod ostatniej komendy Bash trzyma w specjalnym pudełku $?:

ls /etc/hostname
echo "kod: $?"
ls /nie/ma/takiego
echo "kod: $?"
/etc/hostname
kod: 0
ls: cannot access '/nie/ma/takiego': No such file or directory
kod: 2

Co mówią numerki

Niektóre kody ustala sam Bash i są takie same wszędzie:

nieistniejacakomenda
echo "kod: $?"
./raport.txt
echo "kod: $?"
bash: nieistniejacakomenda: command not found
kod: 127
bash: ./raport.txt: Permission denied
kod: 126

127 to „nie znalazłem takiej komendy”. 126 to „znalazłem, ale nie mogę uruchomić”, na przykład brak prawa x. Jeśli komendę zabił sygnał, kod to 128 plus numer sygnału. Pamiętasz sygnały? Ctrl+C to SIGINT, numer 2, więc kod 130. Grzeczne kill, czyli SIGTERM, numer 15, daje 143. Kiedy widzisz w logu 137, wiesz, że ktoś komuś wyłączył prąd, czyli SIGKILL, dziewiątka. Często robi to OOM killer, kiedy brakuje pamięci.

Resztę ustala każdy program po swojemu i trzeba zajrzeć do jego man, do sekcji EXIT STATUS. Najpiękniejszy przykład to grep:

grep -q root /etc/passwd
echo "znalazł: $?"
grep -q zzzz /etc/passwd
echo "nie znalazł: $?"
grep -q root /nie/ma
echo "błąd: $?"
znalazł: 0
nie znalazł: 1
grep: /nie/ma: No such file or directory
błąd: 2

-q to tryb cichy: grep nic nie wypisuje, tylko pokazuje kciuk. Zero: znalazłem. Jeden: przeszukałem i nie ma. Dwa: nie mogłem nawet szukać. To trzy różne sytuacje i dobry skrypt umie je rozróżnić.

$? jest jak złota rybka

false
echo "pierwsze: $?"
echo "drugie: $?"
pierwsze: 1
drugie: 0

$? pamięta tylko ostatnią komendę. Pierwsze echo pokazało kod false. Ale samo echo też jest komendą, udało mu się, więc drugie echo widzi już jego zero. Pamięć złotej rybki. Jeśli kod będzie Ci potrzebny później, przepisz go od razu do zwykłego pudełka: kod=$?.

A skoro o false mowa: true i false to komendy, które nic nie robią, tylko pokazują kciuk. true zawsze zero, false zawsze jeden. Najbardziej leniwi pracownicy w całym systemie, a i tak się przydają: do testów, do pętli bez końca, do miejsc, gdzie składnia wymaga komendy, a Ty nie chcesz nic robić.

exit, czyli kciuk Twojego skryptu

Twój skrypt też jest komendą, więc też pokazuje kciuk. Domyślnie pokazuje kciuk ostatniej komendy, którą wykonał. Ale możesz zdecydować sam:

exit 0
exit 3

exit natychmiast kończy skrypt i zwraca podany kod. Wszystko, co jest niżej, się nie wykona. To jak wyjście ewakuacyjne: kiedy przez nie wyjdziesz, reszta zwiedzania odpada.

Konwencja: 0 sukces, 1 ogólny błąd, 2 złe użycie, na przykład brak argumentu. Resztę możesz ustalić sam i opisać w komentarzu. Tylko nie wchodź na 126 i wyżej, bo to pole Basha i sygnałów.

A co, jeśli przesadzisz z liczbą?

bash -c 'exit 256'; echo "$?"
bash -c 'exit 300'; echo "$?"
0
44

Kod ma osiem bitów, więc mieści liczby od 0 do 255. Większe się „przekręcają” jak licznik kilometrów w starym maluchu: 256 to znowu 0, a 300 to 44. Czyli exit 256 to sukces. Najdziwniejszy sukces w historii.

&& i ||, czyli „jeśli się udało” i „jeśli nie”

Teraz obiecany znaczek. Między dwiema komendami możesz postawić trzy różne łączniki:

; — „potem”. Druga komenda wykona się zawsze, bez względu na kciuk pierwszej.

&& — „i jeśli się udało, to”. Druga komenda wykona się tylko wtedy, gdy pierwsza pokazała kciuk w górę.

|| — „a jeśli nie, to”. Druga komenda wykona się tylko wtedy, gdy pierwsza pokazała kciuk w dół.

Jak sztafeta. && to zawodnik, który rusza tylko wtedy, gdy kolega dobiegł z pałeczką. || to rezerwowy, który wbiega na boisko tylko wtedy, gdy kolega się przewrócił.

mkdir /tmp/demo34 && echo "Założono folder"
mkdir /tmp/demo34 && echo "Założono folder"
mkdir /tmp/demo34 || echo "Nie udało się, ale żyjemy dalej"
Założono folder
mkdir: cannot create directory '/tmp/demo34': File exists
mkdir: cannot create directory '/tmp/demo34': File exists
Nie udało się, ale żyjemy dalej

Za pierwszym razem folder powstał, więc echo się wykonało. Za drugim mkdir dostał kciuk w dół, więc echo po && zostało pominięte. Za trzecim || wpuściło rezerwowego.

I teraz nasz skrypt z haka, naprawiony:

cd /tmp/stare_logi && ls
bash: cd: /tmp/stare_logi: No such file or directory

Nic więcej. ls, a w prawdziwym życiu rm, nie wystartował. Dwa znaki uratowały Ci dzień. W skryptach jeszcze częściej pisze się cd /folder || exit, czyli „albo wejdź, albo kończ”. To właśnie ten pas bezpieczeństwa, który obiecałem wyjaśnić w lekcji 31. ShellCheck sam podpowiada go przy każdym gołym cd w skrypcie (SC2164).

Jest też wykrzyknik, który odwraca kciuk: ! grep -q zzzz /etc/passwd da zero, bo „nie znalazłem” jest tu sukcesem.

Porządny skrypt mówi, że coś jest nie tak

Pamiętasz kto_to.sh z poprzedniej lekcji, który bez argumentu udawał, że wszystko gra? Poprawiamy:

#!/bin/bash
# kto_to.sh - pokazuje informacje o koncie podanym jako argument
getent passwd "$1" > /dev/null || { echo "Nie znam konta: '$1'" >&2; exit 2; }
echo "=== Konto: $1 ==="
getent passwd "$1"
id "$1"
./kto_to.sh nikt; echo "kod=$?"
./kto_to.sh root; echo "kod=$?"
Nie znam konta: 'nikt'
kod=2
=== Konto: root ===
root:x:0:0:root:/root:/bin/bash
uid=0(root) gid=0(root) groups=0(root)
kod=0

Rozbierzmy tę jedną gęstą linijkę. getent passwd "$1" > /dev/null sprawdza, czy konto istnieje, a wynik wyrzuca do czarnej dziury, bo interesuje nas tylko kciuk. Jeśli kciuk jest w dół, || wpuszcza to, co jest w klamrach. Klamry to spinacz: zbierają dwie komendy w jedną paczkę, żeby obie wykonały się jako „rezerwowy”. Uwaga na składnię: spacja po { i średnik przed } są obowiązkowe. W środku komunikat leci na drugi głośnik, czyli >&2, bo to błąd, a nie wynik. A potem exit 2.

Pusty argument też się tu łapie, bo konta o pustej nazwie nie ma. Teraz ten skrypt można wpiąć w większą całość i ta całość będzie wiedziała, co się stało. Bez czytania komunikatów.

Jak to działa pod spodem

Kiedy proces kończy pracę, wywołuje funkcję systemową exit z liczbą. Jądro zapisuje tę liczbę i trzyma ją, aż rodzic ją odbierze. Rodzic robi to wywołaniem wait. Pamiętasz zombie z lekcji o rodzajach procesów, czyli rachunek gościa, który już wyszedł, a kelner go nie zamknął? To jest dokładnie ta sytuacja: proces się skończył, kod wyjścia czeka na odbiór, a rodzic go jeszcze nie odebrał. Bash odbiera kod od razu i wkłada go do $?.

W tym samym miejscu jądro zapisuje też, czy proces zakończył się sam, czy zabił go sygnał. Bash zamienia to drugie na 128 plus numer sygnału, żebyś mógł to rozpoznać jedną liczbą.

&& i || to nie są osobne programy. To gramatyka Basha. Bash uruchamia pierwszą komendę, czeka na jej kciuk, i dopiero wtedy decyduje, czy uruchomić drugą. Dlatego cały łańcuch zwraca kod ostatniej komendy, która naprawdę się wykonała.

Ściąga flag

Komenda / zapisFlaga / wartośćCo robi
$?—kod ostatniej komendy (tylko ostatniej!)
exitNkończy skrypt z kodem N (0–255)
exitbez liczbykończy z kodem ostatniej komendy
a ; b—b zawsze
a && b—b tylko, gdy a się udało (kod 0)
a || b—b tylko, gdy a się nie udało
! a—odwraca kciuk
{ a; b; }—grupuje komendy (spacje i średnik obowiązkowe)
true / false—zawsze 0 / zawsze 1
grep-qnic nie wypisuje, tylko kod: 0 znalazł, 1 nie, 2 błąd
kod126 / 127nie można uruchomić / nie ma komendy
kod128+Nzabity sygnałem N (130 = Ctrl+C, 137 = SIGKILL, 143 = SIGTERM)

⚠ Typowy błąd

&& i || w jednej linijce wyglądają jak „jeśli… to… w przeciwnym razie…”:

grep -q root /etc/passwd && echo "Jest root" || echo "Nie ma roota"

I zwykle tak działają. Ale nie zawsze. To nie jest „jeśli A, to B, inaczej C”. To jest „A, jeśli się udało, to B, a jeśli cokolwiek przed || się nie udało, to C”.

true && false || echo "Ojej"
Ojej

Pierwsza komenda się udała, więc Bash wykonał drugą. Druga się nie udała, więc || wpuściło rezerwowego. W prawdziwym życiu wygląda to tak: „Skopiuj backup i napisz „OK”, a jak się nie uda, wyślij alarm”. Kopia się udaje, echo OK się nie udaje, bo na przykład dysk z logami jest pełny, i dostajesz alarm o nieudanej kopii, która była udana. A raz na jakiś czas odwrotnie, więc nikt już alarmom nie wierzy.

Na prawdziwe „jeśli… to… w przeciwnym razie…” jest if, którego poznasz w następnej lekcji. && i || zostaw do prostych sytuacji: „zrób to, a jak się uda, to tamto” albo „zrób to, a jak nie, to kończ”.

✎ Zadanie

Napisz skrypt czy_jest.sh, który dostaje nazwę konta jako argument. Jeśli konto istnieje (sprawdź przez getent passwd), wypisuje „Konto X istnieje.” i kończy się kodem 0. Jeśli nie istnieje, wypisuje „Konta X nie ma.” na stderr i kończy się kodem 3. Użyj && albo || i exit, bez if. Sprawdź kod przez echo $? dla root i dla wymyślonego konta. A na koniec użyj swojego skryptu w łańcuchu: ./czy_jest.sh root && echo "Można działać".

Pokaż rozwiązanie
#!/bin/bash
# czy_jest.sh - sprawdza, czy konto istnieje; kod 0 = jest, 3 = nie ma
getent passwd "$1" > /dev/null && echo "Konto $1 istnieje." && exit 0
echo "Konta $1 nie ma." >&2
exit 3
./czy_jest.sh root; echo "kod=$?"
./czy_jest.sh nikt; echo "kod=$?"
./czy_jest.sh root && echo "Można działać"
Konto root istnieje.
kod=0
Konta nikt nie ma.
kod=3
Konto root istnieje.
Można działać

Jeśli konto jest, łańcuch && dobiega do exit 0 i skrypt kończy się w pierwszej linijce. Jeśli nie ma, łańcuch się urywa na getent i Bash idzie do następnej linijki: komunikat na stderr i exit 3. Działa, ale przyznaj: żeby to przeczytać, trzeba się chwilę zatrzymać. Za dziesięć minut napiszemy to samo tak, że zrozumie nawet Twój szef. Nawet ten, który drukuje maile.

Co dalej

&& i || są jak szybka odpowiedź „tak albo nie”. W następnej lekcji wchodzi bramkarz: if, elif, else, test plików, test liczb, test tekstów, i wszystkie te dziwne nawiasy kwadratowe, pojedyncze i podwójne.