Projekt 6: LAMP na trzech maszynach z Ansible, wszystko w Git
Po tej lekcji: kursant ma repozytorium Git z inwentarzem, trzema rolami i jednym playbookiem, który na trzech maszynach wirtualnych stawia dwa serwery Apache z PHP i jedną bazę MariaDB, z hasłem schowanym w Ansible Vault, i umie go bezpiecznie uruchamiać raz za razem.
Wstęp
Klient chce sklep. Dwa serwery WWW, żeby jeden mógł paść, a strona dalej działała, i osobna baza danych. Możesz zalogować się na każdą maszynę i przeklikać instalację z poprzednich lekcji. Trzy razy. Bez pomyłki. A za pół roku zrobić to jeszcze raz na nowym sprzęcie i przypomnieć sobie, co właściwie wtedy wpisywałeś.
Albo piszesz to raz, wrzucasz do Gita i od tej pory cała firma ma jedno polecenie:
ansible-playbook site.ymlDziś ostatni projekt do portfolio i, szczerze, ten, który na rozmowie rekrutacyjnej robi największe wrażenie. Stos LAMP, czyli Linux, Apache, MariaDB i PHP, na trzech maszynach, jednym playbookiem, z rolami i hasłem w sejfie. Cały kod jest poniżej, a każdy jego kawałek przetestowałem.
Plan budowy
schemat, laptop z Ansible, strzałki SSH do web1, web2, db1; z web1 i web2 strzałki do db1 na porcie 3306
Trzy maszyny wirtualne z tym samym systemem i siecią host-only:
| Maszyna | Adres | Rola |
|---|---|---|
web1 | 192.168.56.11 | Apache + PHP |
web2 | 192.168.56.12 | Apache + PHP |
db1 | 192.168.56.21 | MariaDB |
Ansible pamiętasz z modułu o automatyzacji: to lista zadań opisująca stan docelowy. Nie mówisz „zainstaluj Apache”, tylko „ma być zainstalowany”. Jeśli już jest, Ansible nic nie robi. Jak dobry kierownik budowy: nie każe stawiać ściany, która już stoi.
Na każdej maszynie potrzebujemy tylko trzech rzeczy: działającego SSH, Pythona (jest w obu rodzinach od razu) i konta z sudo. U mnie konto nazywa się admin. Klucz wgrywamy z laptopa:
for h in 192.168.56.11 192.168.56.12 192.168.56.21; do ssh-copy-id admin@$h; doneKluczyk w kieszeni, kłódka na każdym serwerze. Od tej pory Ansible wchodzi bez pytania o hasło.
Repozytorium: plan biurowca
Tak wygląda cały projekt:
lamp/
├── ansible.cfg
├── site.yml
├── .gitignore
├── inventory/
│ ├── hosts.ini # prawdziwe maszyny
│ └── test.ini # localhost do testów
├── group_vars/
│ ├── all/main.yml # zmienne dla wszystkich
│ ├── all/vault.yml # hasła, zaszyfrowane
│ ├── web/main.yml
│ └── db/main.yml
└── roles/
├── common/tasks/main.yml
├── db/{tasks,handlers,templates,vars}/
└── web/{tasks,handlers,templates,vars}/Ansible ma swoją umowę co do nazw folderów, trochę jak FHS dla Linuksa. Jeśli zadania roli leżą w roles/web/tasks/main.yml, Ansible sam je znajdzie. Nie trzeba mu niczego pokazywać palcem.
ansible.cfg i inwentarz
ansible.cfg w folderze projektu to ustawienia domyślne, żeby nie dopisywać flag za każdym razem:
[defaults]
inventory = inventory/hosts.ini
vault_password_file = .vault_pass
host_key_checking = True
interpreter_python = auto_silent
[privilege_escalation]
become = Truebecome = True znaczy: na maszynach rób wszystko przez sudo. Instalujemy pakiety, więc bez tego daleko nie zajedziemy.
Inwentarz to książka adresowa kuriera. Kto, pod jakim adresem i w jakiej grupie:
[web]
web1 ansible_host=192.168.56.11
web2 ansible_host=192.168.56.12
[db]
db1 ansible_host=192.168.56.21
[lamp:children]
web
db
[lamp:vars]
ansible_user=adminlamp:children to grupa grup: „cały nasz sklep”. Sprawdzamy, czy Ansible rozumie to tak jak my:
ansible-inventory --graph@all:
|--@ungrouped:
|--@lamp:
| |--@web:
| | |--web1
| | |--web2
| |--@db:
| | |--db1I drugi inwentarz, do testów na jednej maszynie, gdzie localhost gra wszystkie role naraz:
# Test na jednej maszynie: localhost gra wszystkie role naraz
[web]
localhost ansible_connection=local
[db]
localhost ansible_connection=local
[lamp:children]
web
dbZmienne i sejf na hasła
Zmienne wspólne dla wszystkich, group_vars/all/main.yml:
---
app_name: sklep
db_name: sklep
db_user: sklep
db_pass: "{{ vault_db_pass }}" # prawdziwe hasło leży zaszyfrowane w vault.yml
firewall_enabled: trueZmienne dla grup. Serwery WWW otwierają w zaporze 80 i 443, baza tylko 3306:
# group_vars/web/main.yml
---
firewall_ports: [80/tcp, 443/tcp]# group_vars/db/main.yml
---
firewall_ports: [3306/tcp]A hasło? Hasło do bazy w Gicie, otwartym tekstem, to jak klucz do mieszkania pod wycieraczką z karteczką „tu jest klucz”. Ansible Vault to sejf: plik jest zaszyfrowany, a Ansible otwiera go w locie, kiedy zna szyfr.
echo 'tajne-haslo-do-sejfu' > .vault_pass
chmod 600 .vault_pass
printf -- '---\nvault_db_pass: "Zm1en-To-Hasl0"\n' > group_vars/all/vault.yml
ansible-vault encrypt group_vars/all/vault.yml
head -2 group_vars/all/vault.ymlEncryption successful
$ANSIBLE_VAULT;1.1;AES256
64336637373033653662393764613938343432326565626362643637363065663761363266353263Tak wygląda sejf od środka, jeśli nie masz szyfru. ansible-vault view i ansible-vault edit otwierają go dla Ciebie. Konwencja z prefiksem vault_ jest prosta: w zwykłym pliku widać, że zmienna db_pass istnieje i skąd bierze wartość, a sama wartość siedzi w sejfie. Plik .vault_pass nigdy nie trafia do Gita:
# .gitignore
.vault_pass
*.retrysite.yml: jeden playbook, trzy akty
---
- name: Wspólne dla wszystkich maszyn
hosts: lamp
roles:
- common
- name: Baza danych
hosts: db
roles:
- db
- name: Serwery WWW
hosts: web
roles:
- webKolejność ma znaczenie. Najpierw wspólne rzeczy wszędzie, potem baza, na końcu WWW, bo strona już przy pierwszym wejściu chce rozmawiać z bazą. Każdy akt dotyczy innej grupy z inwentarza.
Role, czyli segregatory dla stanowisk
Rola to segregator z instrukcjami dla jednego stanowiska w restauracji. Segregator „kucharz”, segregator „kelner”. Nowego kucharza nie uczysz od zera, dajesz mu segregator. Nowy serwer WWW dostaje rolę web.
Rola common, dla wszystkich maszyn:
---
- name: "[Ubuntu] świeży indeks pakietów"
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == 'Debian'
- name: Podstawowe narzędzia
ansible.builtin.package:
name: [curl, vim, tar]
state: present
- name: "[Ubuntu] ufw: najpierw SSH, potem reszta"
community.general.ufw:
rule: allow
port: "{{ item.split('/')[0] }}"
proto: "{{ item.split('/')[1] }}"
loop: "{{ ['22/tcp'] + firewall_ports | default([]) }}"
when: firewall_enabled | bool and ansible_os_family == 'Debian'
- name: "[Ubuntu] ufw włączony"
community.general.ufw:
state: enabled
when: firewall_enabled | bool and ansible_os_family == 'Debian'
- name: "[RHEL] firewalld: otwarte porty"
ansible.posix.firewalld:
port: "{{ item }}"
permanent: true
immediate: true
state: enabled
loop: "{{ firewall_ports | default([]) }}"
when: firewall_enabled | bool and ansible_os_family == 'RedHat'ansible.builtin.package to twój tłumacz: na Ubuntu woła apt, na RHEL dnf. Zapora jest różna, więc tu już rozdzielamy przez when. Kolejności w zadaniach ufw nie zmieniaj: najpierw wpuszczamy SSH, dopiero potem włączamy ochroniarza. Odwrotnie ochroniarz wyrzuca z budynku także Ciebie, razem z Ansible. [RHEL] firewalld po instalacji ma już otwarte SSH w strefie domyślnej, więc tam dokładamy tylko nasze porty.
Rola db. Najpierw różnice między rodzinami, w osobnych plikach:
# roles/db/vars/Debian.yml
---
db_packages: [mariadb-server, python3-pymysql]
db_socket: /run/mysqld/mysqld.sock
db_conf: /etc/mysql/mariadb.conf.d/60-lamp.cnf# roles/db/vars/RedHat.yml
---
db_packages: [mariadb-server, python3-PyMySQL]
db_socket: /var/lib/mysql/mysql.sock
db_conf: /etc/my.cnf.d/lamp.cnfZadania:
---
- name: Zmienne dla tej rodziny systemów
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
- name: MariaDB i biblioteka PyMySQL dla Ansible
ansible.builtin.package:
name: "{{ db_packages }}"
state: present
- name: Baza słucha nie tylko na localhost
ansible.builtin.template:
src: lamp.cnf.j2
dest: "{{ db_conf }}"
mode: "0644"
notify: Restart mariadb
- name: MariaDB działa i wstaje z systemem
ansible.builtin.service:
name: mariadb
state: started
enabled: true
tags: [uslugi]
- name: Baza aplikacji
community.mysql.mysql_db:
name: "{{ db_name }}"
state: present
login_unix_socket: "{{ db_socket }}"
- name: Użytkownik aplikacji dla każdego serwera WWW
community.mysql.mysql_user:
name: "{{ db_user }}"
password: "{{ db_pass }}"
host: "{{ hostvars[item].ansible_host | default('localhost') }}"
priv: "{{ db_name }}.*:ALL"
state: present
login_unix_socket: "{{ db_socket }}"
loop: "{{ groups['web'] }}"
no_log: trueSzablon roles/db/templates/lamp.cnf.j2:
# {{ ansible_managed }}
[mysqld]
bind-address = 0.0.0.0I handler, roles/db/handlers/main.yml:
---
- name: Restart mariadb
ansible.builtin.service:
name: mariadb
state: restarted
tags: [uslugi]Po kolei, co tu się dzieje.
include_vars z ansible_os_family to rozdzielnia: Ansible na starcie zbiera fakty o maszynie, czyli co to za system, ile ma pamięci, jakie adresy. ansible_os_family to Debian dla Ubuntu i RedHat dla RHEL, Rocky i Almy. Wczytujemy plik o tej nazwie i dalej zadania są te same.
[Ubuntu] MariaDB domyślnie słucha tylko na 127.0.0.1, to ustawienie siedzi w 50-server.cnf. Nasz plik ma numer 60, więc jest czytany później i wygrywa. [RHEL] tam domyślna konfiguracja nie ogranicza adresu, ale nasz plik niczemu nie szkodzi, a wszystko jest jawnie zapisane w repozytorium.
notify i handler to „zrób to raz, na końcu, i tylko jeśli coś się zmieniło”. Zmieniłeś konfigurację? Restart. Nie zmieniłeś? Baza działa sobie dalej i nikt jej bez potrzeby nie szarpie.
Użytkownik bazy powstaje osobno dla każdego serwera WWW, z jego adresu: sklep@192.168.56.11 i sklep@192.168.56.12. Klucz do jednego pokoju, wydany na nazwisko i tylko z dwóch konkretnych miejsc. no_log: true sprawia, że hasło nie wyląduje w wyniku na ekranie ani w logach CI.
I wreszcie tags: [uslugi]. Tagi to naklejki na zadaniach. Możesz potem uruchomić tylko zadania z naklejką (--tags uslugi) albo wszystko oprócz nich (--skip-tags uslugi). Za chwilę zobaczysz, do czego mi się to przydało.
Rola web:
# roles/web/vars/Debian.yml
---
web_packages: [apache2, php, libapache2-mod-php, php-mysql]
web_services: [apache2]# roles/web/vars/RedHat.yml
---
web_packages: [httpd, php, php-mysqlnd]
web_services: [httpd, php-fpm]---
- name: Zmienne dla tej rodziny systemów
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
- name: Apache i PHP
ansible.builtin.package:
name: "{{ web_packages }}"
state: present
notify: Restart www
- name: "[RHEL] SELinux: Apache może łączyć się z bazą po sieci"
ansible.posix.seboolean:
name: httpd_can_network_connect_db
state: true
persistent: true
when: ansible_os_family == 'RedHat' and ansible_selinux.status | default('') == 'enabled'
- name: Strona startowa aplikacji
ansible.builtin.template:
src: index.php.j2
dest: /var/www/html/index.php
mode: "0640"
owner: root
group: "{{ 'www-data' if ansible_os_family == 'Debian' else 'apache' }}"
- name: Bez domyślnej strony powitalnej
ansible.builtin.file:
path: /var/www/html/index.html
state: absent
- name: Usługi WWW działają i wstają z systemem
ansible.builtin.service:
name: "{{ item }}"
state: started
enabled: true
loop: "{{ web_services }}"
tags: [uslugi]Handler Restart www wygląda jak ten od bazy, tylko przechodzi pętlą po web_services.
Strona, roles/web/templates/index.php.j2:
<?php
// {{ ansible_managed }}
$db = @new mysqli("{{ hostvars[groups['db'][0]].ansible_host | default('127.0.0.1') }}",
"{{ db_user }}", "{{ db_pass }}", "{{ db_name }}");
$stan = $db->connect_errno ? "BŁĄD: " . $db->connect_error : "OK, MariaDB " . $db->server_info;
?>
<h1>{{ app_name }}</h1>
<p>Serwuje: {{ inventory_hostname }}</p>
<p>Baza danych: <?= htmlspecialchars($stan) ?></p>Szablon Jinja to formularz z lukami. Ansible wpisuje w luki adres pierwszej maszyny z grupy db, nazwę bazy i hasło, a do każdego serwera wpisuje jego własną nazwę. Dzięki temu od razu widać, który serwer Ci odpowiedział. Plik ma uprawnienia 0640 i grupę serwera WWW, bo jest w nim hasło, a inni użytkownicy maszyny nie mają czego tam szukać.
Trzy różnice między rodzinami, które tu siedzą:
[Ubuntu] Apache to apache2, a PHP działa jako moduł w środku Apache (libapache2-mod-php).
[RHEL] Apache to httpd, a PHP domyślnie działa jako osobna usługa php-fpm, do której Apache przekazuje skrypty. Dlatego na liście usług są dwie pozycje.
[RHEL] SELinux domyślnie nie pozwala Apache otwierać połączeń do bazy na innej maszynie. Strona zwraca wtedy „Permission denied”, chociaż hasło jest dobre. Przełącznik httpd_can_network_connect_db to właśnie ta zgoda. To ten sam mechanizm booleanów, który znasz z modułu o SELinux.
Uruchamiamy
Najpierw sprawdzamy, czy to w ogóle jest poprawny YAML i poprawny playbook:
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --list-hosts
ansible lamp -m pingplaybook: site.yml
play #1 (lamp): Wspólne dla wszystkich maszyn TAGS: []
pattern: ['lamp']
hosts (3):
web2
web1
db1A potem na poważnie:
ansible-playbook site.ymlZanim puścisz to na trzy maszyny, możesz sprawdzić całość na jednej, na inwentarzu testowym test.ini, gdzie localhost gra wszystkie trzy role. Tu przydają się naklejki: na maszynie do ćwiczeń, na której usługi uruchamiasz sam, a zapory nie chcesz ruszać, przepuszczasz playbook bez nich:
ansible-playbook -i inventory/test.ini site.yml -e firewall_enabled=false --skip-tags uslugiTASK [db : Baza słucha nie tylko na localhost] *********************************
changed: [localhost]
TASK [db : Baza aplikacji] *****************************************************
ok: [localhost]
TASK [db : Użytkownik aplikacji dla każdego serwera WWW] ***********************
changed: [localhost] => (item=None)
TASK [web : Apache i PHP] ******************************************************
changed: [localhost]
TASK [web : [RHEL] SELinux: Apache może łączyć się z bazą po sieci] ************
skipping: [localhost]
TASK [web : Strona startowa aplikacji] *****************************************
changed: [localhost](item=None) to no_log w akcji: Ansible nie pokazuje nawet, który element pętli przerabiał. Na trzech maszynach wirtualnych uruchamiasz po prostu ansible-playbook site.yml, bez żadnych flag.
Sprawdzenie, czy sklep żyje:
curl -s http://127.0.0.1/<h1>sklep</h1>
<p>Serwuje: localhost</p>
<p>Baza danych: OK, MariaDB 10.11.14-MariaDB-0ubuntu0.24.04.1</p>Na trzech maszynach wirtualnych robisz curl http://192.168.56.11/ i curl http://192.168.56.12/. Dostajesz dwie różne nazwy w „Serwuje” i tę samą bazę w obu odpowiedziach.
A teraz najważniejszy test w całym Ansible. Uruchamiamy to samo drugi raz:
PLAY RECAP *********************************************************************
localhost : ok=14 changed=0 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0changed=0. Wszystko jest już w stanie docelowym, więc nic się nie zmieniło. To się nazywa idempotencja i to jest różnica między playbookiem a skryptem, który po drugim uruchomieniu założyłby drugiego użytkownika, dopisał drugi raz tę samą linijkę do konfiguracji i zrestartował wszystko „na wszelki wypadek”. Jeśli w drugim przebiegu coś ciągle jest changed, masz w playbooku zadanie, które kłamie.
Wszystko do Gita
git init -b main
git add .
git status --short
git commit -m "LAMP: role common, db, web; inwentarz web1/web2/db1; hasło w vault"
git check-ignore -v .vault_pass[main (root-commit) 2a09ac2] LAMP: role common, db, web; inwentarz web1/web2/db1; hasło w vault
.gitignore:1:.vault_pass .vault_passPrzed pierwszym commitem zawsze patrz w git status. Jeśli widać tam .vault_pass, zatrzymaj się. Hasło wypchnięte na GitHuba trzeba uznać za spalone, nawet jeśli skasujesz je minutę później, bo zostaje w historii.
Od teraz każda zmiana na serwerach to commit z opisem. Za pół roku git log powie Ci, kto, kiedy i po co otworzył port 3306. Dokumentacja, która pisze się sama, bo inaczej nic by nie działało.
Jak to działa pod spodem
Ansible nie ma na serwerach żadnego agenta. Dla każdego zadania robi mniej więcej to samo:
- Otwiera połączenie SSH do maszyny, a przy kilku zadaniach pod rząd używa tego samego połączenia.
- Pakuje moduł, na przykład
mysql_user, razem z argumentami w mały program w Pythonie i wysyła go na maszynę. - Uruchamia go tam, przez
sudo, bo mamybecome. - Moduł sprawdza stan, w razie potrzeby go zmienia i odsyła JSON:
changedalbo nie,failedalbo nie. - Ansible sprząta po sobie pliki tymczasowe i bierze następne zadanie.
Z tego wynikają dwie praktyczne rzeczy. Po pierwsze, moduł działa na maszynie docelowej, więc biblioteki, których potrzebuje, muszą być tam, a nie na Twoim laptopie. Stąd python3-pymysql w roli db. Po drugie, „zebranie faktów” na początku każdego aktu to osobny moduł setup, który sprawdza system i wypełnia zmienne ansible_os_family, ansible_selinux i setki innych.
Domyślnie Ansible pracuje na pięciu maszynach równolegle. Zadanie numer jeden leci na wszystkich, potem zadanie numer dwa na wszystkich i tak dalej. Jak kelner, który najpierw roznosi zupy na wszystkie stoliki, a dopiero potem drugie danie.
Ściąga
| Komenda | Flaga | Co robi |
|---|---|---|
ansible-playbook | --syntax-check | sprawdza składnię, niczego nie uruchamia |
ansible-playbook | --check --diff | próba na sucho: co by się zmieniło i jak |
ansible-playbook | --list-hosts / --list-tasks | na jakich maszynach i jakie zadania |
ansible-playbook | -l web1 | tylko wybrane maszyny |
ansible-playbook | --tags X / --skip-tags X | tylko zadania z naklejką / bez niej |
ansible-playbook | -e zmienna=wartość | nadpisuje zmienną z linii poleceń |
ansible-playbook | --start-at-task "nazwa" | zaczyna od wskazanego zadania |
ansible | GRUPA -m ping | czy Ansible dosięga maszyn |
ansible-inventory | --graph | drzewo grup i maszyn |
ansible-vault | encrypt / view / edit | szyfruje / pokazuje / edytuje sejf |
ansible-vault | encrypt_string | szyfruje pojedynczą wartość do wklejenia w YAML |
⚠ Typowy błąd
Pierwsza wersja roli db nie miała python3-pymysql na liście pakietów. Wynik:
fatal: [db1]: FAILED! => {
"changed": false,
"msg": "A MySQL module is required: for Python 2.7 either PyMySQL, or MySQL-python, or for Python 3.X mysqlclient or PyMySQL. Consider setting ansible_python_interpreter to use the intended Python version."
}To bardzo częsty widok. Ludzie instalują wtedy PyMySQL na swoim laptopie przez pip, uruchamiają ponownie i… nic. Bo moduł mysql_db nie działa na laptopie. Jest wysyłany na db1 i tam uruchamiany przez tamtejszego Pythona. Biblioteka musi być na serwerze: [Ubuntu] python3-pymysql, [RHEL] python3-PyMySQL. Dlatego w roli instalujemy ją w tym samym zadaniu co MariaDB, zanim pierwszy raz dotkniemy bazy.
I mały bonus z tej samej rodziny. Na serwerze, na którym ktoś wcześniej „tylko zainstalował nowszego Pythona”, polecenie python3 może wskazywać na innego Pythona niż systemowy i Ansible nie zobaczy bibliotek zainstalowanych z pakietów. Końcówka komunikatu, „Consider setting ansible_python_interpreter”, mówi dokładnie o tym. Pomaga wskazanie systemowego Pythona, na Ubuntu 24.04 na przykład -e ansible_python_interpreter=/usr/bin/python3.12. Na czystej maszynie wirtualnej tego problemu nie będzie.
✎ Zadanie
Dodaj do repozytorium rolę admini, która na wszystkich trzech maszynach zakłada konta z listy w group_vars/all/main.yml (na przykład anna i piotr), wgrywa im klucze SSH i daje im sudo przez plik w /etc/sudoers.d, sprawdzany przed zapisem przez visudo. Zrób commit i uruchom playbook dwa razy: za drugim razem ma być changed=0.
Pokaż rozwiązanie
Zadanie było takie: rola admini, która na wszystkich trzech maszynach zakłada konta z listy, wgrywa klucze SSH i daje sudo. Za drugim uruchomieniem ma być changed=0.
Najpierw lista ludzi. Trzymamy ją w zmiennych, a nie w roli, bo ludzie przychodzą i odchodzą, a rola zostaje:
# group_vars/all/main.yml (dopisane na końcu)
admini:
- name: anna
klucz: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOPY4jAQA/eUKlBSNRapNBQUOh2jeYXNo+WUDGezx354 anna@laptop"
- name: piotr
klucz: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDsh6YSOG+MEiJkU/ZD3BLqbgv6HPFENlU+lMJgsWWKo piotr@laptop"Klucz publiczny to kłódka, więc spokojnie może leżeć w repozytorium. Kluczyk, czyli klucz prywatny, zostaje w kieszeni Anny. Gdyby ktoś chciał wrzucić do Gita klucz prywatny, to tak, jakby wywiesić na drzwiach biura kopię klucza z karteczką „żeby nikt nie musiał dzwonić”.
Teraz sama rola:
# roles/admini/tasks/main.yml
---
- name: Grupa adminów
ansible.builtin.group:
name: admini
state: present
- name: Konta adminów
ansible.builtin.user:
name: "{{ item.name }}"
groups: admini
append: true
shell: /bin/bash
create_home: true
loop: "{{ admini }}"
- name: Klucze SSH adminów
ansible.posix.authorized_key:
user: "{{ item.name }}"
key: "{{ item.klucz }}"
exclusive: true
loop: "{{ admini }}"
- name: Sudo dla grupy admini (sprawdzane przez visudo przed zapisem)
ansible.builtin.template:
src: admini.sudoers.j2
dest: /etc/sudoers.d/admini
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s# roles/admini/templates/admini.sudoers.j2
# {{ ansible_managed }}
%admini ALL=(ALL) ALLTrzy rzeczy, na które warto zwrócić uwagę. append: true w module user: bez tego Ansible ustawi listę grup na dokładnie „admini” i wyrzuci człowieka z wszystkich innych grup, do których należał. Jak przy przeprowadzce: chciałeś dopisać nazwisko na domofonie, a ktoś zdjął wszystkie pozostałe. exclusive: true w kluczach znaczy „w authorized_keys ma być tylko to, co jest w repozytorium”, więc stary klucz, który ktoś dopisał ręcznie trzy lata temu, zniknie. I najważniejsze: validate w szablonie. Ansible najpierw zapisuje plik tymczasowy, puszcza na nim visudo -cf, i dopiero jak visudo powie „parsed OK”, podmienia plik. Literówka w sudoers bez tej linijki to klasyczny sposób, żeby jednym commitem odebrać sudo wszystkim adminom na wszystkich serwerach naraz.
W site.yml rola dochodzi do pierwszego aktu, tego dla wszystkich maszyn:
- name: Wspólne dla wszystkich maszyn
hosts: lamp
roles:
- common
- adminiCommit i dwa uruchomienia. Testowałem rolę lokalnie, stąd localhost zamiast web1:
git add group_vars/all/main.yml roles/admini site.yml
git commit -m "Rola admini: konta, klucze SSH i sudo dla grupy admini"
ansible-playbook site.yml
ansible-playbook site.ymllocalhost : ok=5 changed=2 unreachable=0 failed=0 skipped=0 ...
localhost : ok=5 changed=0 unreachable=0 failed=0 skipped=0 ...Za drugim razem zero zmian. Lista zadań opisuje stan docelowy, a stan już jest docelowy, więc Ansible tylko kiwa głową. I sprawdzenie na maszynie:
sudo -l -U annaUser anna may run the following commands on vm:
(ALL) ALLCo dalej
Koniec projektów, czas na sprawdzian. W następnej lekcji egzamin próbny w stylu RHCSA i LFCS: kilkanaście zadań, zegar, zero podpowiedzi. A potem rozwiązania krok po kroku.