Dotfiles
—
- dotfiles, inaccurate, pl
- finished
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:
- 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; - tworzy odpowiednie dowiązania symboliczne przy pomocy GNU Stow;
- 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.
-
czyt.: używam takiego modułu na co dzień. ↩