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.

ShellCheck

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.

Autorzy

Artykuły na blogu są pisane przez osoby z zespołu EuroLinux. 80% treści zawdzięczamy naszym developerom, pozostałą część przygotowuje dział sprzedaży lub marketingu. Dokładamy starań, żeby treści były jak najlepsze merytorycznie i językowo, ale nie jesteśmy nieomylni. Jeśli zauważysz coś wartego poprawienia lub wyjaśnienia, będziemy wdzięczni za wiadomość.