HashiCorp Packer – automatyzacja budowy obrazów
Jest to pierwszy z serii wpisów dotyczących wykorzystania w praktyce narzędzi firmy HashiCorp. Rozpoczniemy od omówienia jednego z najbardziej dojrzałych produktów firmy. Packer odpowiedzialny jest za automatyzację budowy obrazów maszyn i kontenerów. W dzisiejszym wpisie znajdziecie podstawy składni języka HCL. Zapoznamy się z CLI Packera, pokażę jak konfigurować obraz budowany przez Packera za pomocą szablonów. Na koniec zbudujemy obraz maszyny wirtualnej dla GitHub self-hosted runnera.
Inne wpisy z tej serii dotyczące narzędzi HashiCorp:
- HashiCorp Packer – automatyzacja budowy obrazów maszyn (Jesteś tutaj!)
- HashiCorp Terraform – infrastruktura jako kod
- HashiCorp Vault – centrum zarządzania sekretami
- HashiCorp Consul – networking i zarządzanie configuracją
- HashiCorp Nomad – prosty orkiestrator aplikacji
Ze względu na to, że jest to pierwszy z cyklu wpisów na temat narzędzi HashiCorp zanim przejdę do głównego tematu pozwolę sobie przekazać kilka informacji o samej firmie.
Spis treści
HashiCorp
HashiCorp to dość niewielka firma. W poprzednim roku świętowała dziesięciolecie istnienia na rynku. Przez ten czas zdążyła wydać sporo narzędzi ułatwiających pracę devopsa. W mojej opinii największą zaletą produktów jest to, że każdy z nich ma konkretną rolę do spełnienia. Zakres obowiązków każdego z nich jest ściśle określony dzięki czemu znacznie łatwiej jest rozpocząć pracę z takim oprogramowaniem aniżeli w przypadku narzędzi, które próbują zrobić wszystko. Soft jest niezależny od chmury, a użytkownik jest w stanie zmienić providera w łatwiejszy sposób niż z innymi produktami.
Programy mają modułową architekturę. Tak zwane „core” danego narzędzia ma prostą funkcjonalność, ale możemy bez problemu zwiększać jego możliwości dzięki wtyczkom (plugins). Narzędzia HashiCorp często integrują się między sobą zapewniając dodatkowe korzyści. Zachęcam do przeczytania The Tao of HashiCorp. W tym artykule znajdziecie dużo więcej wartości jakimi kierują się twórcy oprogramowania.
Jeśli jesteście zainteresowani produktami opisywanej marki polecam bibliotekę praktycznych zadań, którą HashiCorp udostępnia. Wyszukiwarka umożliwia wybranie pojedynczych narzędzi lub zestawu, który nas interesuje. Przykłady mają różny poziom zaawansowania.
Wstęp do Packera
Jest to oprogramowanie dzięki, któremu możemu budować obrazy maszyn i obrazy kontenerów za pomocą tzw. Packer Templates. Packer ułatwia automatyzację tego procesu. Narzędzie używa języka HCL do zapisywania konfiguracji, dzięki temu możemy wprowadzić do naszych praktyk definicje obrazów jako kodu.
Przy tworzeniu obrazów maszyn wirtualnych lub obrazów kontenerów Packer wykorzystuje narzędzia dostarczane nam przez różnych dostawców chmurowych, ale nie tylko. Może również używać narzędzi on-premises takich jak np. VMware, Virtualbox, Docker itp. Za pomocą jednego polecenia możemy wyprodukować ten sam obraz maszyny dla różnych dostawców.
Jeśli nie wiecie jakie są różnice między obrazami maszyn, a kontenerów to zapraszam do wpisu Słowniczek – Cloud DevOps-a, w którym znajdziecie odpowiedź na to pytanie.
Proces tworzenia nowego obrazu podzielony jest na kilka etapów. Na poniższym rysunku przedstawiłem przebieg tworzenia obrazu dla chmury AWS.
Zaczynamy od tego, że potrzebujemy szablonu Packera. To nasza receptura, za pomocą której będziemy tworzyć nasz obraz. Następnie Packer rozpoczyna budowę od uruchomienia instancji używającej obrazu źródła. Gdy instancja jest gotowa następuje etap, który najbardziej nas interesuje czyli Provisioning. W tym etapie Packer może wykonywać różne działania na instancji. Może kopiować pliki, ustawiać zmienne środowiskowe, wykonywać skrypty, używać narzędzi do konfiguracji takich jak Ansible, Chef, Salt i inne.
Następnie z tak zmienionej maszyny Packer wykonuję snapshota i rejestruje obraz w Amazon Machine Image (AMI). Gdy AMI jest gotowe instancja jest usuwana i cykl budowy się kończy.
Host, który uruchomimy z użyciem wyprodukowanego przez nas AMI będzie posiadał wszystkie wprowadzone zmiany. Nie będzie potrzeby ponownej instalacji specyficznych dla danej aplikacji bibliotek czy plików.
HashiCorp configuration language (HCL)
Packer podobnie jak inne narzędzia firmy HashiCorp mi.n. Terraform używaja prostego blokowego języka HCL. Poniżej widzimy block-type source. Jest to typ bloku specyficzny dla Packera. Następnie mamy block-label amazon-ebs. Jest to rodzaj źródła obrazu specyficzny dla chmury AWS. Dokumentacja ściśle określa wartości jakie ta etykieta może przyjmować. Następny blok-label może przyjmować dowolną niepowtarzającą się nazwę. Cały ciąg nazw bloków jest wykorzystywany jako wskaźnik. Przykładowo by użyć tego źródła zapiszemy sources = ["source.amazon-ebs.ubuntu"]
source "amazon-ebs" "ubuntu" {
ami_name = "ubuntu-aws-ami"
}
<BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" {
# Block body
<IDENTIFIER> = <EXPRESSION> # Argument
}
Zmienne i dynamiczna interpolacja
Uniwersalnymi blokami języka HCL są variable i locals. Blok variable może przyjmować domyślną wartość lub też nie. Jeśli wartość domyślna nie jest zdefiniowana musimy ją podać w inny sposób w przeciwnym wypadku program zwróci błąd. W celu przypisania zmiennej do danego parametru wystarczy wskazać go za pomocą słowa kluczowego var.<variable_label> np. ssh_username = var.ssh_user
W celu dynamicznego wstrzyknięcia zmiennej do ciągu znaków używamy składni ${var.<variable_label>}
variable "ssh_user" {
type = string
default = "ubuntu"
}
variable "ami_name" {
type = string
}
locals {
timestamp = formatdate("YYYYMMDD", timestamp())
}
source "amazon-ebs" "ubuntu" {
ami_name = "${var.ami_name}-${local.timestamp}"
ssh_username = var.ssh_user
source_ami = "XXXX-1234"
}
build {
sources = ["source.amazon-ebs.ubuntu"]
}
Warto wspomnieć, że konfiguracja nie musi być zapisana w jednym pliku. Na powyższym przykładzie bloki vaiable, locals i inne zostały umieszczone w jednym pliku ponieważ jest ich niewiele. Jednak w większych projektach dobrą praktyką jest podzielone konfiguracji ze względu na typ bloku. Język HCL w momencie jego wykonywania automatycznie łączy wszystkie pliki z danego folderu w jeden.
Te podstawowe wiadomości dotyczące jezyka HCL w zupełności wystarczą do pracy z prostymi szablonami Packera. Pełne omówienie składni HCL zdecydowanie wykracza poza zakres tego wpisu. Jeśli jednak chcecie dowiedzieć się więcej odsyłam do dokumentacji.
Packer CLI
Wiersz poleceń Packer-a nie jest zbyt obszerny. Najczęściej używanymi komendami są: build
, fmt
, validate
, init
Komenda packer init
służy do pobrania zależności lub ich aktualizacji według definicji. Zależności definujemy w bloku packer.
packer {
required_plugins {
happycloud = {
version = ">= 2.7.0"
source = "github.com/hashicorp/happycloud"
}
}
}
Komenda packer fmt
formatuje pliki, przydaje się do utrzymania przejrzystości naszego kodu i utrzymania standardu w zespole. Packer validate
używamy gdy chcemy sprawdzić czy wszystkie zmienne się prawidłowo zinterpolują lub czy nie mamy błędów w składni.
Najważniejsza jest komenda packer build
, której używamy jeśli chcemy by Packer zbudował wszystkie obrazy zapisane w obecnej ścieżce. Możemy też wskazać pojedyńczy plik, który Packer ma zbudować za pomocą packer build <file_name>.pkr.hcl
.
Packer Templates
Wcześniej wspomniałem, że w celu stworzenia nowego obrazu Packer wymaga tzn. szablonów. Szablon składa się z dwóch bloków source i build. Poniżej podaję jeden z najprostrzych bloków konfiguracyjnych, za pomocą których Packer zbuduje nowy obraz AMI.
Tym razem zamiast source_ami używamy atrybutu source_ami_filter, w którym dzięki parametrom filtrujemy obrazy dostępne w danym regionie. AMI id różnią się między regionami więc takie podejście sprawi, że kod będzie bardziej uniwersalny.
source "amazon-ebs" "ubuntu" {
ami_name = "new-ubuntu-ami"
instance_type = "t2.micro"
region = "eu-central-1"
ssh_username = "ubuntu"
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"
architecture = "x86_64"
root-device-type = "ebs"
virtualization-type = "hvm"
}
most_recent = true
owners = ["099720109477"]
}
}
build {
sources = ["source.amazon-ebs.ubuntu"]
}
Pod względem praktycznym powyższy kod jest bezużyteczny. W obrazie nie wprowadzamy żadnych zmian, brakuje bloku provisioner. Po prostu tworzymy instancję i klonujemy konfigurację z jednego AMI do drugiego nic w niej nie zmieniając. Jest to tylko przykład mający na celu zapoznanie ze strukturą szablonów Packera. W dalszej części wpisu znajdziemy dużo bardziej przydatne rozwiązania.
W bloku build definiujemy źródłowe AMI, które posłużą nam jako szablon, na którym będziemy wykonywać następne akcje. Możemy wskazać jedno lub więcej sources
blok. W tym miejscu umieszczamy też blok provisioners
i post-proccesors
.
Provisioner jest jednym z najważniejszych bloków. W nim określamy jak chcemy zmodyfikować nasz obraz. Możemy w nim wykonywać komendy shell, playbooki ansibla, skrypty, PowerShell dla obrazów windowsa i wiele więcej. Poniżej umieszczam kilka przykładów. Szczegóły bloku provisioner w dokumentacji.
provisioner "shell" {
inline = [
"sudo apt-get install -y git",
"ssh-keyscan github.com >> ~/.ssh/known_hosts",
"git clone git@github.com:exampleorg/myprivaterepo.git"
]
max_retries = 5
}
provisioner "shell" {
script = "script.sh"
pause_before = "10s"
timeout = "10s"
}
provisioner "breakpoint" {
disable = false
note = "this is a breakpoint"
}
provisioner "ansible" {
playbook_file = "./playbook.yml"
}
Jak widzimy powyżej w tym bloku możemy też określać dodatkowe pomocnicze parametry takie jak timeout
, pause_before
, max_retries
itd. Na szczególną uwagę zasługuje też provisioner typu breakpoint
. Jeśli go użyjemy to w momencie dojścia do niego Packer zatrzyma wykonywanie instrukcji i zapyta się czy proces budowy ma być kontynuowany. Możemy wykorzystać tę pauzę by np. połączyć się z hostem i sprawdzić czy wszystko jest ok.
Jeśli blok build wykorzystuje kilka źródeł możemy wskazać za pomocą atrybutu only
, które zadania provisioners mają zostać wykonane dla danego obrazu.
build {
sources = [
"source.amazon-ebs.first-example",
"source.amazon-ebs.second-example",
]
provisioner "shell" {
only = ["amazon-ebs.first-example"]
inline = [ "echo provisioning all the things" ]
}
provisioner "shell" {
inline = [ "echo Hi World!" ]
}
}
W jezyku HCL kolejność definiowania bloków nie ma znaczenia jednak w przypadku provisioners jest inaczej. Bloki provisioners będą wykonywanie w kolejności od góry do dołu.
Ostatnim z ważnych elementów bloku build jest post-proccesor. Blok ten wykonuje się gdy nasz obraz jest już gotowy. Możemy w nim wygenerować raport (tzw. manifest) z ukończonej budowy, wykonać akcje na maszynie, z której Packer został uruchomiony – na przykład w celu przejścia do następnego kroku automatyzacji.
post-processor "manifest" {
output = "manifest.json"
strip_path = true
custom_data = {
my_custom_data = "example"
}
}
post-processor "shell-local" {
inline = ["echo foo"]
}
Komunikacja
Domyślnie Packer używa do komunikacji protokołu SSH, dlatego dla obrazów ubuntu nie musieliśmy wcześniej poza nazwą użytkownika podawać protokołu jaki zostanie użyty. W komunikacji z obrazami windows Packer wykorzystuje protokół WinRM. Poniżej przykład definicji obrazu dla windowsa.
source "amazon-ebs" "windows" {
ami_name = "packer-windows-demo"
communicator = "winrm"
instance_type = "t2.micro"
region = "${var.region}"
source_ami_filter {
filters = {
name = "Windows_Server-2012-R2*English-64Bit-Base*"
root-device-type = "ebs"
virtualization-type = "hvm"
}
most_recent = true
owners = ["amazon"]
}
user_data_file = "./bootstrap_win.txt"
winrm_password = "${var.password}"
winrm_username = "Administrator"
}
Poza atrybutem communicator = "winrm"
możemy zauważyć, że dodaliśmy, też user_data_file
, który wskazuje na plik bootstrap_win.txt
. Jest to zestaw instrukcji napisanych w powershell, które zostaną wykonane po inicjalizacji hosta w celu umożliwienia Packerowi nawiązania połączenia. Szczegóły znajdziecie w dokumentacji.
Debugowanie
Packer udostępnia kilka opcji wspomagających debugowanie. Pierwszą z nich jest włączenie szczegółowego poziomu logowania. Wystarczy ustawić zmienną środowiskową PACKER_LOG=1.
Drugą bardzo wygodną opcją jest użycie przy wykonywaniu komendy packer build
flagi -debug
. Gdy użyjemy tej flagi Packer w trakcie wykonywania operacji zapisze w lokalnym folderze klucz pem, którym komunikuje się z maszyną. Przy każdym kroku Packer będzie nas interaktywnie pytać czy ma kontynuować. Ta opcja sprawi, że jeśli na jakimś etapie wystąpi błąd wówczas będziemy mogli się połączyć z hostem i sprawdzić co poszło nie tak. Po zakończonej pracy Packer automatycznie usuwa klucz.
CI/CD Packer przy użyciu GitHub Actions
Pora na zastosowanie zdobytej wiedzy w praktyce. Naszym zadaniem jest napisanie szablonu Packera, który będzie budował self-hosted agenta GitHub-a. Taka instancja zdecydowanie przyda nam się w następnych projektach. W szablonie Packera wykorzystamy plugin provisioner-a ansibla. Sam pipeline stworzymy używając workflow GitHub Actions. Zanim przejdziemy do konkretów należy wspomnieć słów kilka o self-hosted agent i o tym dlaczego dotychczasowe rozwiązanie jest mniej bezpieczne.
GitHub self-hosted agent
Własny agent przyda nam się z kilku powodów. Po pierwsze dzięki takiemu rozwiązaniu będziemy mieli pełną kontrolę nad zainstalowany oprogramowaniem.
Po drugie nasza maszyna może zostać uruchomiona w prywatnej podsieci i nie bedzie widoczna z poziomu publicznego internetu. Wystarczy, że instancja będzie miała dostęp do internetu i serwerów GitHub za pomoca bramy NAT. Agent co parę chwil będzie wykonywał long pull w celu sprawdzenia czy ma do wykonania jakąś pracę.
Takie rozwiązanie sprawi, że nie będziemy musieli podawać w serwisie GitHub żadnych prywatnych kluczy oraz zapisywać sekretów do AWS. Odpowiednie uprawnienia nadamy instancji podpinając właściwe „instance profiles„.
Dodatkowo nie będziemy musieli się przejmować ograniczeniami czasowymi wynikającymi z używania bezpłatnego agenta.
Wróćmy do naszego zadania. Będziemy musieli napisać trzy konfiguracje. Po pierwsze szablon Packera:
variable "ssh_user" {
type = string
default = "ubuntu"
}
variable "gh_runner_config_token" {
type = string
sensitive = true
}
locals {
timestamp = formatdate("YYYYMMDD", timestamp())
}
source "amazon-ebs" "ubuntu" {
ami_name = "GitHub-self-hosted-runner-${local.timestamp}"
instance_type = "t2.micro"
region = "eu-central-1"
ssh_username = var.ssh_user
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"
architecture = "x86_64"
root-device-type = "ebs"
virtualization-type = "hvm"
}
most_recent = true
owners = ["099720109477"]
}
}
build {
sources = ["source.amazon-ebs.ubuntu"]
provisioner "ansible" {
playbook_file = "./ansible/ansible-self-hosted-runner.yaml"
user = var.ssh_user
use_proxy = false
extra_arguments = [
"--extra-vars",
"gh_runner_config_token=${var.gh_runner_config_token}",
]
}
post-processor "manifest" {
output = "manifest.json"
strip_path = true
}
}
Na samej górze konfiguracji mamy dwie zmienne ssh_user
i gh_runner_config_token
. Token będzie nam potrzebny w celu rejestracji instancji self-hosted runner do GitHub-a, atrybut sensitive = true
oznacza, że w logach nie będzie się wyświetlała wartość tej zmiennej.
W naszym wypadku nie ma to wielkiego znaczenia ponieważ token jest ważny tylko przez godzinę i w momencie zarejestrowania instancji traci ważność. A mimo to, że w logach Packera wartość ta będzie ukryta to w logach Ansibla będzie już widoczny jako zwykły string. By obejść problem musielibyśmy użyć ansible vault lub skorzystać z zewnętrznego systemu dostarczającego hasła co jest przerostem formy nad treścią dla tymczasowego tokena.
W bloku source dla atrybutu ami-name
używamy interpolacji by dodać do nazwy AMI datę utworzenia. Pozostała konfiguracja tego bloku określa wielkość instancji użytej do budowy, region w którym obraz będzie tworzony i filtr zastosowany do wyboru AMI źródłowego. Użyjemny najnowszej wersji Ubuntu.
Blok build wykorzystuje plugin ansible do modyfikacji instancji. Atrybuty wskazują jaki playbook ma się wykonać ./ansible/ansible-self-hosted-runner.yaml
określają użytkownika i dodatkowe zmienne, które zostaną przekazane.
Niestety wtyczka ansibla nie jest wspierana przez HashiCorp lecz przez społeczność co sprawia, że zawiera więcej bugów. Miałem problemy z handshake w trakcie wykonywania połączenia do instancji. Musiałem zatem zmienić opcję use_proxy = false
by wyłączyć localhost proxy adapter, który nie potrafił dobrać prawidłowego algorytmu.
Blok post-proccesor zapisze wynik budowy naszego Packer template do folderu manifest.json
Za pomocą playbooka na naszej maszynie zainstalujemy oprogramowanie Packera, Terraform, Ansibla, Docker, NodeJS, AWS CLI i agenta GitHub-a. Warto zauważyć, że do Roli macunha1.github_actions_runner
wstrzykujemy token za pomocą zmiennej gh_runner_config_token
. Role, które używamy w tym przykładzie znajdują się w folderze roles na poziomie, z którego wykonujemy playbooka.
---
- name: GitHub self-hosted runner builder
hosts: all
vars:
- gh_runner_config_token: ''
pre_tasks:
- name: Update_cache
become: true
ansible.builtin.apt:
update_cache: yes
roles:
- role: geerlingguy.pip
become: true
- role: geerlingguy.nodejs
become: true
- role: geerlingguy.ansible
become: true
- role: geerlingguy.docker
become: true
vars:
docker_users:
- ubuntu
- role: geerlingguy.packer
become: true
vars:
packer_version: 1.8.5
- role: diodonfrost.terraform
become: true
vars:
terraform_version: 1.2.8
- role: macunha1.github_actions_runner
vars:
gh_runner_config_name: AWS-self-hosted-runner
gh_runner_config_url: https://github.com/red-devops
gh_runner_config_token: "{{ gh_runner_config_token }}"
gh_runner_version: 2.302.1
gh_runner_config_labels:
- self-hosted
- ubuntu
- AWS
tasks:
- name: Install AWS CLI
shell: |
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
Szablon Packera i playbook ansibla są już gotowe. Musimy jeszcze napisać pipeline, który rozpocznie budowę nowego obrazu. Wykorzystamy do tego pliki workflow GitHub Actions.
name: GitHub self-hosted runner builder
on:
workflow_dispatch:
inputs:
runner_register_token:
description: 'Token for register new GitHub self-hosted runner'
required: true
type: string
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: eu-central-1
jobs:
Build-AWS-self-hosted-runner:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup packer
uses: hashicorp/setup-packer@v2.0.0
with:
version: "1.8.3"
- name: Packer init
run: packer init ./packer-self-hosted-runner.pkr.hcl
- name: Packer fmt
run: packer fmt -check ./packer-self-hosted-runner.pkr.hcl
- name: Packer validate
run: packer validate -var "gh_runner_config_token=${{ inputs.runner_register_token }}" ./packer-self-hosted-runner.pkr.hcl
- name: Packer build
run: packer build -var "gh_runner_config_token=${{ inputs.runner_register_token }}" ./packer-self-hosted-runner.pkr.hcl
- name: Print manifest
run: cat manifest.json
Workflow GitHuba dla tego przypadku wyzwalamy ręcznie. W trakcie odpalania zostaniemy poproszeni o podanie tokenu autoryzacyjnego do rejestracji nowego agenta. Następnie wstrzykujemy z sekretów klucze do AWS i rozpoczynamy potok. Ściągamy pliki z konfiguracją, instalujemy oprogramowanie packera gdyż będziemy potrzebowali jego CLI i wykonujemy podstawowe komendy.
Komenda packer fmt -check
sprawdza, czy plik jest prawidłowo sformatowany. Jeśli tak nie jest wówczas cały pipeline zakończy pracę. Zdecydowanie przydaje się to w celu utrzymania dyscypliny w zespole. Można również dodać bardziej szczegółowe weryfikacje z pomocą narzędzi „lint„, które są dostępne dla większości ww. softów.
W kroku „Packer validate” i „Packer build” przekazujemy token podany na wejścu workflow. Po zakończeniu budowy umieszczamy w logach zawartość pliku manifest.json.
Po wyzwoleniu potoku Packer sam stworzy tymczasowy klucz do komunikacji z maszyną i security group, które zostaną automatycznie usunięte po zakonczeniu pracy.
Budowa zajmuje kilka minut, a ansible wykonuje wszystkie zadania bez większych problemów.
Następnie możemy zauważyć w logach treść manifestu opublikowanego przez Packera. Znajdziemy tam podstawowe informacje o naszym obrazie.
a pomocą komendy aws ec2 describe-images --image-ids ami-xxxxxxx --output table
sprawdzamy czy obraz rzeczywiście został zapisany w archiwum obrazów AWS.
Wygląda na to, że wszystko przebiegło pomyślnie.
To wszystko co na dzisiaj przygotowałem. Przedstawione materiały w zupełności wystarczą do automatyzacji większości zadań związanych z tworzeniem nowym obrazów z wykorzystaniem HashiCorp Packer. Cały kod użyty niniejszym poradniku zamieściłem w repozytorium.
UWAGA NA ZMIANY W REPOZYTORIACH !!!
Proszę zwrócić uwagę na daty commitów, które znajdziecie we wszystkich repozytoriach dostępnych na https://github.com/red-devops. Często korzystam z wcześniejszego kodu w kolejnych wpisach, dostosowując go do bieżących zadań. W związku z tym może się zdarzyć, że kod prezentowany w tym wpisie będzie różnił się od tego, który znajdziecie w najnowszym commicie repozytorium. Niemniej jednak wszystkie główne commity zostawiam, dlatego proszę porównywać daty publikacji wpisu z datami commitów.
Klikając w poniższe linki znajdziecie moje artykuły do innych narzędzi użytych we wpisie:
Ansible – Skuteczne I Proste Konfigurowanie Infrastruktury
Poznajemy GitHub Actions w Praktyce