Shell Script, oh mijn god, wat is hier de syntax

Ingediend door Dirk Hornstra op 24-apr-2023 21:18

Bij de service-afdeling van TRES hebben we een dashboard, wat op een Raspberry Pi draait en opgestart wordt met een stukje Shell Script. Het ging daar om het starten van de dashboard-applicatie die op een bepaalde poort de HTML-output aanlevert en het opstarten van de Chromium-browser die dan in full-screen (een soort kiosk-mode) het dashboard toont. In het verleden met mijn collega Dirk-Jan mee bezig geweest en als het dan draait: niets meer aan doen!

Maar goed.. er draaien meer scripts. Bijvoorbeeld om data van de webservers te backuppen naar een eigen disk en daar gebruik je (natuurlijk) RSYNC voor. Die tool is dé manier om data te vergelijken en de boel weer in sync brengen (waarbij niet het hele bestand over de lijn gestuurd hoeft te worden, alleen de verschillen). Alleen, we liepen tegen het feit aan dat er zo nu en dan zo'n RSYNC actie onderbroken werd (de andere kant verbrak de verbinding of aan onze kant werd iets afgebroken), na 15 minuten werd de boel opnieuw opgestart. En ging het volgens mij nog wel eens fout. Dan had je de "retry-actie" al gehad en kon hopelijk de volgende avond het proces wel voltooid worden. Maar betekent dat dan ook dat "de mappen die na deze map kwam" niet lokaal gebackupt werden? Daar lijkt het wel op.

Toen werd bij mij aangeklopt of ik hier wat in zou kunnen betekenen. Natuurlijk wil ik hier een blik op werpen, maar zoals ik al aangaf, ik ben hier geen expert in. Zoals je op de wikipedia-pagina kunt lezen (link) is dit de Linux/Unix variant van batch-bestanden onder Windows/MS-DOS. Daar heb ik vroeger nog wel wat in om zitten pielen. Maar goed, ik heb het idee dat deze shell-scripts meer kunnen.

Mouwen opstropen, nieuw .sh-bestand aanmaken en kijken wat het nu doet en hoe ik de boel kan aanpassen. Dan moet je wel even wennen aan de syntax, want die is anders dan ik gewend ben in C#, VB en Delphi/Pascal. Maar goed, doe je wat fout, dan krijg je wel een foutmelding met regelnummer te zien en kun je met trial-en-error uiteindelijk je fouten wel wegwerken.

Mocht jij ook eens met zo'n klusje aan de gang moeten gaan, hier een paar tips waar je hopelijk je voordeel mee kunt doen!

Tip 0: let op met de namen van je variabelen / je kunt parameters aan functies meegeven / een functie kan alleen maar een getal terug geven!

Ik weet niet exact hoe het zit met de "scope" van variabelen. Gebruik je ergens en variabele $i voor een teller, wordt daarbinnen een andere functie aangeroepen en die heeft ook een variabele $i, ik had het idee dat daar wel eens wat fout ging. Dus geeft elke variabele een unieke naam, dan weet je zeker dat je in ieder geval daar niet mee tegen een muur aan gaat lopen!

En in een functie kun je met $1 de waarde van de 1e parameter opvragen, $2 voor de 2e, etc.

En je gebruikt "globale variabelen" om je resultaat van een functie in te stellen. Want een functie kan alleen een getal terug geven...

Tip 1: splits je code over bestanden

Het script wat draaide was 1 groot .sh-bestand. De functies moeten "in volgorde staan". Dus als functie A een aanroep doet naar functie B, dan moet functie B voor functie A staan, anders is de functie niet bekend. Dat is bij elk script zo (en ook wel logisch), maar mocht dit helemaal nieuw voor je zijn, de eerste tip :) Maar goed, het ging mij erom dat al die code in 1 bestand stond. Dat maakt het wel heel onoverzichtelijk, want bepaalde functies om wat aan te passen staan helemaal bovenaan, de code waarmee de "applicatie" start en de checks in zitten, dat zit helemaal onderaan je bestand "page down, page down".

Dat is gelukkig snel op te lossen. Die "losse functies" zet je in een eigen functies.sh bestand. En in jouw syncjob.sh bestand zet je na de #!/bin/sh regel de tekst source ./functies.sh

Tip 2: vergeet niet (de bestanden in) je "hoofdfolder"

In mijn aangepaste script ga ik al mijn "subfolders" verwerken. Maar er kunnen natuurlijk ook nog bestanden staan in de "hoofdmap" waar die subfolders in staan. Die bestanden verwerk ik dus eerst en dat zie je in onderstaand stukje code. Dat komt dus door die -f
Je ziet daar ook nog een paar ander zaken die ik in HOOFDLETTERS op 0 instel. Dat is namelijk voor je eindresultaat. Als je 1 RSYNC actie doet op je map en submappen, dan heb je aan het einde een totaal wat je door kunt geven/kunt verwerken.
Maar als je "per map" en RSYNC doet, dan krijg je ook per map een samenvatting. Die waardes zul je zelf moeten gaan optellen om een eindresultaat op te bouwen. Kom ik later op terug!



function process_backup_per_folder {

    # process the root-folder
    rootCommand="$RSYNC_COMMAND $RSYNC_ARGUMENTS $EXCLUDEARG -f\"- */\" -f\"+ *\" $USERNAME@$HOSTNAME:$SOURCEFOLDER/* $BASEDATADIR$DESTINATION/$SUPPLIER/$INPUTHOST/"
    OUTPUT=$(eval $rootCommand 2>&1)
    EXITCODE=$?
    if [ $EXITCODE -eq 0 ] || [ $EXITCODE -eq 24 ]; then {
        TOTALCOUNT=0
        CREATECOUNT=0
        DELETECOUNT=0
        REGULARCOUNT=0
        LISTGENERATIONTIME=0
        TOTALSENT=0
        TOTALRECEIVED=0
        RSYNCSTATS=$(printf '%s\n' "$OUTPUT" | grep -e 'Number of files:' -e 'Files Scanned at Source:' -A 14)
        parse_summary "$RSYNCSTATS"
        # root succesfull, continue with subfolders
        rsync_subfolders
    }; fi
}

Tip 3: hoe krijg je een mooie lijstje van folders?

Je kunt met een RSYNC --list-only een lijst met mappen (en bestanden) opvragen bij je remote server. Maar... net als je in Linux een ls-commando uitvoert (en ook de Windows-developer die WSL op zijn/haar machine heeft en in bash een ls uitvoert) dan krijg je dus een overzicht van rechten (en wat iets is), en nog meer informatie, net zoals je in MS-DOS een dir-commando uitvoert. Je hebt dan eigenlijk een stuk tekst uit een tekstbestand en je moet zelf de regels gaan splitten op basis van line-break (ascii-waarde 10, in functie rsync_subfolders) en de regel splitsen op de spatie (ascii-waarde 32, in functie process_line_with_subfoldername) en zo kom je uiteindelijk bij de echte naam van de map. Dat doet het onderstaande script:

 


function process_line_with_subfoldername {
    processLine=$1
    lngSubFolder=${#processLine}
    z=0
    posSubFolder=0
    previousVal=0
    lineVal=""
    finishedLine=0
    firstChar=${processLine:0:1}
    # only proces folders
    if [ "$firstChar" != "d" ];
    then
    {
        finishedLine=1
    } fi   
    while [ $finishedLine -eq 0 ]; do
    {
        i=${processLine:$z:1}
        asciiVal=$(printf "%d" "'$i")           
        if [ $((asciiVal)) -eq 32 ];
        then
        {
            if [ $((asciiVal)) -ne $((previousVal)) ];
            then
            {
                posSubFolder=$((posSubFolder+1))
            } fi
        } fi
        if [ $((posSubFolder)) -ge 4 ];
        then
        {
            if [ ${#lineVal} -eq 0 ];
            then
            {
                if [ $((asciiVal)) -ne 32 ];
                then
                {
                    lineVal="$lineVal$i"
                } fi
            }
            else
            {
                lineVal="$lineVal$i"
            } fi
        } fi
        previousVal=$asciiVal
        z=$((z+1))
        if [[ $((z)) -eq $((lngSubFolder)) ]] || [[ $((z)) -gt $((lngSubFolder)) ]];
        then
        {
            finishedLine=1
        } fi       
    }
    done
    if [[ "$lineVal" == "." ]] || [[ "$lineVal" == ".." ]];
    then
    {
        lineVal=""
    } fi
    LINE=$lineVal
}


function rsync_subfolders {
    fullExternalPath="$USERNAME@$HOSTNAME:$SOURCEFOLDER/"
    localExitCode=0
    subFolders=$(eval "rsync --list-only $fullExternalPath")
    lng=${#subFolders}
    lineIterator=0
    workString=""
    finishedFile=0
    while [ $finishedFile -eq 0 ]; do
    {
        i=${subFolders:$k:1}
        asciiVal=$(printf "%d" "'$i")
        if [ $((asciiVal)) -eq 10 ];
        then
        {
|            k=$((k+1))            
            if [ ${#workString} -eq 0 ];
            then
            {
                continue
            } fi
            process_line_with_subfoldername "$workString"
            workString=""
            continue
        }; fi
        workString=$workString$i
        k=$((k+1))
        if [[ $((k)) -eq $((lng)) ]] || [[ $((k)) -gt $((lng)) ]];
        then
        {
            finishedFile=1
            process_line_with_subfoldername "$workString"
            workString=""
        } fi
        if [ "$LINE" != "" ];
        then
        {
            subfolderCommand="$RSYNC_COMMAND $RSYNC_ARGUMENTS $EXCLUDEARG $USERNAME@$HOSTNAME:$SOURCEFOLDER/$LINE $BASEDATADIR$DESTINATION/$SUPPLIER/$INPUTHOST/"
            echo "$USERNAME@$HOSTNAME:$SOURCEFOLDER/$LINE"
            OUTPUT=$(eval $subfolderCommand 2>&1)
            tempLocalExitCode=$?
            if [ $tempLocalExitCode -ne 0 ] && [ $tempLocalExitCode -ne 24 ];
            then
            {
                localExitCode=$tempLocalExitCode
                inputHostWithSourceFolder="$INPUTHOST.$SOURCEFOLDER"
                send_zabbix "backup.rsync.failedfolder[$inputHostWithSourceFolder]" "$LINE"
            }
            else
            {
                RSYNCSTATS=$(printf '%s\n' "$OUTPUT" | grep -e 'Number of files:' -e 'Files Scanned at Source:' -A 14)
                parse_summary "$RSYNCSTATS"
            } fi
            LINE=""
        } fi
    }
    done
    EXITCODE=$localExitCode
}

Tip 4: zie ik daar iets van Zabbix?

Goed gezien, hier boven zit een call naar Zabbix. We registreren namelijk de start, de finish en het eindresultaat. Maar ook "de map waar het mis gaat". Want zo kun je in Zabbix monitoren of een bepaalde map vaak problemen geeft en zo ja: dan kun je er wat aan doen!

Tip 5: je krijgt per map een samenvatting. Hoe haal ik de waarden uit de tekst en tel ze op bij mijn andere waardes?

Dat is een "vieze oplossing" die ik gemaakt heb. Ik match op de teksten en ga zo de hele lijst met resultaten door. Zou een nieuwe versie van rsync een andere volgorde terug geven, dan loopt mijn resultaat in de soep. Het zij zo, dan passen we dat wel weer aan, ik moet door. Zo kun je in een Shell Script alleen met integers rekenen (gehele getallen). Maar bepaalde rsync-resultaten, dat zijn cijfers met komma's, dus bijvoorbeeld 0.003. En de volgende waarde is 0.022 en die wil je "gewoon" op kunnen tellen tot 0.025. Ook dat kan, maar dat was dus ook weer even zoeken (dat doe je met AWK en kun je in het onderstaande script terug vinden):

 

function parse_summary {
    summary=$1
    #echo $summary
   finishSummary=0
    lngSummary=${#summary}
    m=0
    label=""
    value=""
    itemIsLabel=1
    skipValue=0
    while [ $finishSummary -eq 0 ]; do
    {
        i=${summary:$m:1}
        asciiVal=$(printf "%d" "'$i")                   
 
        if [ $asciiVal == 58 ];
        then
        {
            itemIsLabel=0
            skipValue=0
        }
        elif [ $asciiVal == 10 ];
        then
        {
            itemIsLabel=1
            #echo "$label = $value"
            if [ "$label" == "Number of files" ];
            then
            {
                TOTALCOUNT=$((TOTALCOUNT+$value))
            }
            elif [ "$label" == "Number of created files" ];
            then
            {
                CREATECOUNT=$((CREATECOUNT+$value))
            }
            elif [ "$label" == "Number of deleted files" ];
            then
            {
                DELETECOUNT=$((DELETECOUNT+$value))
            }           
            elif [ "$label" == "Number of regular files transferred" ];
            then
            {
                REGULARCOUNT=$((REGULARCOUNT+$value))
            }            
            elif [ "$label" == "Total file size" ];
            then
            {
                dummy="OK"
            }           
            elif [ "$label" == "Total transferred file size" ];
            then
            {
                dummy="OK"
            }           
            elif [ "$label" == "Literal data" ];
            then
            {
                dummy="OK"
            }           
            elif [ "$label" == "Matched data" ];
            then
            {
                dummy="OK"
            }           
            elif [ "$label" == "File list size" ];
            then
            {
                dummy="OK"
            }           
            elif [ "$label" == "File list generation time" ];
            then
            {
                statement="awk \"BEGIN {print ${LISTGENERATIONTIME}+$value}\""
                addedGenerationTime=$(eval $statement)
                LISTGENERATIONTIME=$addedGenerationTime               
            }           
            elif [ "$label" == "File list transfer time" ];
            then
            {
                dummy="OK"
            }           
            elif [ "$label" == "Total bytes sent" ];
            then
            {
                TOTALSENT=$((TOTALSENT+$value))               
            }           
            elif [ "$label" == "Total bytes received" ];
            then
            {
                TOTALRECEIVED=$((TOTALRECEIVED+$value))
            } fi
 
            label=""
            value=""
        }
        else
        {
            if [ $itemIsLabel -eq 1 ];
            then
            {
                label="$label$i"
            }
            else
            {
                if [ $asciiVal -eq 32 ] && [ ${#value} -gt 0 ];
                then               
                {
                    skipValue=1
                } fi
                if [ $asciiVal -ne 32 ] && [ $skipValue -eq 0 ];
                then
                {
                    value="$value$i"
                } fi
            } fi
        } fi
        m=$((m+1))
        if [ $((m)) -ge $((lngSummary)) ];
        then
        {
            finishSummary=1
        } fi
    }
    done
}

 

Conclusie:

Ik ben verwend met mijn "normale" programmeerwerkzaamheden. Draai je project in Visual Studio, zet breakpoints, alles wat je wilt is in theorie mogelijk en je hoeft er veel minder moeite voor te doen dan wat ik hier aan vertimmerd heb. Aan de andere kant, is het wel een leuke uitdaging, leer je weer een nieuwe syntax van een "andere taal" en hopelijk werkt het straks allemaal zoals we willen!