Skip to main content

Module 2 · Shell & Bash

Bash Scripting

  • Lesson 5/15
  • 50 min
  • Difficulty: Intermediate

This lesson is not available in English yet: you are reading it in Italian.

Objective

Scrivere uno script bash che si ferma al primo guasto, quota le espansioni, ripulisce le risorse temporanee anche uscendo per errore, e distingue i guasti con codici di uscita diversi

Prerequisites

Prima di leggere: prevedi l'output

Una variabile contiene due parole.txt, ed è il nome di un file che esiste. Lo script esegue ls -l $file, senza virgolette. Scrivi che cosa stampa: una riga, due righe, un errore, o due errori? Poi scrivi perché. La risposta è il cardine di tutto quello che segue, e sbagliarla adesso vale più che leggerla fra dieci minuti.

Nella lezione precedente hai imparato a comporre comandi a mano. Uno script è la stessa cosa messa in un file, con una differenza che cambia tutto: a mano i dati li scrivi tu e sono quelli che intendi, in uno script i dati arrivano da fuori e sono quelli che capitano.

Il problema: uno script funziona finché i dati sono gentili

Il primo script che scrivi funziona. Il secondo funziona. Il decimo gira ogni notte su un server e una notte fa un danno, perché un nome di file conteneva uno spazio, una variabile era vuota, o un comando a metà catena è fallito e lo script ha proseguito come se nulla fosse. Nessuno di questi tre guasti è un errore di logica: sono tre comportamenti predefiniti della shell che sono comodi all'interattivo e pericolosi in automatico.

Quello che impari qui non è la sintassi, che è breve e si trova nel manuale. È la pratica deliberata di tre abilità separate: quotare le espansioni, far fallire presto, e ripulire anche quando lo script muore a metà. Ognuna delle tre si allena da sola, e insieme fanno la differenza fra uno script che gira e uno script di cui ti puoi fidare.

Prima abilità: le virgolette non sono stile

Quando la shell sostituisce una variabile, il risultato viene riletto: diviso sugli spazi, e i caratteri come l'asterisco espansi sui nomi di file. Le virgolette doppie fermano questa rilettura e lasciano il valore intero. Non è una questione di eleganza, è la differenza fra un argomento e due.

$ file="due parole.txt"
$ ls -l $file
ls: cannot access 'due': No such file or directory
ls: cannot access 'parole.txt': No such file or directory
$ ls -l "$file"
-rw-rw-r-- 1 federicocalo federicocalo 0 Oct 30 17:44 due parole.txt

Due errori, non uno, perché ls ha ricevuto due argomenti. La regola operativa è semplice e non ha quasi eccezioni: ogni espansione di variabile va fra virgolette doppie, sempre, e le eccezioni sono i casi in cui vuoi davvero la divisione, che sono rari e vanno commentati.

Seconda abilità: fallire presto

Per default la shell va avanti dopo un comando fallito e tratta una variabile inesistente come stringa vuota. Tre opzioni cambiano questo comportamento, e si scrivono insieme sulla prima riga utile: set -e ferma lo script al primo comando fallito, set -u lo ferma se usi una variabile non definita, set -o pipefail fa fallire una catena quando fallisce un pezzo intermedio, come hai visto nella lezione precedente.

#!/bin/bash
set -euo pipefail
echo "inizio"
echo "valore: $NON_DEFINITA"
echo "questa riga non si vede"
$ ./strict.sh; echo "exit=$?"
inizio
./strict.sh: riga 4: NON_DEFINITA: variabile non assegnata
exit=1

Lo script si ferma alla riga quattro e lascia uno. Senza quelle opzioni, lo stesso script stampa valore: seguito dal nulla, poi stampa la riga finale e lascia zero: un successo dichiarato su un dato mancante. Il messaggio compare nella lingua del mio sistema, quindi sulla tua macchina leggerai unbound variable se la lingua è l'inglese: il testo cambia, il comportamento no.

Terza abilità: ripulire anche morendo

Uno script che crea una cartella di lavoro deve cancellarla. Se la cancella all'ultima riga, non la cancella mai nei casi che contano, cioè quando lo script esce prima per un errore o perché qualcuno lo ha interrotto. Il meccanismo giusto è trap, che registra un comando da eseguire all'uscita, qualunque sia la strada dell'uscita.

Il resto della sintassi è poco: if [[ condizione ]] con le doppie parentesi quadre, che sono una costruzione di bash più sicura delle singole; for e while per i cicli; una funzione dichiarata con nome e parentesi, che riceve argomenti in $1, $2 e restituisce un numero con return. E exit con un numero scelto da te, perché chi chiama lo script ha diritto di sapere che genere di problema è.

Esempio svolto: uno script che si ripulisce da solo

Il problema: archiviare una cartella passata come argomento, usando uno spazio di lavoro temporaneo, e riportare quanti file sono finiti nell'archivio. Requisiti impliciti: se l'argomento manca lo script deve dirlo e non proseguire; se la cartella non esiste deve distinguere questo caso dal precedente; e lo spazio temporaneo non deve restare sul disco in nessun caso.

#!/bin/bash
set -euo pipefail

SORGENTE="${1:?serve la cartella da archiviare}"
DESTINAZIONE="${2:-/var/tmp}"

LAVORO="$(mktemp -d)"
trap 'rm -rf "$LAVORO"' EXIT

conta_file() {
  find "$1" -type f | wc -l
}

if [[ ! -d "$SORGENTE" ]]; then
  echo "errore: '$SORGENTE' non e' una cartella" >&2
  exit 2
fi

n="$(conta_file "$SORGENTE")"
archivio="$LAVORO/archivio.tar.gz"
tar -czf "$archivio" -C "$SORGENTE" .
dimensione="$(stat -c %s "$archivio")"

echo "file archiviati: $n"
echo "dimensione archivio: $dimensione byte"
echo "cartella di lavoro: $LAVORO"

Due costruzioni meritano attenzione. ${1:?messaggio} significa «usa il primo argomento, e se manca stampa questo messaggio ed esci»: è un controllo in tre caratteri che sostituisce un blocco if. ${2:-/var/tmp} significa «usa il secondo argomento, e se manca usa questo valore»: è un valore predefinito, non un controllo. La differenza fra i due punti interrogativo e il meno è la differenza fra «obbligatorio» e «facoltativo», dichiarata nel codice.

Il trap sta subito dopo la creazione della cartella e non altrove, perché nell'intervallo fra le due righe un'uscita lascerebbe la cartella orfana. L'alternativa che ho scartato è mettere rm -rf "$LAVORO" in fondo: funziona nel caso che va bene, che è l'unico caso in cui la pulizia non serve.

Le tre esecuzioni, con i loro codici di uscita misurati:

$ ./backup.sh ../ricerca; echo "exit=$?"
file archiviati: 5
dimensione archivio: 306 byte
cartella di lavoro: /home/federicocalo/.cache/tmp/tmp.QPp63KoWZD
exit=0

$ ./backup.sh; echo "exit=$?"
./backup.sh: riga 4: 1: serve la cartella da archiviare
exit=1

$ ./backup.sh /non/esiste; echo "exit=$?"
errore: '/non/esiste' non e' una cartella
exit=2

Tre casi, tre codici diversi, e questo è il punto. Zero è successo, uno è «mi hai chiamato male», due è «l'argomento che mi hai dato non è valido». Chi chiama lo script può distinguerli senza leggere il testo del messaggio, e questo rende lo script componibile con altri.

La verifica finale è quella che conta davvero, perché controlla la promessa che il codice fa:

$ ls -d /home/federicocalo/.cache/tmp/tmp.QPp63KoWZD
ls: cannot access '/home/federicocalo/.cache/tmp/tmp.QPp63KoWZD': No such file or directory

La cartella che lo script aveva stampato non esiste più: il trap ha fatto il suo lavoro. L'errore che avrei potuto commettere, e che è il più insidioso, è provare solo il caso che funziona: il trap scritto male si vede solo nei casi di errore, quindi un collaudo che prova soltanto la strada buona non lo collauda affatto.

Il caso negativo: due cicli che sembrano equivalenti

In una cartella ci sono due file, uno dei quali si chiama a b.txt. Due cicli attraversano la cartella. Prima di leggere l'output, dimmi quante volte gira ciascuno.

$ for f in spazi/*; do echo "[$f]"; done
[spazi/a b.txt]
[spazi/c.txt]

$ for f in $(ls spazi); do echo "[$f]"; done
[a]
[b.txt]
[c.txt]

Il primo gira due volte, una per file. Il secondo gira tre volte, e nessuna delle tre corrisponde a un file esistente: il nome con lo spazio è stato spezzato in due, e i percorsi hanno perso la cartella. Se il corpo del ciclo invece di echo contenesse rm, il danno sarebbe stato silenzioso e parziale.

La causa è la stessa delle virgolette: l'output di ls è testo, e la shell lo rilegge dividendolo sugli spazi. Il modello con l'asterisco invece non passa per il testo, perché la shell espande direttamente in una lista di nomi, e i nomi restano interi anche se contengono spazi. La regola che ne segue: non si cicla sull'output di ls, mai, nemmeno per una prova rapida, perché la prova rapida è quella che poi resta nel file.

Il guasto gemello riguarda la lettura riga per riga, e si vede nel secondo blocco. Qui la differenza è su un file che contiene barre rovesciate:

$ while IFS= read -r r; do echo "[$r]"; done < righe.txt
[riga  con   spazi]
[riga\con\backslash]

$ while read r; do echo "[$r]"; done < righe.txt
[riga  con   spazi]
[rigaconbackslash]

Senza -r le barre rovesciate vengono interpretate come caratteri di escape e scompaiono, e senza IFS= gli spazi all'inizio e alla fine della riga vengono tagliati. Su un percorso di Windows, su una chiave di configurazione o su un dato che arriva da un sistema esterno, quella trasformazione cambia il valore senza dirlo. La forma da imparare a memoria e scrivere sempre è while IFS= read -r riga.

Esercizio 1, guidato: ripara uno script

Questo script dovrebbe spostare in archivio tutti i file di log più vecchi di trenta giorni e riportare quanti ne ha spostati. Ha quattro difetti fra quelli trattati in questa lezione. Trovali e riscrivilo, poi prova la tua versione con un file il cui nome contiene uno spazio.

#!/bin/bash
DIR=$1
ARCHIVIO=/var/archivio
n=0
for f in $(ls $DIR); do
  mv $DIR/$f $ARCHIVIO
  n=$((n+1))
done
echo "spostati $n file"

Esercizio 2, autonomo: un salvataggio che si verifica da solo

Scrivi uno script che riceve una cartella e un file di destinazione, crea l'archivio, e verifica di averlo creato bene prima di dichiarare successo: la verifica deve essere un controllo del contenuto dell'archivio, non del fatto che il comando non ha dato errore. Deve restituire codici di uscita diversi per argomento mancante, cartella inesistente, e archivio che non supera la verifica. Criterio di successo: eseguendolo tre volte in modo da provocare i tre guasti, ottieni tre codici diversi, e la cartella temporanea non resta su disco in nessuno dei tre casi.

Soluzioni commentate

Esercizio 1, i quattro difetti. Il primo è il ciclo su $(ls $DIR), che spezza i nomi con spazi e perde il percorso: va sostituito con for f in "$DIR"/*, che produce percorsi completi e nomi interi. Il secondo sono le espansioni nude, $DIR e $f, da mettere fra virgolette in tutte le occorrenze, compresa quella di mv. Il terzo è l'assenza di set -euo pipefail: così com'è, se mv fallisce lo script incrementa il contatore comunque e dichiara un numero di file spostati che non corrisponde al vero. Il quarto è il controllo che manca su $1: con l'argomento assente, DIR è vuota, il modello diventa /* e il ciclo tenta di spostare la radice del filesystem.

Quel quarto difetto merita una riga in più, perché è il più grave e il meno visibile. Lo script non è «sbagliato quando gli manca un argomento»: è distruttivo quando gli manca un argomento, e il difetto si manifesta soltanto nel caso che nessuno prova. La correzione è DIR="${1:?serve la cartella}", che costa quattro caratteri.

Una cosa che il testo dell'esercizio non chiedeva e che vale la pena notare: lo script non filtra per età. L'enunciato parla di file più vecchi di trenta giorni, e il ciclo prende tutto. Se hai trovato anche questo, hai fatto la cosa che conta più di riconoscere i quattro difetti noti, cioè hai confrontato il codice con la sua specifica invece che con un elenco di errori tipici. La forma corretta usa find "$DIR" -type f -name '*.log' -mtime +30, che hai visto nella lezione sulla ricerca dei file.

Esercizio 2. La parte in cui si gioca l'esercizio è la verifica, e la risposta sbagliata ma frequente è controllare il codice di uscita di tar. Non basta: tar può restituire zero e produrre un archivio che non contiene quello che credi, per esempio se la cartella era vuota. La verifica che dimostra qualcosa è rileggere l'archivio, con tar -tzf "$archivio" | wc -l, e confrontare il conteggio con quello dei file di partenza. Due numeri che devono coincidere, e un codice di uscita dedicato se non coincidono.

Sui codici: zero per il successo, e tre valori distinti e documentati in un commento in testa al file per i tre guasti. Il valore numerico scelto conta meno del fatto che siano diversi e scritti da qualche parte: un codice di uscita non documentato è un codice che il prossimo chiamante tratterà come generico. Sulla cartella temporanea, l'unica forma che regge in tutti e tre i casi è il trap registrato subito dopo mktemp -d, e il modo di collaudarlo è stampare il percorso e poi cercarlo dopo l'uscita, come nell'esempio svolto.

Domande per fissare il concetto

Perché set -e da solo non basta, e quale categoria di guasti lascia passare che pipefail intercetta? In quale situazione trap ... EXIT non viene eseguito, e che cosa puoi fare al riguardo? E se dovessi spiegare a un collega in una frase perché le virgolette attorno a una variabile non sono una questione di gusto, quale frase useresti?

Che cosa adesso sai fare

Sai scrivere uno script che si ferma al primo guasto invece di proseguire su dati mancanti, e sai le tre opzioni che lo ottengono. Sai quotare le espansioni e sai dire che cosa succede se non lo fai, su un nome con uno spazio. Sai registrare una pulizia che avviene anche quando lo script esce per errore, e sai collaudarla provocando l'errore invece di provare solo la strada buona. Sai assegnare codici di uscita distinti ai guasti distinti.

La verifica concreta: prendi uno script che hai già scritto, cerca le espansioni senza virgolette e i cicli sull'output di ls, e provalo su una cartella in cui un file ha uno spazio nel nome. Se il risultato cambia, hai trovato un guasto che era già lì.