Na co dzień korzystam z DevPod, które to znowu korzysta z Development Containers. Działa to mniej więcej tak, że definiuję plik devcontainer, który zawiera obraz lub Dockerfile i uruchamia powtarzalne środowisko.
Dzięki temu mogę korzystać np. z różnych wersji PHP w obrębie jednego systemu. Działa to świetnie ale jest jeden, mały problem.
Każdy nowy projekt lub eksperyment zaczyna się od tej samej, nudnej sekwencji w terminalu. Mniej więcej coś takiego:
# 1. Stworzenie katalogu
mkdir my-app
# 2. Przejście do niego
cd my-app
# 3. Pobranie szablonu projektu (boilerplate)
git clone boilerplate-repo.
# 4. Usunięcie katalogu git powiązanego z boilerplate
rm -rf .git
# 5. Postawienie kontenera deweloperskiego z własnymi dotfiles
devpod up . --dotfiles your-dotfiles-repo
Punkt 5 można usprawnić definiując zmienną w samym DevPod:
devpod context set-options -o DOTFILES_URL=your-dotfiles
ale dalej mam do wpisania 5 komend.
Wykonanie tego raz czy dwa razy w tygodniu nie stanowi problemu. Jednak gdy mam to pisać 3 razy dziennie, jest to po prostu strata czasu, która może się zwiększyć przez np. literówki.
Pokusiłem się o napisanie skryptu, który z 5 poleceń zrobiłby po prostu jedno bo dlaczego nie? Wyszło coś takiego:
#!/usr/bin/env bash
set -euo pipefail
dotfiles=""
project_name=""
path_to_project_dir="${DEVPOD_PROJECTS_DIR:-}"
project_boilerplate="${DEVPOD_REPO_BOILERPLATE:-}"
project_path=""
usage() {
local exit_code="${1:-1}"
cat << EOF
Usage: $0 [-d <repo_personal_dotfiles>] <project_name> [path_to_project_parent_dir] [repo_boilerplate_project]
Options:
-d URL (Optional) Repo with your personal dotfiles
-h Show help
Environment variables:
DEVPOD_PROJECTS_DIR - absolute path for your projects directory
DEVPOD_REPO_BOILERPLATE - URL for projects boilerplate REPO
EOF
exit "$exit_code"
}
parse_args() {
while getopts "d:h" o; do
case "${o}" in
d)
dotfiles=$OPTARG ;;
h)
usage 0 ;;
*)
usage 1 ;;
esac
done
shift "$((OPTIND - 1))"
project_name="${1:-}"
[ -n "${2:-}" ] && path_to_project_dir="$2"
project_path="${path_to_project_dir}/${project_name}"
[ -n "${3:-}" ] && project_boilerplate="$3"
}
check_dependencies() {
for cmd in git devpod; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: Required command '$cmd' is not installed." >&2
exit 1
fi
done
}
validate_data() {
check_dependencies
if [ -z "$project_name" ]; then
echo "Error: missing project name argument." >&2
usage 1
fi
if [ -z "$path_to_project_dir" ]; then
echo "Error: missing path to projects directory." >&2
usage 1
fi
if [[ ! "$path_to_project_dir" =~ ^/ ]]; then
echo "Error: Path '$path_to_project_dir' needs to be an absolute path." >&2
usage 1
fi
if [ -d "$project_path" ]; then
echo "Error: Target directory '$project_path' already exists." >&2
exit 1
fi
if [ -z "$project_boilerplate" ]; then
echo "Error: missing boilerplate repo argument." >&2
usage 1
fi
local regex_git_repo='^((https?|git|ssh|file)://|git@[[:alnum:]._-]+:|/).+$'
if [[ ! "$project_boilerplate" =~ $regex_git_repo ]]; then
echo "Error: $project_boilerplate does not look like a valid git repo." >&2
usage 1
fi
if [ -n "$dotfiles" ]; then
if [[ ! "$dotfiles" =~ $regex_git_repo ]]; then
echo "Error: $dotfiles does not look like a valid git repo." >&2
usage 1
fi
fi
}
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ] && [ -n "${project_path:-}" ] && [ -d "$project_path" ]; then
echo -e "\n==> Cleaning up incomplete project directory: $project_path" >&2
rm -rf "$project_path"
fi
}
main () {
trap cleanup EXIT INT TERM
echo "==> Creating project directory: $project_path"
mkdir -p "$project_path"
echo "==> Cloning repo..."
git clone "$project_boilerplate" "$project_path"
echo "==> Removing .git directory from repo directory..."
rm -rf "${project_path}/.git"
echo "==> Starting DevPod..."
local devpod_args=("$project_path")
if [ -n "$dotfiles" ]; then
devpod_args+=(--dotfiles "$dotfiles")
fi
devpod up "${devpod_args[@]}"
echo "==> Done. Now you can run: devpod ssh ${project_name}"
}
[ $# -eq 0 ] && usage 1
parse_args "$@"
validate_data
main
Najmniej optymalnie:
./devpod-run.sh -d [email protected]:DawidFerchow/dotfiles.git new-project /home/dawid/Projects/ [email protected]:DawidFerchow/boilerplate.git
ale idąc po nitce do kłębka.
Zamiast dodawać za każdym razem moje dotfiles, możemy je zdefiniować bezpośrednio w DevPod:
devpod context set-options -o DOTFILES_URL[email protected]:DawidFerchow/dotfiles.git
Więc na chwilę obecną mamy:
./devpod-run.sh new-project /home/dawid/Projects/ [email protected]:DawidFerchow/boilerplate.git
Nadal za dużo :D
No więc, w skrypcie używam dwóch globalnych zmiennych. Folder, w którym projekt ma się utworzyć oraz link do repo, który jest boilerplate’m projektu.
Jeżeli je eksportuje np. w ~/.bashrc, w ten sposób:
export DEVPOD_PROJECTS_DIR="/home/dawid/projects"
export DEVPOD_REPO_BOILERPLATE="https://github.com/DawidFerchow/boilerplate.git"
Teraz do postawienia nowego projektu wystarczy:
./devpod-run.sh new-project
A jeśli w konkretnym projekcie chcę przekazać swoje dotfiles:
./devpod-run.sh -d [email protected]:mischavandenburg/devpod-dotfiles my-new-api
Został tylko jeden problem. Muszę być w folderze, który zawiera skrypt devpod-run.sh, ale na to też jest sposób.
Sam skrypt można przenieść w kilka miejsc, natomiast na moim komputerze tylko ja będe z niego korzystał, więc:
mv devpod-run.sh ~/.local/bin/devpod-run
oraz nadałem uprawnienia do wykonywania:
chmod u+x devpod-run
teraz z dowolnej lokalizacji mogę postawić sobie nowe środowisko, wywołując:
devpod-run new-project
Fajnie? Jeszcze jak.
No dobra, to teraz trochę technikaliów i elementów, którymi chciałbym się pochawlić. Nie są one potrzebne do samego działania skryptu, ale tym właśnie cechują się dobre praktyki :)
Na początku skrypt sprawdza, czy wymagane zależności są dostępne:
check_dependencies() {
for cmd in git devpod; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: Required command '$cmd' is not installed." >&2
exit 1
fi
done
}
Nie ma sensu zaczynać, jeżeli od początku wiadomo, że brakuje Gita albo Devpoda. Następnie sprawdzane są argumenty, ścieżka docelowa, repozytorium oraz to, czy katalog projektu już istnieje.
A to wszystko dzieje się zanim skrypt zacznie cokolwiek modyfikować.
Ścieżka do projektów oraz domyślny boilerplate nie są wpisane bezpośrednio w kodzie. Zamiast tego skrypt korzysta z:
DEVPOD_PROJECTS_DIR
DEVPOD_REPO_BOILERPLATE
Dzięki temu nie muszę zmieniać skryptu, gdy zmieni się lokalizacja moich projektów albo repozytorium boilerplate.
Przed uruchomieniem git clone sprawdzam również, czy przekazane repozytorium wygląda jak adres repozytorium Git:
local regex_git_repo='^((https?|git|ssh|file)://|git@[[:alnum:]._-]+:|/).+$'
Regex pozwala jednak odrzucić oczywiście niepoprawne wartości jeszcze przed rozpoczęciem operacji. Podobnie wymuszam, żeby ścieżka do katalogu projektów była absolutna:
if [[ ! "$path_to_project_dir" =~ ^/ ]]; then
echo "Error: Path '$path_to_project_dir' needs to be an absolute path." >&2
usage 1
fi
Nie do końca, ale mechanizm może być podobny. Problem pojawia się, gdy zaczynamy składać polecenie jako jeden string i później pozwalamy Bashowi go ponownie zinterpretować, np. przez eval.
Dlatego zamiast:
command="devpod up $project_path --dotfiles $dotfiles"
eval "$command"
użyłem tablicy:
local devpod_args=("$project_path")
if [ -n "$dotfiles" ]; then
devpod_args+=(--dotfiles "$dotfiles")
fi
devpod up "${devpod_args[@]}"
W tym przypadku każdy element tablicy jest osobnym argumentem. Bash nie musi więc zgadywać, gdzie kończy się jeden argument, a zaczyna kolejny.
Na początku skryptu znajduje się tajemnicza linijka kodu:
set -euo pipefail
Jest to nic innego jak Bash Strict Mode.
W skrócie:
-e - skrypt kończy działanie po błędzie,-u - użycie niezdefiniowanej zmiennej powoduje błąd,pipefail - błąd w dowolnym elemencie pipeline’u powoduje jego niepowodzenie.Nie rozwiązuje to wszystkich problemów, ale daje mi trochę większą kontrolę nad zachowaniem skryptu.
Komunikaty diagnostyczne i błędy kieruję na stderr:
echo "Error: Required command '$cmd' is not installed." >&2
Może to mieć znaczenie, jeśli kiedyś będę chciał wykorzystać ten skrypt w innym skrypcie albo pipeline.
Ciekawy jest też trap
trap cleanup EXIT INT TERM
Jeżeli coś pójdzie nie tak, na przykład straci połączenie sieciowe podczas git clone albo przerwę operację za pomocą Ctrl+C - skrypt może posprzątać po sobie utworzony katalog.
Służy do tego:
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ] && [ -n "${project_path:-}" ] && [ -d "$project_path" ]; then
echo -e "\n==> Cleaning up incomplete project directory: $project_path" >&2
rm -rf "$project_path"
fi
}
Jeżeli skrypt się wywali, nie zostanie mi więc pusty albo częściowo sklonowany katalog.
Na ten moment skrypt robi dokładnie to, czego od niego chciałem. Ale pomysłów na rozwój już trochę mam.
git initObecnie po sklonowaniu boilerplate usuwam jego katalog .git:
rm -rf "${project_path}/.git"
Ma to sens, ponieważ nie chcę, żeby nowy projekt był powiązany z repozytorium boilerplate. To wydaje mi się sensowną zmianą i prawdopodobnie trafi do kolejnej wersji skryptu.
Nie jestem natomiast przekonany do automatycznego tworzenia pierwszego commita. Samo git init nie podejmuje za mnie decyzji o tym, kiedy projekt jest gotowy do pierwszego commita.
Kusi mnie też integracja z GitHub CLI.
Na przykład możliwość wykonania:
devpod-run my-new-project --github
która automatycznie wykonałaby:
gh repo create
i utworzyła zdalne repozytorium.
Nie każdy eksperyment, test czy projekt musi od razu trafiać na GitHuba. Czasami chcę po prostu odpalić środowisko, coś sprawdzić i po godzinie usunąć cały katalog.
Skrypt jest stosunkowo prosty, ale pojawia się już kilka elementów, które warto przetestować:
git lub devpod,--dotfiles,DEVPOD_*,git clone,Ctrl+C.Do napisania są więc zarówno testy jednostkowe, jak i integracyjne. Może to będzie kolejny wpis na bloga, kto wie :p
Nie jest to żadne przełomowe narzędzie, które zmieni bieg historii. To tylko kilkadziesiąt linii Basha, które rozwiązują bardzo mały, ale powtarzalny problem z mojego codziennego workflow.
Pięć komend samo w sobie nie jest żadnym problemem. Ale jeżeli wykonujesz je kilka razy dziennie, zaczynają się pojawiać literówki, pomyłki i zwyczajna strata czasu.
Ostatecznie cały proces, który wcześniej wymagał:
mkdir
cd
git clone
rm -rf .git
devpod up
teraz wygląda tak:
devpod-run my-new-project
A skoro coś robię regularnie i za każdym razem dokładnie w ten sam sposób, to chyba jest to całkiem dobry kandydat do automatyzacji.
Bo po co wpisywać pięć komend, skoro można wpisać jedną