Dotfiles

Table of Contents

2021-12-08: I no longer use this approach. Instead, I use YADM.

Zarządzanie konfiguracją komputerów jest skomplikowane, a sprawa dodatkowo nabiera kolorytu wraz ze wzrostem liczby obsługiwanych komputerów osobistych, włączając w to telefony komórkowe. Każdy z nich trochę się różni: a to wersją systemu operacyjnego, a to ilością podpiętych monitorów, a to bebechami. System operacyjny wiele rzeczy przykrywa pod warstewką spójnego interfejsu, jednak część różnic konfiguracja użytkownika musi uwzględniać.

Prostota przystosowania konfiguracji do wielu urządzeń to nie jedyna z pożądanych cech systemu służącego do zarządzani zbiorem konfiguracji. Innymi cechami są łatwość instalacji/usunięcia pewnych części konfiguracji (np. usunięcia plików konfiguracyjnych dla programu, którego nie będziemy więcej używać), a także prostota definiowania zasad opisujących sam proces instalacji – skrypty sh tudzież basha, poza tymi najbardziej trywialnymi, mają to do siebie, że szybko stają się nieutrzymywalnymi koszmarkami: wodospadami wywołań komend przekazywanych do grepa, seda i awka wywoływanych z niezrozumiałymi wyrażeniami regularnymi, wyrytych na stałe w spaghetti if-elsów i swich-case’ów.

Nieco historii

Przez ostatnie 4 lata tworzyłem (nadal tworzę) system umożliwiający mi zarządzanie konfiguracjami w prosty i czytelny sposób. Celowo używam słowa “konfiguracjami”, a nie “plikami konfiguracjami”, gdyż konfiguracja jest pojęciem szerszym. Owszem, są to również pliki tekstowe zawierające opis najczęściej używanych opcji różnych programów, lecz oprócz tego są to na przykład reguły instalacji oprogramowania, komendy vima (vim -c), czy też komendy kompilujące i patchujące kod źródłowy. “Konfiguracja” to w tym kontekście ujęcie całościowe sposobu działania komputera dla danego użytkownika.

“Framework”, którego dziś używam, przez ostatnie lata ewoluował i w niczym nie przypomina kilku swoich poprzednich wersji. Część zmian zresztą nie ostała się nawet w historii gita; mam za sobą dość długi epizod tworzenia brancha dla każdej maszyny z osobna i cherry-pickowania między nimi części wspólnych (koszmar), który zakończył się usunięciem wzmiankowanych branchy.

W pierwszej wersji ów system był właśnie wspomnianym zestawem skryptów shellowych – każdy we własnym katalogu – zarządzanych przez skrypt centralny oraz “bibliotekę” wspólnych funkcji (np. do patchowania kodu, wyświetlania diffów, czy interaktywnego menu wyświetlającego się od wielkiego dzwonu). Każdy skrypt instalacyjny posiadał zatem obowiązkowe dwie pierwsze linijki: określenie swojej ścieżki bezwzględnej i source’owanie owej nieszczęsnej “biblioteki”:

DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
. "$DIR/../libconfig"

Następnie następowała seria wywołań magicznych funkcji, które miały za zadanie “bezpiecznie” kopiować i usuwać pliki, zmieniać ich nazwy, tworzyć linki symboliczne itp. Ciekawe było, że część funkcji tworzyła linki symboliczne, podczas gdy inna część tworzyła zwykłe kopie plików, przez co zmiany nie były natychmiastowo odzwierciedlane w repozytorium.

System ten istniał sobie dość długo, bo blisko 3 lata. Przez ten czas ewoluowała głównie biblioteka. Funkcja usuwająca pliki stała się bezpieczniejsza po tym jak przez przypadek usunęła ich zbyt wiele. Funkcja tworząca linki symboliczne nie zawsze chciała je tworzyć. Dopisane zostało wykrywanie już zainstalowanych plików konfiguracyjnych i interaktywne potwierdzanie ich nadpisywania. Takie buty.

Potem zaś nadeszła rewolucja – spowodowana głównie niemożliwością utrzymania różnych konfiguracji dla różnych komputerów.

Operacja na otwartym sercu

Wszystkie skrypty, łącznie z łamagowatą biblioteką, zostały wysadzone w puch, a ich miejsce zajął tandem config-manage i GNU Stow. Skrypt config-manage ma za zadanie wykonywać polecenia zapisane w pliku konfiguracyjnym za pomocą prostego do zrozumienia i edycji formatu ini. GNU Stow jest natomiast managerem dowiązań symbolicznych - to on wykonuje lwią część pracy polegającej na odpowiednim ulokowaniu plików konfiguracyjnych.

Repozytorium nadal jest podzielone na katalogi odpowiadające instalowalnym modułom. Na przykład może ono wyglądać następująco:

$HOME/config/
    home/
        .local/bin/
            script1
            script2
        .aliases
        .xsessionrc

    self/ -> .config-framework/self

    zsh/
        .zprofile
        .zshenv
        .zshrc

    .config-framework
        self/
            .local/bin/
                cmpver
                config-manage -> ../../../config-manage
            .zsh/completion/
                _config-manage
        config-manage

    config.ini

GNU Stow odpowiada za odwzorowanie wewnątrz katalogu domowego struktury plików i katalogów z każdego modułu. Zatem po zainstalowaniu modułów home, self i zsh, katalog domowy będzie miał następującą zawartość:

$HOME/
    .local/bin
        cmpver -> ../../config/self/.local/bin/cmpver
        config-manage -> ../../config/self/.local/bin/config-manage
        script1 -> ../../config/home/.local/bin/script1
        script2 -> ../../config/home/.local/bin/script2

    .zsh/completion/ -> ../../config/self/.zsh/completion

    .aliases -> ../../config/home/.aliases
    .xsessionrc -> ../../config/home/.xsessionrc
    .zprofile -> ../../config/zsh/.zprofile
    .zshenv -> ../../config/zsh/.zshenv
    .zshrc -> ../../config/zsh/.zshrc

Szczególnie warto zwrócić uwagę na .zsh/completion/ – w tej chwili jest to link symboliczny do katalogu, jednak gdyby więcej modułów zdefiniowało skrypty autouzupełniania dla zsh, wówczas GNU Stow utworzyłby w jego miejscu katalog, a w nim odpowiednie dowiązania.

config-manage

Skryptem, który zajmuje się wprawianiem maszynerii w ruch jest config-manage. Jego zadaniem jest zmiana katalogu roboczego, ustawienie kilku zmiennych dostępnych w pliku konfiguracyjnym i koniec końców odczyt i wykonanie zadań zapisanych w pliku config.ini - pierwszym znalezionym w bieżącym katalogu lub dowolnym katalogu nadrzędnym. Nie będę przytaczał tutaj kodu źródłowego samego skryptu, gdyż jest on dostępny w repozytorium, które specjalnie na tę okazję stworzyłem.

Repozytorium to jest stworzone z myślą o uwzględnieniu go w repozytorium konfiguracji jako submoduł gita. Domyślnie posiada moduł o nazwie self, który zainstaluje skrypt config-manage oraz dodatkowe skrypty pomocnicze w katalogu .local/bin

config-manage posiada dwie podkomendy służace do instalacji i deinstalacji modułów: install i uninstall. Obie po prostu przyjmują listę nazw modułów. Domyślne zasady (komendy) dlań wywoływane znajdują się w sekcji [DEFAULT] pliku config.ini. Sekcja [DEFAULT] zawiera zresztą domyślne pola dla każdej sekcji pliku konfiguracyjnego, a nawet jeśli dany moduł nie posiada odpowiadającej mu sekcji, to zostanie w zamian użyta sekcja domyślna. Oznacza to, że jeśli zadowolimy się ustawieniami domyślnimi (czyli jedynie instalacją plików konfiguracyjnych tu i ówdzie), to w ogóle nie trzeba się przejmować istnieniem pliku config.ini. Dodatkowo dla wygody w sekcji domyślnej utworzone są dwie dodatkowe zmienne, które mogą zostać użyte gdziekolwiek: ${stow_install} i ${stow_uninstall}. Ułatwiają one pisanie zasad instalacji modułów, w których wywołanie programu Stow jest zledwie jednym z kilku kroków.

Spójrzmy jak może wyglądać przykładowy plik config.ini:

[DEFAULT]
stow_install = stow -R ${targetname}
stow_uninstall = stow -D ${targetname}
install_cmd = ${stow_install}
uninstall_cmd = ${stow_uninstall}

[self]
# disable uninstall
uninstall_cmd =

[home]
install_cmd = ${stow_install}
              script1 ${env:PWD}
              ls ${var:home}/.local/bin

Jego treść mówi raczej sama za siebie, jednak dla jasności opiszę co się dzieje w jego poszczególnych fragmentach.

Najpierw zostały zdefiniowane domyśle zasady instalowania i odinstalowywania modułów. Następnie, w sekcji [self], określamy, że chcemy moduł o takiej nazwie jedynie instalować, uniemożliwiając jego usunięcie.

W sekcji [home] pozostawiamy domyślne zasady deinstalacji programów, jednak przy instalacji chcemy, oprócz utworzenia odpowiednich linków symbolicznych, wykonać uruchomienie testowe jednego z instalowanych skryptów, a następnie wylistowanie katalogu, w którym został zainstalowany. Wobec tego przekazujemy konkretne polecenia do pola install_cmd, każde w nowej linii. Teraz config-manage będzie wiedział, że przy instalacji musi wykonać więcej niż jedną komendę, jednak niepowodzenie dowolnej z nich (sygnalizowane przez zwrócenie statusu innego niż 0) zakończy wykonywanie całego “skryptu”.

Warto jeszcze zatrzymać się nad zmiennymi dostępnymi w pliku konfiguracyjnym, jest ich bowiem kilka:

  • ${targetname} - specjalna zmienna dodana przez skrypt config-manage, dostępna wewnątrz danej sekcji i zawierająca jej nazwę.
  • ${var:...} - kilka zmiennych pomocniczych dodanych przez config-manage. Należą do nich: ${var:home} (ścieżka bezwzględna do katalogu domowego) oraz ${var:workdir} (ścieżka bezwzględna do katalogu, w którym rezyduje skrypt config-manage). Dostępne są w każdej sekcji.
  • ${env:...} - wszystkie zmienne środowiskowe ustawione w momencie wywoływania config-manage. Dostępne w każdej sekcji.

Potrzeba argumentów pozycyjnych

O tym, że instalacja modułów wywoływana jest przez polecenie config-manage install, a także o tym co wtedy dzieje się pod maską, nie będę przypominał, gdyż ten temat załatwiliśmy powyżej. Czasem jednak chcielibyśmy dodatkowo sparametryzować wywołania instalatora. Na przykład, możemy zechcieć stworzyć1 moduł pkg, który będzie skarbnicą zasad kompilacji i instalacji paczek. Nie ma sensu przekompilowywać wszystkich paczek za każdym razem, gdyż zajęłoby to zbyt wiele czasu - kompilacja jest niezbędna tylko w przypadku braku danej paczki lub potrzeby jej aktualizacji.

Summa summarum config-manage to program do wywoływania szeregu komend. Dlatego niezbędna będzie nam komenda, która na podstawie przekazanych parametrów wywoła odpowiednie zadania kompilacji/instalacji dla konkretnej paczki. Będzie to parametryzowany skrypt, który umieścimy wewnątrz katalogu modułu. Drzewo plików może wówczas wyglądać następująco:

$HOME/config/
    pkg/
        firefox/
            Makefile
        neovim/
            Makefile
        other-program/
            Makefile
        install

    config.ini

Sam skrypt install może zaś wyglądać na przykład tak:

#!/bin/bash

DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"

if [[ "$1" != "" ]]; then
    for name in $@; do
        (cd "$DIR/$name" && make && make install)
    done
    exit
fi

for pkg in $(find "${DIR}" -mindepth 1 -maxdepth 1 -type d); do
    (cd "$DIR/$pkg" && make && make install)
done

Jego jedynym zadaniem jest przeanalizowanie przekazanych mu nazw paczek i wywołanie dla nich poleceń make i make install, które zawierają ostateczny opis procedury kompilacji i instalacji. W przypadku gdy skrypt nie otrzyma żadnego parametru, rozpocznie kompilację wszystkich paczek znalezionych w tym samym katalogu, w którym się znajduje.

Oczywiście skrypt taki może wywoływać dowolne inne polecenia i być w dowolnie skomplikowany - podałem jedynie łatwy do przetrawienia przykład.

Wywołanie skryptu install

Tak uzbrojeni, musimy teraz jedynie przekazać odpowiednie argumenty do skryptu install. Z poziomu konsoli wywołalibyśmy go np. poleceniem pkg/install neovim albo pkg/install firefox. W przypadku programu config-manage sprawa jest nieco bardziej skomplikowana, ale nie znów aż tak bardzo: dla każdego argumentu polecenia install lub uninstall można podać po dwukropku dodatkowe argumenty, które zostaną przypisane do specjalnej zmiennej ${posargs} dostępnej wewnątrz pliku konfiguracyjnego. Sekcja pliku config.ini dla modułu pkg wyglądać będzie w naszym przypadku następująco:

[pkg]
install_cmd = ${targetname}/install ${posargs}

Jeśli install_cmd zawierałoby więcej komend, to każda z nich mogłaby przyjmować zmienną ${posargs} (co jednak wielce prawdopodobnie nie ma większego sensu).

Samo wywołanie config-manage wyglądać będzie następująco (zwracam uwagę na drugie wywołanie: jeśli potrzebujemy rozdzielić spacjami część argumentów/opcji, musimy całość ująć w cudzysłów tak, aby słowo firefox zostało potraktowane jako część wywołania modułu pkg, a nie oddzielnego modułu o nazwie firefox):

$ config-manage install pkg:neovim pkg:firefox

$ config-manage install "pkg:neovim firefox"

Możemy pójść o krok dalej i połączyć zasady automatycznej kompilacji paczek i instalacji ich plików konfiguracyjnych. Jak? W bardzo prosty sposób: poprzez rekursywne wywołanie config-manage z wnętrza konfiguracji modułu. Poniżej przedstawiam przykład instalacji programu neovim:

[neovim]
install_cmd = which nvim > /dev/null || config-manage install pkg:neovim
              ${stow_install}
              nvim -c ":PlugClean!" -c ":PlugUpdate" -c ":PlugInstall" -c ":qa!"

Powyższa zasada wykonuje następujące kroki:

  1. sprawdza, czy program nvim jest dostępny w $PATH użytkownika, a jeśli nie jest, to go próbuje zainstalować przy pomocy modułu pkg;
  2. tworzy odpowiednie dowiązania symboliczne przy pomocy GNU Stow;
  3. instaluje pluginy vima.

Pragnę przy okazji zaznaczyć, że wywołanie instalacji paczki w pierwszej komendzie jako rekursywnego config-manage jest preferowane w stosunku do zwykłego wywołania skryptu pkg/install z tego powodu, że zasada modułu pkg może wykonywać jeszcze jakieś dodatkowe czynności (np. sprawdzanie wersji, instalacja zależności), których nie ma sensu dublować w opisach innych modułów.

Jak działa GNU Stow

GNU Stow jest managerem instalacji wielu programów (zwanych w nomenklaturze twórców programu “paczkami”) we wspólnych katalogach docelowych. Osiąga to poprzez tworzenie dowiązań symbolicznych do plików i katalogów położonych w innych lokacjach niż te, do których są instalowane.

Czemu miałoby to być przydatniejsze niż zwyczajowe make && make install? Żeby to lepiej zobrazować, posłużę się przykładem z manuala Stowa: gdybyśmy chcieli zainstalować Perla i Emacsa w katalogu /usr/local, musielibyśmy, między innymi, skopiować manuale obu tych programów do katalogu /usr/local/man/man1. Są to następujące pliki: a2p.1, ctags.1, emacs.1, etags.1, h2ph.1, perl.1 i s2p.1. Robi się tam śmietnik i w przypadku chęci odinstalowania lub aktualizacji np. Perla, administrator systemu musi ręcznie sprawdzić, który plik należy do Perla, a który do Emacsa.

Stow ułatwia administrację instalowanymi programami dzięki instalacji każdego z nich w jego własnym poddrzewie katalogów, które jednak powinno odzwierciedlać strukturę wspólnego katalogu docelowego (/usr/local). Na przykład wspomniany Perl i Emacs mogłyby zostać zainstalowane do katalogów /usr/local/stow/{perl,emacs}, wewnątrz których znajdowałyby się takie katalogi jak bin, man, share itd. Następnie pliki wewnątrz tych katalogów zostałyby zsymlinkowane do /usr/local/{bin,man,share}.

Wiele build systemów domyślnie tworzy poprawne poddrzewa katalogów, możliwe do użycia bez żadnych dodatkowych modyfikacji. Na przykład autotools obsługuje przełącznik --prefix oraz zmienną środowiskową $DESTDIR, które umożliwiają zainstalowanie poszczególnych fragmentów programu w katalogach $DESTDIR$PREFIX/{bin,share,share/man} (zwracam uwagę na brak separatora między zmiennymi $DESTDIR i $PREFIX związany z tym, że prefix jest ścieżką absolutną, domyślnie ustawioną na /usr).

Dokumentacja Stowa sugeruje utworzenie jednego katalogu-repozytorium. Jest to o tyle wygodne, że wystarczy wejść do danego katalogu i wywołać komendę stow <package>, żeby zainstalować daną paczkę w katalogu o poziom wyżej (czyli wewnątrz rodzica bieżącego katalogu). Ta akcja, oprócz tego, że stworzy symlinki dla żądanej paczki, dodatkowo sprawdzi czy inne pliki w modyfikowanych katalogach są zarządzane przez GNU Stow i odpowiednio je zmodyfikuje, jeśli będzie taka potrzeba (GNU Stow stara się tworzyć jak najmniejszą liczbę dowiązań symbolicznych).

Wszystkie akcje Stowa są przy tym domyślnie bezpieczne: program nie dotknie plików, które nie są przez niego zarządzane (czyli zwykłych plików oraz linków symbolicznych wskazujących gdzieś poza repozytorium Stowa).

Aby usunąć daną paczkę (czyli usunąć jej dowiązania symboliczne), należy użyć opcji -D: stow -D <package>. Przeinstalowanie paczki wiąże się natomiast z użyciem opcji -R: stow -R <package>. Proste jak drut.

Wykorzystanie Stowa

Moje repozytorium dotfile’ów jest w dużej mierze repozytorium paczek Stowa, które symlinkuję do katalogu domowego $HOME.

Mój plik config.ini posiada następującą sekcję domyślną:

[DEFAULT]
stow_install = stow -R ${targetname} -t ${var:home}
stow_uninstall = stow -D ${targetname} -t ${var:home}

install_cmd = ${stow_install}
uninstall_cmd = ${stow_uninstall}

Dzięki temu nie muszę w ogóle tworzyć sekcji w pliku konfiguracyjnym dla prostych modułów, które muszą zaledwie zsymlinkować kilka plików - dla wszystkich innych modułów mogę natomiast użyć komend zapisanych pod zmiennymi ${stow_install} i ${stow_uninstall}, które automatycznie będą znały nazwę stow-owanych paczek.


  1. czyt.: używam takiego modułu na co dzień.