Poradnik Bash Bushidō cz. V – jak pisać lepsze skrypty w Bashu
Techniki stosowane w testowaniu oprogramowania można podzielić na wiele kategorii. Jedną z nich jest podział testowania ze względu na to, czy będziemy działać na uruchomionym programie/aplikacji/skrypcie, czy też nie. Jak łatwo się domyślić, testowanie statyczne zawiera w sobie techniki, które działają na nieuruchomionym programie oraz/lub jego artefaktach. Z kolei testowanie dynamiczne wymaga działania programu lub jego fragmentu (np. modułu). Dziś omówimy przydatne narzędzie ShellCheck do statystycznej analizy skryptów bashowych, które pomaga pisać lepsze skrypty.
Czym są statyczne techniki testowania?
Techniki stosowane w testowaniu oprogramowania można podzielić na wiele kategorii. Jedną z nich jest podział testowania ze względu na to, czy będziemy działać na uruchomionym programie/aplikacji/skrypcie, czy też nie. Jak łatwo się domyślić, testowanie statyczne zawiera w sobie techniki, które działają na nieuruchomionym programie oraz/lub jego artefaktach. Z kolei testowanie dynamiczne wymaga działania programu lub jego fragmentu (np. modułu).
Jedną z najczęściej wykorzystywanych technik statycznych jest statyczna analiza kodu. W rozwiązaniach DevOpsowych (CI/CD) często uruchamia się tzw. lintery kodu ze szczególnym lub wyłącznym uwzględnieniem nowego kodu (plików, w których pojawia się świeży kod). Dzięki stosunkowo prostej automatyzacji tego procesu, z reguły sprowadzającej się do wywołania dosłownie jednej komendy z odpowiednimi parametrami lub napisania własnego skryptu będącego tzw. glue code, możemy uzyskać, szczególnie zaraz po wprowadzeniu narzędzia, znaczny wzrost jakości kodu (i bat na programistów), a to wszystko przy stosunkowo niewielkim nakładzie pracy.
Czym są lintery?
Słowo linter pochodzi od Unixowego (a jakże :) ) narzędzia Lint. Służyło ono do wyszukania potencjalnych błędów w kodzie źródłowym napisanym w języku C. Więcej na jego temat można znaleźć w manualu Unixa w wersji 7.
Obecnie większość IDE (zintegrowanych środowisk programistycznych), kompilatorów i narzędzi do analizy statystycznej kodu posiada w sobie bardziej lub mniej widoczny linter. Część z linterów (np. pylint) posiada nawet własną punktację kodu ze względu na jego jakość. Punktacja taka może być zapisywana w celu śledzenia trendów.
Jak wybrać akceptowalną punktację lintera?
Zespoły programistyczne często posiadają wskazane minimalne akceptowalne pokrycie kodu (jest to z reguły zwykłe pokrycie instrukcji) testami. Za dobrą praktykę uznaję się ok. 80%+. Należy tutaj jednak podkreślić, iż szereg czynników wpływa na to, jaką liczbę uznamy za „dobrą”, a znaczące pokrycie kodu (nawet 100%) nie jest gwarantem posiadania dobrego zestawu przypadków testów.
Identyczna sytuacja występuje z wynikami naszego lintera. Może to być dobre wskazanie, iż nowy kod nie jest napisany zgodnie z naszymi standardami. Jednak zespół deweloperski z reguły zostawia sobie pewne pole dla kodu, który jest poprawny, jednak niekoniecznie maksymalnie oceniany przez narzędzie.
Na samym końcu przytoczę jedną z 7 zasad testowania oprogramowania podawaną przez ISTQB.
Testowanie zależy od kontekstu
Jakie są zalety linterów?
- Wykrywają kod, który nie podąża za ustalonymi w zespole praktykami zapisu kodu (na przykład zmodyfikowany PEP8);
- szybko i skutecznie wykrywają szereg błędów. Przykładowo: Zła składnia, nieużycie zmiennej, użycie złej nazwy, niepoprawne wywołanie metody lub funkcji;
- niski koszt wprowadzenia i pielęgnacji;
- wysoki wpływ na jakość kodu, szczególnie w pierwszych fazach (iteracjach) i w projektach, w których występuje duża rotacja programistów;
- z reguły trywialna integracja do rozwiązań CI/CD – z których część dostarcza dedykowane dla nich wtyczki/paczki.
OK, ale co to ma wspólnego z ShellCheck?
ShellCheck jest narzędziem do statystycznej analizy skryptów bashowych. Posiada on wiele cech typowego lintera. Wykorzystuje też standardowe kody wyjścia do informowania, czy zadany skrypt zawiera błędy lub nie podąża za najlepszymi praktykami.
https://github.com/koalaman/shellcheck/wiki
Na terminalach wspierających kolory (czyli tych, które powstały już po dinozaurach), informacja dotycząca typu błędów także jest podkreślana kolorem.
Sam program grupuje znalezione usterki ze względu na ich wpływ, dotkliwość – severity:
1. Error – błędy w skrypcie – z reguły uniemożliwiają poprawne wykonanie skryptu.
2. Warning – ostrzeżenia – z reguły złe (lecz akceptowalne składniowo) użycie.
3. Info – informacyjne – często doprowadzenie dbałości o kod do ekstremum.
Instalacja ShellCheck na systemach z rodziny Enterprise Linux w wersji 7.
Instalacja ShellCheck w EuroLinuxie w wersji 7 jest dziecinnie prosta. Wymagane jest repozytorium EPEL, w którym znajduje się ShellCheck.
sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm sudo yum install -y ShellCheck
Wersja webowa
Jeśli jednak nie chcemy lub nie możemy zainstalować ShellCheck, lokalnie istnieje możliwość skorzystania z wersji przeglądarkowej. Oczywiście należy mieć na uwadze fakt, iż wrzucanie gdziekolwiek skryptów zawierających sekrety (tokeny, hasła itp.) jest po prostu nieodpowiedzialne.
Nie mniej polecam korzystanie z tego narzędzia (skrypty można zanonimizować, co oczywiste należy to zrobić to przed wrzuceniem do edytora).
Samo narzędzie znajduje się pod tym linkiem.
Jego niewątpliwą zaletą jest fakt, iż przy każdym błędzie istnieje od razu odnośnik do strony wiki projektu, która zawiera przykładowy kod wraz z uzasadnieniem, dlaczego nie należy tak postępować.
Poniżej przykład ze wszystkimi typami defektów: info, warning i error.
Praktyczne użycie
Prawie rok temu napisałem artykuł, w którym użyliśmy skryptu bashowego do instalacji najnowszej wersji gita w systemie EuroLinux. Tym razem będziemy jednak szukać tego, co można w tym skrypcie poprawić.
W tym celu zapisałem podany skrypt jako install-git.sh
.
#!/bin/bash MY_LOGFILE="/var/log/git_installation.log" REQUIRED_COMMAND='yum tee sed wget' check_running_as_root(){ if [ "$EUID" -ne 0 ]; then echo "This script requires root privilages. Please run it as root or via sudo." exit 1 fi } check_required_commands(){ for my_command in $REQUIRED_COMMAND; do hash $my_command 2>/dev/null || { echo "Script requires $my_command command! Aborting."; exit 1; } done } rpm_mods(){ echo "Removing git rpm if installed, install necessary rpm packages." | tee $MY_LOGFILE yum remove -y git yum install -y curl-devel expat-devel openssl-devel zlib-devel gcc perl-ExtUtils-MakeMaker gettext } get_newest_stable_git_version(){ # NOTE: reading tags with regex is the simplest but definitely not the best way page="https://git-scm.com/downloads" tag_open="span class=\"version\"" tag_end="span" git_current_v=$(curl -s $page | sed -n "/<$tag_open>/,/<\/$tag_end>/p" | grep -Eo '[0-9\.]*') echo "Getting the newest git git-${git_current_v}" | tee $MY_LOGFILE echo "Getting git-${git_current_v} and unpacking it into /usr/src/" | tee $MY_LOGFILE wget https://www.kernel.org/pub/software/scm/git/git-${git_current_v}.tar.gz -O /tmp/git-latest.tar.gz tar xvzf /tmp/git-latest.tar.gz -C /usr/src/ cd /usr/src/git-${git_current_v}/ ./configure make make install } set -e # Script stops on first error check_running_as_root check_required_commands rpm_mods get_newest_stable_git_version
Po uruchomieniu ShellCheck dostaniemy:
[1] Alex@SpaceShip ~/D/a/E/shell_check ➞ shellcheck install-git.sh In install-git.sh line 13: hash $my_command 2>/dev/null || { echo "Script requires $my_command command! Aborting."; exit 1; } ^-- SC2086: Double quote to prevent globbing and word splitting. In install-git.sh line 32: wget https://www.kernel.org/pub/software/scm/git/git-${git_current_v}.tar.gz -O /tmp/git-latest.tar.gz ^-- SC2086: Double quote to prevent globbing and word splitting. In install-git.sh line 34: cd /usr/src/git-${git_current_v}/ ^-- SC2086: Double quote to prevent globbing and word splitting.
Oczywiście wszystkie błędy miały swój kolor – jednak został on stracony przy kopiowaniu tekstu.
ShellCheck pozwala na ustawienie kilku typów wyjścia, a raczej tego, w jaki sposób przedstawi wyniki swojego działania. Moim ulubionym formatem jest JSON. Pojawiał się on kilkukrotnie na naszym blogu choćby tutaj. Jest on czytelny i pozwala na dalszą manipulację przy pomocy innych narzędzi, np. Pythona czy narzędzia jq
.
Uruchommy więc shellcheck
z --format json
.
[1] Alex@SpaceShip ~/D/a/E/shell_check ➞ shellcheck --format=json install-git.sh [{"line":13,"column":14,"level":"info","code":2086,"message":"Double quote to prevent globbing and word splitting."},{"line":32,"column":58,"level":"info","code":2086,"message":"Double quote to prevent globbing and word splitting."},{"line":34,"column":21,"level":"info","code":2086,"message":"Double quote to prevent globbing and word splitting."}]
Warto zauważyć, że standardowo wyjście ShellCheck w formacie JSON nie posiada białych znaków innych niż te niezbędne do poprawnego zapisu danych.
W związku z tym proponuję użyć standardowego modułu Pythona json.tool
lub polecenia jq
.
Alex@SpaceShip ~/D/a/E/shell_check (master) ➞ shellcheck --format=json install-git.sh | python -m json.tool [ { "code": 2086, "column": 14, "level": "info", "line": 13, "message": "Double quote to prevent globbing and word splitting." }, { "code": 2086, "column": 58, "level": "info", "line": 32, "message": "Double quote to prevent globbing and word splitting." }, { "code": 2086, "column": 21, "level": "info", "line": 34, "message": "Double quote to prevent globbing and word splitting." } ]
Zauważmy, że wszystkie poziom (level) wszystkich komunikatów wydanych przez ShellCheck to „info”. Jeśli chcemy przefiltrować komunikaty o tym poziomie istotności, możemy użyć narzędzia jq
.
shellcheck --format=json install-git.sh | jq '.[] | select(.level != "info") '
Oczywiście po jego użyciu nie zobaczymy żadnego wyjścia. Przynajmniej tak długo, jak nie wprowadzimy defektu, który zostanie zakwalifikowany na inny poziom.
Użycie wraz z narzędziami do CI/CD
Jak już mówiłem, integracja narzędzi do statycznej analizy kodu jest z reguły banalnie prosta. Poniższy skrypt można wykorzystać do wykonania narzędzia ShellCheck na zadanym katalogu. Głęboko niewierzę w samodokumentujący się kod. Pozwolę wiec sobie na zwięzłe opisanie, co nasz skrypt robi.
1. check_required_commands – sprawdza dostępność wymaganych komend.
2. print_help – wypisuje sposób użycia skryptu i wychodzi z niezerowym wyjściem.
3. setup_script – przypisuje do zmiennej MY_DIR_TO_CHECK argument, który ustawia katalog, w którym będą poszukiwane skrypty.
4. find_files_to_check – wyszukuje skrypty w zadanym katalogu.
5. run_shellcheck – odpowiada za faktyczne wywołanie ShellCheck na znalezionych skryptach.
#!/bin/bash # Author: Aleksander Baranowski # License: MIT (https://choosealicense.com/licenses/mit/) # VARS MY_REQUIRED_COMMANDS="python shellcheck find" MY_DIR_TO_CHECK="" MY_FILES_TO_CHECK="" check_required_commands(){ for my_command in $MY_REQUIRED_COMMANDS; do hash "$my_command" 2>/dev/null || { echo "Script require $my_command command! Aborting."; exit 98; } # Using 98 as exit code done } print_help(){ echo "Usage: $0 DIRECTORY_TO_CHECK" exit 1 } setup_script(){ if [ $# -ne 1 ];then print_help fi MY_DIR_TO_CHECK="$1" [ -d "$MY_DIR_TO_CHECK" ] || { echo "$1 is not directory!"; exit 1; } } find_files_to_check(){ MY_FILES_TO_CHECK=$(find "$MY_DIR_TO_CHECK" -name '*.sh') echo "Found $( echo "$MY_FILES_TO_CHECK" | wc -w) *.sh file/s" } run_shellcheck(){ if [[ -z "$MY_FILES_TO_CHECK" ]]; then echo "No *.sh script found - skipping" exit 0 else # disable fail on first error; multiple scripts might not pass; # fail_flag is now responsible for exit code set +e fail_flag=0 for i in $MY_FILES_TO_CHECK; do output=$(shellcheck --format=json "$i") output_status=$? if [[ $output_status -ne 0 ]]; then fail_flag=$((fail_flag+1)) echo "==== Script $i REPORT: ====" echo "$output" | python -m json.tool fi done fi if [[ $fail_flag -ne 0 ]]; then echo "$fail_flag script/s failed :(" && exit 1 else echo "All script passed :)" fi } check_required_commands setup_script "$@" find_files_to_check run_shellcheck
W celu zmiany naszego skryptu tak, by sprawdzał tylko pliki, które zostały zmienione w najnowszym commicie systemu wersjonowania Git, możemy podmienić wyszukiwanie na odpowiednią komendę gita wraz z dopasowaniem do wyrażenia regularnego. Kod różni się także wywołaniem ShellCheck. Przed nazwą pliku dodajemy ścieżkę do repozytorium.
#!/bin/bash # Author: Aleksander Baranowski # License: MIT (https://choosealicense.com/licenses/mit/) # VARS MY_REQUIRED_COMMANDS="python shellcheck git" MY_DIR_TO_CHECK="" MY_FILES_TO_CHECK="" check_required_commands(){ for my_command in $MY_REQUIRED_COMMANDS; do hash "$my_command" 2>/dev/null || { echo "Script require $my_command command! Aborting."; exit 98; } # Using 98 as exit code done } print_help(){ echo "Usage: $0 DIRECTORY_TO_CHECK" exit 1 } setup_script(){ if [ $# -ne 1 ];then print_help fi MY_DIR_TO_CHECK="$1" [ -d "$MY_DIR_TO_CHECK" ] || { echo "$1 is not directory!"; exit 1; } } find_files_to_check(){ pushd "$MY_DIR_TO_CHECK" > /dev/null MY_FILES_TO_CHECK=$(git show --pretty="format:" --name-only | grep ".*\.sh$") echo "Found $( echo "$MY_FILES_TO_CHECK" | wc -w) *.sh file/s" popd > /dev/null } run_shellcheck(){ if [[ -z "$MY_FILES_TO_CHECK" ]]; then echo "No *.sh script found - skipping" exit 0 else # disable fail on first error; multiple scripts might not pass; # fail_flag is now responsible for exit code set +e fail_flag=0 for i in $MY_FILES_TO_CHECK; do output=$(shellcheck --format=json "${MY_DIR_TO_CHECK}/${i}") output_status=$? if [[ $output_status -ne 0 ]]; then fail_flag=$((fail_flag+1)) echo "==== Script $i REPORT: ====" echo "$output" | python -m json.tool fi done fi if [[ $fail_flag -ne 0 ]]; then echo "$fail_flag script/s failed :(" && exit 1 else echo "All script passed :)" fi } check_required_commands setup_script "$@" find_files_to_check run_shellcheck
Podsumowanie
Zapewnianie jakości jest nieustającym procesem. Dotyczy to także skryptów napisanych w powłoce Bash, które, mimo swojego wieku wciąż trzymają się naprawdę mocno.
ShellCheck może pomóc nam uniknąć wielu błędów, a także okazać się wyjątkowo pomocnym narzędziem w nauce dobrych praktyk.