Hlavní navigace

Bashible: automatizační jazyk inspirovaný Ansiblem

Při psaní bashových skriptů číhají na každém kroku nějaké záludnosti. Programátoři proto vymýšlejí, jak by skripty nahradili něčím spolehlivějším. Proto vznikl nástroj Ansible. Nedalo by se něco podobného napsat v Bashi?
Jan Molič 23. 3. 2020
Doba čtení: 11 minut

Sdílet

Inspirovat se přednostmi, které Ansible má, ale oprostit se od závislosti na dalším skriptovacím jazyce? Ten pokus byl učiněn a jmenuje se Bashible.

Cíle Bashible

  1. dobrou čitelnost skriptů po vzoru Ansible
  2. být striktní; okamžitě ukončit skript při neošetřeném návratovém kódu
  3. umožnit snadné přeskakování již vykonaných činností
  4. nastavovat aktuální adresář
  5. ošetřovat hodnoty proměnných
  6. řešit hierarchii závislostí
  7. implementovat odložené úlohy
  8. ukončovat dceřinné procesy společně s rodičem
  9. modularitu; možnost přidávání vlastní funkcionality
  10. neimplementovat to, co lze udělat jinak (ssh, pdsh, smyčky)

Nejprve si připomeňme, jak funguje nástroj Ansible.

Ansible

Základem jsou playbooky. Každý playbook představuje seznam akcí, které se mají v systému vykonat. Výhodou je, že při dalším spuštění playbooku se již vykonané akce neopakují. Tomu se říká idempotence. Playbook může vypadat takto:

tasks:
  - name: Install nginx
    yum:
      name: nginx
      state: present

  - name: Start NgiNX
    service:
      name: nginx
      state: started

  - command: /bin/false
    register: result
    ignore_errors: True

  - command: /bin/something
    when: result|failed

  - command: /bin/something_else
    when: result|succeeded

Playbook pochopí snad každý na první pohled, což je velkou výhodou. Je ve formátu YAML. Všimněte si, že jsou v něm implementovány i podmínky ( when), avšak nic se nikde nerozvětvuje – právě tím je možná docíleno lepší čitelnosti.

Jenže tvůrci Ansible nakonec YAML poněkud „znásilnili“. Implementovali totiž smyčky a proměnné, čímž vznikl jakýsi programovací jazyk, avšak, podle mého názoru, nevalného designu. Takovéhle konstrukce, myslím si, čitelnost naopak snižují:

- command: echo {{ item }}
  with_items: "{{ mylist|default([]) }}"
  when: item > 5

Nebyla by zde zřetelnější obyčejná smyčka for- end?

Bashible

Skript v Bashible může vypadat takto:

#!/usr/local/bin/bashible

@ Synchronzuji soubory
  - result synced rsync -av /foo /bar

@ Posilam mail, kdyz se synchronizace nezdarila
  - when not synced
  - mail me@me.com <<< "synchronziace se nezdarila"

@ Vypinam stroj, pokud ma byt vypinan po synchronizaci
  - when synced
  - not when test -f /etc/shutdown-disabled
  - shutdown -h now

Zavináče (bloky) i pomlčky (příkazy) jsou obyčejné bashové funkce, které spouštějí (či nespouštějí) své argumenty. Nejde tedy o žádný nový jazyk. Skript v Bashible je pořád jenom bashový skript. Lze využívat proměnných prostředí a dalších jazykových konstrukcí Bashe.

MIF aplikace

Do čistého Bashe by se ukázka výše přepsala přibližně takto. Chci tím demonstrovat onu subjektivně lepší čitelnost zápisu v Bashible:

#!/bin/bash
set -eux -o pipefail
cd `dirname $0`
echo Synchronizuji soubory
if rsync -av /foo /bar; then
  echo Vypinam stroj, pokud ma byt vypinan po synchronizaci
  if test -f /etc/do-shutdown; then
    shutdown -h now
  fi
else
  echo "Posilam mail, kdyz se synchronizace nezdarila"
  mail me@me.com <<< "synchronziace se nezdarila"
fi

Další ukázka: editace souboru sshd_config a generování my.cnf ze šablony s využitím modulů templateedit.

#!/usr/local/bin/bashible

use template edit

@ Edituji sshd_config
  - cd /etc/ssh
  - uncomment_lines_matching 'UseDNS' sshd_config
  - uncomment_lines_matching 'X11Forwarding no' sshd_config
  - replace_lines_matching 'AllowTcpForwarding' 'AllowTcpForwarding yes' sshd_config

@ Generuji konfiguraci mysql ze sablony, pokud je mysqld v systemu
  - when which myslqd
  - not when test -f /etc/my.cnf
  - fill_var HOSTNAME not empty hostname
  - fill_var LISTEN not empty cat /etc/myip.txt
  - output_to /etc/my.cnf template my.cnf.tpl
  - delayed unless_already systemctl reload nginx

Skončit dřív, než se něco poškodí

Jakmile příkaz ve skriptu selže, je vhodné skript okamžitě ukončit, než ho nechat běžet, jak je obvyklé. Proto Bashible ve výchozím stavu nastavuje striktní režim  set -e -o pipefail.

Někdy ale návratový kód není důležitý. Třeba když chceme něco smazat a je zbytečné testovat, zda to existuje či nikoli. Pak se hodí funkce  may_fail:

- may_fail rm /neexistujici

Nechceme-li vidět ani případnou chybovou hlášku, předřadíme funkci  quiet:

- quiet may_fail rm /neexistujici

Skript skončí také při použití nedefinované proměnné (unbound variable). Bashible nastavuje striktní režim set -u. Někdy se však hodí tento režim dočasně vypnout, například když ve skriptu používáme proměnné prostředí, které mohou, ale nemusejí být nastaveny:

set +u
echo $nedefinovana_promenna  # toto projde
set -u
echo $nedefinovana_promenna  # toto nikoli - vychozi chovani

Přeskakování již vykonaných činností

Bloky ( @) slouží především k přeskakování činností, které již byly vykonány. Funguje to tak, že funkce „ - “ spouští příkaz (své argumenty) jen tehdy, nebyl-li v témže bloku již nastaven režim přeskakování.

@ Instaluji nginx, pokud jeste neni v systemu
  - not when which nginx
  - aptitude install nginx

Vícewhen v témže bloku docílí podmínky AND :

@ Instaluji nginx, pokud jeste neni v systemu a zaroven tam take neni apache
  - not when which nginx
  - not when which apache2
  - aptitude install nginx

Je lhostejné, píšeme-li not when či when not. Někdy zní angličtěji to, jindy ono.

Dále funkce result zapamatuje návratový kód. Funguje to tak, že nadefinuje novou funkci – zde s názvem  installed:

- result installed true
- result installed false
- result installed which nginx
- result installed test -f /foo/bar

Tuto novou funkci pak použijeme s when:

- when installed
- when not installed
- not when installed

Podle zaznamenaného návratového kódu se lze i rozhodovat. V následujícím příkladu skončí příkaz ls nejspíš s návratovým kódem  2:

result listed ls /nonexistent

@ Podle toho udelam foo
  - when listed == 0
  - foo

@ Podle toho udelam bar
  - when listed == 1
  - bar

@ Podle toho udelam baz
  - when listed -gt 1   # větší než 1
  - when listed -le 3   # a zároveň menší nebo rovný 3
  - baz

Aktuální adresář

Často narážíme na problém, že byl skript spuštěn z jiného adresáře, než měl být. Bashible automaticky na začátku každého bloku ( @) dělá chdir. Ve výchozím stavu do téhož adresáře, v němž se skript nachází.

$ cd /tmp
$ bashible /home/uzivatel/muj_skript.bash

@ Blok 1
  - pwd       # /home/uzivatel
  - cd /mnt   # zmena

@ Blok 2
  - pwd       # opet /home/uzivatel

Chceme-li přenastavit základní adresář jinam, slouží k tomu funkce base_dir. Každý další blok v témže skriptu pak bude dělat chdir právě tam. Funkce reset_base_dir navrátí počáteční stav a funkce orig_dir vypíše adresář, odkud byl skript spuštěn.

Ošetření hodnot proměnných

Dalším častým problémem je neošetřování hodnot proměnných. Například očekáváme nějakou hodnotu, jenže příkaz, který by ji měl vrátit, selže:

myhost=` hostnam `
rm -rf /home/$myhost     # a vsichni uzivatele zmizi

V Bashible využijeme funkce empty:

- fill_var myhost not empty hostnam
- rm -rf /home/$myhost

Proces by skončil buď už při spuštění hostnam (které neexistuje), nebo kdyby hostnam nic nevypsalo.

Závislosti

Řekněme, že naše hypotetická aplikace má webové API a webové rozhraní. API běží nad Node.js, web je generován v PHP. Oboje potřebuje k činnosti Nginx a MySQL. Ty zase závisejí na nějakém základním nastavení systému: system_base. Závislosti mohou vypadat takhle,

aplikace
├── api
│   ├── mysql
│   │   └── system_base
│   └── nodejs
│       └── system_base
├── nginx
│   └── system_base
└── web
    └── php
        └── system_base

Tedy máme celkem osm skriptů:

aplikace.bash

#!/usr/local/bin/bashible
- run api.bash
- run web.bash

api.bash

#!/usr/local/bin/bashible
- run mysql.bash
- run nodejs.bash

mysql.bash

#!/usr/local/bin/bashible
- unless_already run system_base.bash
...

nodejs.bash

#!/usr/local/bin/bashible
- unless_already run system_base.bash
...

Spuštěním konkrétního skriptu se nainstaluje daná část aplikace se všemi závislostmi. Spuštěním aplikace.bash pak celá aplikace.

Jelikož skript system_base.bash je volán z více míst, mohlo by dojít k zacyklení. Proto je u něj funkci run předřazena funkce unless_already. Ta zaznamenává již spuštěné příkazy (včetně jejich parametrů a aktuálních adresářů) napříč celou hierarchií skriptů. Skript system_base.bash proběhne jen jednou, poprvé.

Dojde-li ve vnořeném skriptu k chybě, celý proces se ukončí až k nejvyššímu rodiči.

K cílenému ukončení činnosti jednoho skriptu slouží funkce finish (rodič pokračuje dál). K zastavení celého procesu, tedy včetně nejvyššího rodiče, funkce  halt.

Odložené úlohy

Někdy se hodí, když některé úlohy proběhnou až na úplný závěr. Například reload webserveru. A navíc jen jednou, přestože „událost“ mohou „emitovat“ víckrát různé dceřiné skripty.

K tomuto účelu slouží funkce delayed, jež shromažďuje odložené příkazy. Interně je zapisuje do dočasného souboru, který je vytvořen nejvyšším rodičem. Opět je vhodné všemu předřadit funkci unless_already. Navíc may_fail, neboť také havárie odloženého příkazu by ukončila celý proces, takže by se další odložené příkazy již neprovedly.

- delayed unless_already may_fail quiet rm /foobar
- delayed unless_already systemctl reload nginx

Ukončení dceřiných procesů společně s rodičem

Asi jste se setkali s tím, že když zabijete rodičovský proces Bashe, dceřiné procesy zůstanou „viset“. Stačí například, když v dceřiném skriptu spustíte sleep 600 a zabijete rodiče. Bash se totiž chová jinak v interaktivním a neinteraktivním režimu.

Zatímco v interaktivním zabíjí všechno, v neinteraktivním nepřeposílá signály TERM ani INT. V neinteraktivním režimu navíc nelze odchytávat signály ( trap), dokud neskončí spuštěný příkaz (třeba ten  sleep).

V praxi ale spíš požadujeme, aby nikde nic nezůstalo „viset“. Proto Bashible reaguje ihned na INT/ HUP/ QUIT/ INT/ TERM a posílá TERM všem procesům se stejným  PGID.


Smyčky

Bashible smyčky neimplementuje, na rozdíl od Ansible. Smyčky se píší normálně v Bashi. Buď takhle:

@ Kopiruji soubory
  - when true
  - || for i in foo bar baz; do
     - cp "$i" /dest
  done

Nebo jednořádkově:

@ Kopiruji soubory
  - when true
  - || for i in foo bar baz; do - cp "$i" /dest; done

Díky konstrukci - || smyčka neproběhne, pokud je aktivní režim přeskakování.

Podobně while- done:

@ Kopiruji soubory
  - when true
  - || while read path; do
     - cp "$path" /dest
  done < list.txt

Či s použitím roury místo přesměrování:

@ Kopiruji soubory
  - when true
  - || cat list.txt | grep foo | while read path; do
     - cp "$path" /dest
  done

Jednořádková verze téhož:

@ Kopiruji soubory
  - when true
  - || cat list.txt | grep foo | while read path; do cp "$path" /dest; done

Výchozí nastavení set -o pipefail skript ukončí také v případě, že nějaký příkaz v rouře selže.

Moduly

Moduly jsou kolekce bashových funkcí. Bashible moduly hledá ve stejném adresáři, jako je samo, ve formátu  bashible.jmeno_modulu.bash.

Například modul edit obsahuje následující funkce:

add_line přidání řádky na konec souboru (není-li řádka již jinde v souboru)
append_line přidání řádky na konec souboru (není-li řádka již na konci souboru)
comment_lines_matching zakomentování řádek, které odpovídají regulárnímu výrazu
uncomment_lines_matching odkomentování řádek, které odpovídají regulárnímu výrazu
prepend_line přidání řádky na začátek souboru (není-li již řádka na začátku)
remove_lines_matching odstranění řádek, které odpovídají regulárnímu výrazu
replace_matching nahrazení řetězců odpovídajících regulárnímu výrazu
replace_lines_matching nahrazení celých řádků, na nichž se vyskytuje řetězec, odpovídající regulárnímu výrazu

Pozor, zatím to nemusí fungovat na všech platformách. Interně je používán GNU/sed, grep a další příkazy, které se mohou chovat na různých platformách různě.

Existuje také jednoduchý „template engine“ modul napsaný v Bashi na 18 řádcích. V šablonách lze provádět jakékoli příkazy, tisknout proměnné a šablona může volat také podšablony (parts):

<html>
  <head>
    $( template parts/head.tpl )
  </head>
  <body>
    Právě je: $( date )<br />
    Aktuální uživatel: $( echo $USER )<br />
    $( template parts/body.tpl )
  </body>
</html>

Díky striktnímu režimu generování šablony selže, vyskytne-li se v ní proměnná, která nebyla nadefinována. Až v šabloně je dále možné ošetřit prázdné řetězce, tedy namísto echo $USER psát  not empty echo $USER.

Šablony asi nikdo nebude používat ke generování HTML, přesto se hodí například ke generování konfiguračních souborů:

use template

@ Generuji my.cnf
   - fill_var HOSTNAME hostname
   - fill_var MYIP evaluate "host foobar.com | grep 'has address' | cut -d ' ' -f 4"
   - output_to /etc/mysql/my.cnf template my.cnf.tpl

Neimplementovat to, co lze udělat stávajícími nástroji

Bashible neumí přístup přes SSH, na rozdíl od Ansible. Neimplementuje ani žádný repozitář s definicemi, co se má na jakém stroji udělat. Takovou funkcionalitu lze doplnit v podobě externího nástroje, který zkopíruje skripty na cílový stroj a tam je spustí skrze ssh či  pdsh.

Bashible dále neimplementuje „facts“, čili abstrakci vlastností systému. Fakta by mohl nastavovat modul v podobě bashových proměnných, třeba takhle:

use system_facts

echo $architecture
echo $ipv4_addresses

Jenže nebylo by lepší, namísto faktů spouštět jednoúčelové systémové příkazy? Proč nastavovat proměnnou $architecture, když máme příkaz uname -a? Nebo proměnnou $hostname, získáme-li hodnotu spuštěním příkazu hostname? V případě ip adresy už je ale situace složitější. V systému může být buď ifconfig nebo ip, navíc je nutno zapojit grep, cut či awk, abychom zjistili jediný údaj.

Myslím si, že toto je zásadní nedostatek, kvůli němuž vlastně vzniklo Ansible. V Pythonu totiž řeší to, co by mělo fungovat už na příkazové řádce. V Linuxu nejen že neexistuje standardní kolekce malých, jednoúčelových nástrojů, které by měly jednotné rozhraní, ale neexistuje ani jednotný komunikační formát mezi nimi, jenž by umožnil snadnou a spolehlivou výměnu metadat. Je nanejvýš pošetilé, kvůli pár IP adresám parsovat text, který ani nebyl určen ke strojovému zpracování. A svým způsobem je také pošetilé dělat shellové věci v Pythonu.

Zatím jen koncept

Bashible je zatím koncept. Cílem je, aby s verzí 1.0 nebylo nutné už nic měnit. Veškerá přídavná funkcionalita by měla spočívat v modulech a nebo ještě lépe, v jednoúčelových systémových příkazech, napsaných třeba v C, Rustu či Go.

Dalo by se pokračovat třeba tak, že by byla vytvořena standardní kolekce těchto jednoúčelových příkazů, nejlépe multiplatformních, využitelná nejen v Bashible. A hlavně, s jednotným formátem pro výměnu metadat a jednotným dotazovacím jazykem. Je to utopie? Pokud ano,  Bashible zůstane jen dalším zbytečným frameworkem. Přesto snad aspoň někoho inspiruje.