Skip to main content

Cyber AI România

Vineri, 6 noiembrie 2026

Bash pipefail și PIPESTATUS: pipeline-uri care nu ascund erorile

Un raport zilnic poate arăta corect chiar dacă prima comandă din pipeline nu a reușit să citească toate datele. Ultima comandă, precum tee, termină cu succes, scriptul întoarce codul 0, iar automatizarea publică un rezultat incomplet.

În acest laborator construim un pipeline Bash pentru procesarea unui jurnal comprimat. Acesta decomprimă datele, validează fiecare înregistrare, numără nivelurile WARN și ERROR, păstrează codul de ieșire al fiecărei etape și înlocuiește raportul anterior numai dacă toate comenzile reușesc.

Rezultatul verificabil este format din două fișiere: output/level-counts.txt, cu numărul evenimentelor, și output/status-good.tsv, cu starea fiecărei comenzi. Vom deteriora controlat copia arhivei și vom demonstra că un pipeline obișnuit ascunde eroarea, în timp ce scriptul robust întoarce un cod diferit de zero și păstrează raportul bun.

Fixăm laboratorul la Bash 5.2 și șase etape de procesare

Laboratorul este destinat Ubuntu 24.04 LTS, cu Bash 5.2, GNU coreutils 9.4, gzip 1.12 și GNU Awk 5.2.1. Reviziile pachetelor pot diferi după actualizările instalate, de aceea comenzile de mai jos reprezintă verificarea autoritară pe sistemul cititorului.

Sunt suficiente 256 MB RAM, mai puțin de 10 MB pe disc și aproximativ 45 de minute. Nu sunt necesare privilegii root după instalarea pachetelor.

. /etc/os-release
printf 'Sistem: %sn' "$PRETTY_NAME"
bash --version | sed -n '1p'
gzip --version | sed -n '1p'
gawk --version | sed -n '1p'
sort --version | sed -n '1p'
df -h .

Un exemplu de rezultat este:

Sistem: Ubuntu 24.04.x LTS
GNU bash, version 5.2...
gzip 1.12
GNU Awk 5.2.1...
sort (GNU coreutils) 9.4
Filesystem      Size  Used Avail Use% Mounted on
...

Instalăm explicit comenzile folosite și creăm directorul proiectului:

sudo apt update
sudo apt install --yes bash coreutils gzip gawk
install -d "$HOME/pipeline-robust/input"
install -d "$HOME/pipeline-robust/output"
install -d "$HOME/pipeline-robust/scripts"
cd "$HOME/pipeline-robust"

Proiectul va avea următoarea structură:

pipeline-robust/
├── input/
│   ├── events.tsv.gz           # setul valid, comprimat
│   └── events-corrupt.tsv.gz   # copie cu footerul gzip eliminat
├── output/
│   ├── level-counts.txt        # raport publicat numai după succes
│   ├── status-good.tsv         # codurile etapelor pentru intrarea validă
│   └── status-corrupt.tsv      # codurile etapelor după testul negativ
└── scripts/
    ├── build-fixture.sh        # generează datele reproductibile
    ├── report.sh               # rulează și controlează pipeline-ul
    └── verify.sh               # execută testul complet

Pasul 1 — Generăm același jurnal valid și aceeași arhivă deteriorată

Acest pas creează opt evenimente TSV — câmpuri separate prin tab — și două arhive. A doua arhivă pierde ultimii opt octeți, adică footerul gzip; conținutul poate ajunge în aval, dar gzip trebuie să raporteze că fluxul nu s-a încheiat corect.

Fișierul scripts/build-fixture.sh:

#!/usr/bin/env bash
set -Eeuo pipefail

readonly ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
readonly SOURCE="$ROOT/input/events.tsv"
readonly GOOD="$ROOT/input/events.tsv.gz"
readonly CORRUPT="$ROOT/input/events-corrupt.tsv.gz"

install -d "$ROOT/input" "$ROOT/output"

cat > "$SOURCE" <<'DATA'
2026-09-26T08:00:00Z	INFO	api	worker started
2026-09-26T08:01:10Z	WARN	api	upstream response delayed
2026-09-26T08:02:20Z	ERROR	billing	invoice export failed
2026-09-26T08:03:30Z	INFO	billing	retry scheduled
2026-09-26T08:04:40Z	ERROR	api	upstream unavailable
2026-09-26T08:05:50Z	WARN	worker	queue depth increased
2026-09-26T08:06:00Z	INFO	worker	queue recovered
2026-09-26T08:07:10Z	ERROR	worker	job rejected
DATA

gzip --no-name --stdout -- "$SOURCE" > "$GOOD"
cp -- "$GOOD" "$CORRUPT"

size=$(stat --format='%s' -- "$CORRUPT")
if (( size <= 8 )); then
    printf 'fixture: arhiva este prea mică pentru testn' >&2
    exit 1
fi

truncate --size="$((size - 8))" -- "$CORRUPT"
rm -f -- "$SOURCE"

printf 'fixture: %sn' "$GOOD"
printf 'fixture deteriorat: %sn' "$CORRUPT"

Îl facem executabil și îl rulăm:

chmod 750 scripts/build-fixture.sh
./scripts/build-fixture.sh

--no-name elimină din arhivă numele și momentul fișierului sursă, făcând fixture-ul determinist. stat --format='%s' citește dimensiunea, iar truncate --size scurtează copia fără să modifice arhiva bună.

Trebuie să vezi:

fixture: /home/utilizator/pipeline-robust/input/events.tsv.gz
fixture deteriorat: /home/utilizator/pipeline-robust/input/events-corrupt.tsv.gz

Calea din exemplu diferă în funcție de valoarea lui $HOME.

Punct de control: dacă lipsesc cele două fișiere, verifică ls -l input/ și nu continua până când ambele arhive există.

Pasul 2 — Capturăm PIPESTATUS înainte ca o altă comandă să-l suprascrie

Fără opțiunea pipefail, codul unui pipeline este codul ultimei comenzi. Cu pipefail, Bash întoarce codul ultimei comenzi, citită de la stânga la dreapta, care a eșuat. Dacă toate reușesc, rezultatul este 0.

Această valoare agregată nu arată însă care etapă a eșuat. Array-ul Bash PIPESTATUS conține codul fiecărei comenzi din ultimul pipeline foreground. Trebuie copiat imediat: chiar și o comandă simplă executată după pipeline îi schimbă conținutul.

Fișierul scripts/report.sh:

#!/usr/bin/env bash
set -uo pipefail

readonly ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
readonly INPUT=${1:-"$ROOT/input/events.tsv.gz"}
readonly REPORT=${2:-"$ROOT/output/level-counts.txt"}
readonly STATUS_FILE=${3:-"$ROOT/output/pipeline-status.tsv"}

stages=(
    "gzip -cd"
    "gawk validate"
    "sort level"
    "uniq -c"
    "sort -nr"
    "tee temporary"
)
readonly stages

if ! install -d "$(dirname -- "$REPORT")" "$(dirname -- "$STATUS_FILE")"; then
    printf 'pipeline: nu pot crea directoarele de ieșiren' >&2
    exit 73
fi

if ! temporary_report=$(mktemp --tmpdir="$(dirname -- "$REPORT")" 
    '.level-counts.XXXXXX'); then
    printf 'pipeline: nu pot crea raportul temporarn' >&2
    exit 73
fi

cleanup() {
    rm -f -- "$temporary_report"
}
trap cleanup EXIT HUP INT TERM

gzip --decompress --stdout -- "$INPUT" |
gawk -F 't' '
BEGIN {
    OFS = "t"
}
NF != 4 {
    printf "validator: line %d has %d fields, expected 4n", NR, NF > "/dev/stderr"
    invalid = 1
    next
}
$2 !~ /^(INFO|WARN|ERROR)$/ {
    printf "validator: line %d has invalid level: %sn", NR, $2 > "/dev/stderr"
    invalid = 1
    next
}
$2 != "INFO" {
    print $2
}
END {
    if (invalid) {
        exit 65
    }
}
' |
LC_ALL=C sort |
uniq --count |
LC_ALL=C sort --numeric-sort --reverse |
tee -- "$temporary_report"

statuses=("${PIPESTATUS[@]}")

if (( ${#statuses[@]} != ${#stages[@]} )); then
    printf 'pipeline: expected %d stage statuses, got %dn' 
        "${#stages[@]}" "${#statuses[@]}" >&2
    exit 70
fi

if ! {
    printf 'stagetstatusn'
    for index in "${!stages[@]}"; do
        printf '%st%dn' "${stages[$index]}" "${statuses[$index]}"
    done
} > "$STATUS_FILE"; then
    printf 'pipeline: nu pot scrie %sn' "$STATUS_FILE" >&2
    exit 73
fi

pipeline_status=0
for status in "${statuses[@]}"; do
    if (( status != 0 )); then
        pipeline_status=$status
    fi
done

if (( pipeline_status != 0 )); then
    printf 'pipeline: raport respins; cod final %dn' "$pipeline_status" >&2
    exit "$pipeline_status"
fi

if ! mv -- "$temporary_report" "$REPORT"; then
    printf 'pipeline: nu pot publica %sn' "$REPORT" >&2
    exit 74
fi

trap - EXIT HUP INT TERM
printf 'pipeline: raport publicat în %sn' "$REPORT"

Activăm scriptul:

chmod 750 scripts/report.sh

Pipeline-ul are șase procese. gawk verifică numărul câmpurilor și acceptă numai nivelurile stabilite. LC_ALL=C oferă o ordine reproductibilă. uniq --count numără liniile consecutive, motiv pentru care este precedat de sort.

tee scrie simultan pe terminal și într-un fișier temporar. Fișierul final este înlocuit prin mv numai după verificarea tuturor codurilor. Astfel, un raport bun anterior nu este distrus de o execuție incompletă.

Scriptul nu se bazează pe set -e pentru această decizie. Ieșirea automată ar interveni înainte de logica noastră în anumite contexte, iar regulile sale au excepții pentru liste condiționale, pipeline-uri și alte construcții. Aici fiecare eroare relevantă este tratată explicit.

Punct de control: bash -n scripts/report.sh nu trebuie să afișeze nimic și trebuie să întoarcă 0.

Pasul 3 — Publicăm raportul numai când toate cele șase coduri sunt zero

Rulăm pipeline-ul cu arhiva validă și afișăm atât raportul, cât și registrul codurilor:

./scripts/report.sh 
    input/events.tsv.gz 
    output/level-counts.txt 
    output/status-good.tsv

cat output/level-counts.txt
cat output/status-good.tsv

Prima parte a rezultatului este un exemplu; spațiile din fața numerelor provin de la uniq --count:

      3 ERROR
      2 WARN
pipeline: raport publicat în output/level-counts.txt
      3 ERROR
      2 WARN
stage	status
gzip -cd	0
gawk validate	0
sort level	0
uniq -c	0
sort -nr	0
tee temporary	0

Codul 0 de pe toate rândurile confirmă traseul complet, nu doar succesul lui tee. În automatizări mai ample, aceeași disciplină ajută și comenzile dintr-un audit rapid al utilizatorilor, serviciilor, porturilor și logurilor: fiecare filtru trebuie să poată opri publicarea unui rezultat incomplet.

Punct de control: dacă raportul nu conține exact două rânduri, verifică mai întâi status-good.tsv; etapa cu valoare diferită de zero indică locul investigației.

Pasul 4 — Demonstrăm cum ultimul proces poate masca o arhivă invalidă

Mai întâi rulăm o variantă intenționat fragilă. wc primește toate cele opt linii pe care gzip reușește să le emită, apoi termină cu succes. Pentru că pipefail este dezactivat, codul pipeline-ului devine codul lui wc.

set +o pipefail
gzip --decompress --stdout -- input/events-corrupt.tsv.gz | wc --lines
naive_status=$?
set -o pipefail
printf 'cod pipeline fragil: %dn' "$naive_status"

Ordinea dintre mesajul de eroare și numărul de linii poate varia, deoarece stderr și stdout sunt fluxuri diferite. Rezultatul relevant este:

gzip: input/events-corrupt.tsv.gz: unexpected end of file
8
cod pipeline fragil: 0

Acum folosim scriptul robust. Dezactivăm temporar errexit în shell-ul apelant numai ca să putem tipări codul primit:

set +e
./scripts/report.sh 
    input/events-corrupt.tsv.gz 
    output/level-counts.txt 
    output/status-corrupt.tsv
robust_status=$?
set -e

printf 'cod pipeline robust: %dn' "$robust_status"
cat output/status-corrupt.tsv

Exemplul așteptat este:

gzip: input/events-corrupt.tsv.gz: unexpected end of file
      3 ERROR
      2 WARN
pipeline: raport respins; cod final 1
cod pipeline robust: 1
stage	status
gzip -cd	1
gawk validate	0
sort level	0
uniq -c	0
sort -nr	0
tee temporary	0

Aceasta este eroarea pe care simpla verificare a ultimei comenzi ar fi pierdut-o. Faptul că textul raportului pare complet nu repară arhiva și nu justifică publicarea sa.

Pentru fluxuri JSON, aceeași structură poate folosi un validator care întoarce explicit un cod diferit de zero; operațiile sigure sunt detaliate în ghidul despre transformări și validarea JSON cu jq.

Punct de control: robust_status trebuie să fie 1, iar gzip -cd trebuie să fie singura etapă cu stare nenulă.

Pasul 5 — Automatizăm proba de capăt și protejăm raportul anterior

Ultimul script reconstruiește intrările, verifică raportul valid, rulează cazul corupt și compară hash-ul raportului înainte și după eroare. În acest fel demonstrăm că rezultatul publicat nu a fost înlocuit.

Fișierul scripts/verify.sh:

#!/usr/bin/env bash
set -Eeuo pipefail

readonly ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
readonly REPORT="$ROOT/output/level-counts.txt"
readonly GOOD_STATUS="$ROOT/output/status-good.tsv"
readonly BAD_STATUS="$ROOT/output/status-corrupt.tsv"
readonly STDERR_FILE="$ROOT/output/corrupt.stderr"

expected=$(mktemp)
cleanup() {
    rm -f -- "$expected"
}
trap cleanup EXIT HUP INT TERM

printf '      3 ERRORn      2 WARNn' > "$expected"

"$ROOT/scripts/build-fixture.sh" >/dev/null
"$ROOT/scripts/report.sh" 
    "$ROOT/input/events.tsv.gz" 
    "$REPORT" 
    "$GOOD_STATUS" >/dev/null

if ! diff --unified -- "$expected" "$REPORT"; then
    printf 'verify: report content differsn' >&2
    exit 1
fi

before=$(sha256sum -- "$REPORT" | gawk '{print $1}')

set +e
"$ROOT/scripts/report.sh" 
    "$ROOT/input/events-corrupt.tsv.gz" 
    "$REPORT" 
    "$BAD_STATUS" >/dev/null 2>"$STDERR_FILE"
corrupt_status=$?
set -e

after=$(sha256sum -- "$REPORT" | gawk '{print $1}')

if (( corrupt_status == 0 )); then
    printf 'verify: corrupted input was acceptedn' >&2
    exit 1
fi

if [[ "$before" != "$after" ]]; then
    printf 'verify: published report changed after failuren' >&2
    exit 1
fi

if ! grep --fixed-strings --line-regexp $'gzip -cdt1' "$BAD_STATUS"; then
    printf 'verify: gzip failure is missing from status filen' >&2
    exit 1
fi

printf 'PASS: raportul valid corespunde rezultatului așteptatn'
printf 'PASS: intrarea deteriorată întoarce codul %dn' "$corrupt_status"
printf 'PASS: raportul publicat rămâne neschimbat după eroaren'

Rularea finală:

chmod 750 scripts/verify.sh
./scripts/verify.sh

Trebuie să obții:

gzip -cd	1
PASS: raportul valid corespunde rezultatului așteptat
PASS: intrarea deteriorată întoarce codul 1
PASS: raportul publicat rămâne neschimbat după eroare

Punct de control: toate cele trei linii PASS trebuie să apară, iar comanda printf '%sn' "$?" executată imediat după test trebuie să afișeze 0.

Mesajele care identifică etapa defectă

gzip: input/events-corrupt.tsv.gz: unexpected end of file înseamnă că fluxul comprimat este trunchiat. Nu accepta rezultatul parțial; reconstruiește fixture-ul cu ./scripts/build-fixture.sh sau recopiază arhiva originală din sursa controlată.

gzip: input/missing.tsv.gz: No such file or directory indică o cale de intrare greșită. Confirmă argumentul cu realpath --canonicalize-existing input/events.tsv.gz, apoi rulează scriptul cu acea cale.

validator: line 4 has 3 fields, expected 4 apare când o înregistrare nu are exact patru câmpuri separate prin tab. Identifică liniile afectate fără a publica raportul:

gzip --decompress --stdout -- input/events.tsv.gz |
gawk -F 't' 'NF != 4 { print NR ": " $0 }'

validator: line 4 has invalid level: CRITICAL arată că al doilea câmp nu este INFO, WARN sau ERROR. Corectează sursa conform contractului de date; nu extinde expresia regulată doar pentru a face testul verde.

verify: report content differs înseamnă că datele valide nu mai produc rezultatul fixat în test. Rulează diff --unified <(printf ' 3 ERRORn 2 WARNn') output/level-counts.txt și examinează diferența înainte de a schimba rezultatul așteptat.

Ștergem laboratorul fără să atingem alte fișiere

Comenzile următoare elimină numai directorul creat în acest tutorial. Verifică mai întâi calea afișată; rm --recursive șterge definitiv conținutul selectat.

cd "$HOME"
printf 'Director de șters: %sn' "$HOME/pipeline-robust"
rm --recursive -- "$HOME/pipeline-robust"

Nu am instalat servicii și nu am schimbat configurația sistemului. Pachetele bash, coreutils și gzip fac parte din instalarea de bază Ubuntu și nu trebuie eliminate pentru curățarea proiectului.

Bifele care confirmă că erorile nu mai trec neobservate

  • ☐ bash -n scripts/report.sh întoarce 0.
  • ☐ Raportul valid conține 3 ERROR și 2 WARN.
  • ☐ status-good.tsv conține șase coduri egale cu 0.
  • ☐ Arhiva deteriorată determină un cod final diferit de zero.
  • ☐ status-corrupt.tsv păstrează codul 1 pentru gzip -cd.
  • ☐ Eșecul nu înlocuiește output/level-counts.txt.
  • ☐ PIPESTATUS este copiat imediat după pipeline.
  • ☐ Datele ajung mai întâi într-un fișier temporar.
  • ☐ scripts/verify.sh afișează toate cele trei rezultate PASS.

Următorul proiect logic este rularea lui verify.sh într-un timer systemd, cu notificare OnFailure, astfel încât raportul să fie verificat periodic. Pentru date JSON, etapa gawk poate fi înlocuită cu o validare jq -e, păstrând aceeași captură PIPESTATUS. Într-un flux de evaluare automatizată, același model poate transforma fiecare verificare într-un criteriu măsurabil, precum în proiectul despre evaluări pentru agenți AI cu tool-uri.

Întrebări frecvente

De ce nu este suficient doar set -o pipefail?

pipefail produce un cod agregat corect, dar nu spune singur care etapă a eșuat. PIPESTATUS păstrează separat codul fiecărei comenzi din pipeline.

De ce trebuie copiat imediat array-ul PIPESTATUS?

Următoarea comandă simplă executată de Bash îi poate modifica valoarea. În script, atribuirea statuses=("${PIPESTATUS[@]}") este prima operație de după pipeline.

De ce scrie tee într-un fișier temporar?

Un proces din amonte poate eșua după ce a emis date parțiale. Fișierul temporar permite publicarea prin mv numai după ce toate codurile au fost verificate.

Merge proiectul și pe Debian?

Scripturile folosesc Bash și utilitare GNU, dar tutorialul fixează și verifică mediul pe Ubuntu 24.04 LTS. Pe Debian trebuie confirmate versiunile și opțiunile cu comenzile de verificare de la început.

Surse

Facebook
X
WhatsApp
Bash pipefail și PIPESTATUS: pipeline-uri care nu ascund erorile

Te-ar putea interesa si: