Podstawy Ansible, bez których Twoje podręczniki będą kawałkiem lepkiego makaronu

Robię wiele recenzji kodu Ansible innych osób i dużo piszę. Analizując błędy (zarówno cudze, jak i własne), a także szereg wywiadów, zdałem sobie sprawę, jaki jest główny błąd, jaki popełniają użytkownicy Ansible – zagłębiają się w skomplikowane rzeczy bez opanowania podstawowych.

Aby naprawić tę powszechną niesprawiedliwość, postanowiłem napisać wprowadzenie do Ansible dla tych, którzy już to wiedzą. Ostrzegam, to nie jest opowieść o mężczyznach, to długa lektura z dużą ilością listów i bez zdjęć.

Oczekiwany poziom czytelnika jest taki, że napisano już kilka tysięcy linijek yamli, coś jest już w produkcji, ale „jakoś wszystko jest krzywe”.

Imiona

Głównym błędem popełnianym przez użytkownika Ansible jest niewiedza, jak coś się nazywa. Jeśli nie znasz nazw, nie możesz zrozumieć, co mówi dokumentacja. Żywy przykład: w trakcie wywiadu osoba, która zdawała się twierdzić, że dużo napisała w Ansible, nie potrafiła odpowiedzieć na pytanie „z jakich elementów składa się playbook?” A kiedy zasugerowałem, że „oczekiwano odpowiedzi, że podręcznik składa się z zabawy”, nastąpił potępiający komentarz „nie używamy tego”. Ludzie piszą Ansible dla pieniędzy i nie korzystają z gier. Rzeczywiście tego używają, ale nie wiedzą, co to jest.

Zacznijmy więc od czegoś prostego: jak to się nazywa. Może to wiesz, a może nie, ponieważ nie zwróciłeś uwagi podczas czytania dokumentacji.

ansible-playbook wykonuje podręcznik. Playbook to plik z rozszerzeniem yml/yaml, wewnątrz którego znajduje się coś takiego:

---
- hosts: group1
  roles:
    - role1

- hosts: group2,group3
  tasks:
    - debug:

Już zdaliśmy sobie sprawę, że cały ten plik to podręcznik. Możemy pokazać, gdzie są role i gdzie są zadania. Ale gdzie jest zabawa? Jaka jest różnica między zabawą a rolą lub podręcznikiem?

Wszystko jest w dokumentacji. I im tego brakuje. Początkujący - bo jest tego za dużo i nie zapamiętasz wszystkiego na raz. Doświadczony - bo „rzeczy trywialne”. Jeśli masz doświadczenie, czytaj te strony ponownie przynajmniej raz na sześć miesięcy, a Twój kod stanie się wiodącym w swojej klasie.

Zatem pamiętaj: Poradnik to lista składająca się z zabaw i import_playbook.
To jest jedna sztuka:

- hosts: group1
  roles:
    - role1

a to także kolejna sztuka:

- hosts: group2,group3
  tasks:
    - debug:

Czym jest zabawa? Dlaczego ona jest?

Zabawa jest kluczowym elementem podręcznika, ponieważ gra i tylko zabawa wiąże listę ról i/lub zadań z listą hostów, na których należy je wykonać. W głębi dokumentacji można znaleźć wzmiankę o delegate_to, wtyczki wyszukiwania lokalnego, ustawienia specyficzne dla sieci CLI, hosty skoku itp. Pozwalają na nieznaczną zmianę miejsca wykonywania zadań. Ale zapomnij o tym. Każda z tych sprytnych opcji ma bardzo specyficzne zastosowania i zdecydowanie nie są uniwersalne. A mowa tu o podstawowych rzeczach, które każdy powinien znać i stosować.

Jeśli chcesz „coś” „gdzieś” zagrać, piszesz sztukę. Nie rola. Nie jest to rola z modułami i delegatami. Bierzesz to i piszesz grę. W którym w polu hosts podajesz, gdzie wykonać, a w rolach/zadaniach - co wykonać.

Proste, prawda? Jak mogłoby być inaczej?

Jednym z charakterystycznych momentów, kiedy ludzie pragną tego dokonać nie poprzez zabawę, jest „rola, która wszystko ustala”. Chciałbym mieć rolę, która konfiguruje zarówno serwery pierwszego typu, jak i serwery drugiego typu.

Archetypowym przykładem jest monitorowanie. Chciałbym mieć rolę monitorującą, która będzie konfigurować monitorowanie. Rola monitorująca jest przypisana do hostów monitorujących (w zależności od gry). Okazuje się jednak, że do monitorowania musimy dostarczyć pakiety do hostów, które monitorujemy. Dlaczego nie skorzystać z delegata? Musisz także skonfigurować iptables. delegat? Musisz także napisać/poprawić konfigurację systemu DBMS, aby umożliwić monitorowanie. delegat! A jeśli brakuje kreatywności, możesz dokonać delegacji include_role w zagnieżdżonej pętli przy użyciu skomplikowanego filtra na liście grup i wewnątrz include_role możesz zrobić więcej delegate_to Ponownie. I odchodzimy...

Dobre życzenie – mieć jedną rolę monitorującą, która „robi wszystko” – prowadzi nas w kompletne piekło, z którego najczęściej jest tylko jedno wyjście: napisać wszystko od nowa.

Gdzie tu nastąpił błąd? W chwili, gdy odkryłeś, że aby wykonać zadanie „x” na hoście X, musisz udać się do hosta Y i tam wykonać „y”, musiałeś wykonać proste ćwiczenie: iść i napisać grę, co na hoście Y robi y. Nie dodawaj niczego do „x”, ale napisz to od zera. Nawet ze zmiennymi zakodowanymi na stałe.

Wydaje się, że wszystko w powyższych akapitach zostało powiedziane poprawnie. Ale to nie jest Twój przypadek! Ponieważ chcesz napisać kod wielokrotnego użytku, który jest SUCHY i podobny do biblioteki, i musisz poszukać metody, jak to zrobić.

W tym miejscu czai się kolejny poważny błąd. Błąd, który sprawił, że wiele projektów ze znośnie napisanych (mogło być lepiej, ale wszystko działa i łatwo się kończy) zamieniło się w kompletny horror, którego nawet autor nie jest w stanie pojąć. To działa, ale nie daj Boże, żebyś cokolwiek zmieniał.

Błąd brzmi: rola jest funkcją biblioteczną. Ta analogia zrujnowała tak wiele dobrych początków, że po prostu przykro jest na to patrzeć. Rola nie jest funkcją biblioteczną. Nie potrafi wykonywać obliczeń ani podejmować decyzji na poziomie gry. Przypomnij mi, jakie decyzje podejmuje gra?

Dziękuję, masz rację. Play podejmuje decyzję (dokładniej zawiera informacje), jakie zadania i role pełnić na jakich hostach.

Jeśli oddelegujesz tę decyzję do roli, a nawet obliczeń, skazujesz siebie (i tego, kto będzie próbował analizować Twój kod) na nędzną egzystencję. Rola nie decyduje o tym, gdzie jest pełniona. Decyzję tę podejmuje się na podstawie gry. Rola robi to, co jej się każe i tam, gdzie jej się każe.

Dlaczego programowanie w Ansible jest niebezpieczne i dlaczego COBOL jest lepszy od Ansible, porozmawiamy w rozdziale o zmiennych i jinja. Na razie powiedzmy jedno – każde Twoje wyliczenie pozostawia po sobie niezatarty ślad zmian zmiennych globalnych, z którym nic nie możesz zrobić. Gdy tylko te dwa „ślady” się przecięły, wszystko zniknęło.

Uwaga dla wrażliwych: rola z pewnością może mieć wpływ na przepływ kontroli. Jeść delegate_to i ma rozsądne zastosowania. Jeść meta: end host/play. Ale! Pamiętasz, że uczymy podstaw? Zapomniałem o delegate_to. Mówimy o najprostszym i najpiękniejszym kodzie Ansible. Który jest łatwy do odczytania, łatwy do napisania, łatwy do debugowania, łatwy do przetestowania i łatwy do wykonania. Zatem jeszcze raz:

play i tylko gra decyduje o tym, który host będzie wykonywany.

W tej części zajmowaliśmy się opozycją pomiędzy zabawą a rolą. Porozmawiajmy teraz o relacji zadania vs rola.

Zadania i role

Rozważ zabawę:

- hosts: somegroup
  pre_tasks:
    - some_tasks1:
  roles:
     - role1
     - role2
  post_tasks:
     - some_task2:
     - some_task3:

Powiedzmy, że musisz zrobić foo. I to wygląda foo: name=foobar state=present. Gdzie mam to napisać? w przed? post? Utworzyć rolę?

...A gdzie podziały się zadania?

Zaczynamy znowu od podstaw – urządzenia odtwarzającego. Jeśli nie będziesz się skupiał na tej kwestii, nie możesz używać gry jako podstawy do wszystkiego innego, a twój wynik będzie „chwiejny”.

Urządzenie do odtwarzania: dyrektywa hostów, ustawienia samej gry i zadań wstępnych, zadania, role, sekcje post_zadania. Pozostałe parametry gry nie są już dla nas istotne.

Kolejność ich sekcji z zadaniami i rolami: pre_tasks, roles, tasks, post_tasks. Ponieważ semantycznie kolejność wykonania jest pomiędzy tasks и roles nie jest jasne, to najlepsze praktyki mówią, że dodajemy sekcję tasks, tylko jeśli nie roles... Jeśli jest roles, wówczas wszystkie dołączone zadania są umieszczane w sekcjach pre_tasks/post_tasks.

Pozostaje tylko, że wszystko jest semantycznie jasne: po pierwsze pre_tasks, a następnie roles, a następnie post_tasks.

Ale nadal nie odpowiedzieliśmy na pytanie: gdzie jest wywołanie modułu? foo pisać? Czy musimy pisać całą rolę dla każdego modułu? A może lepiej mieć grubą rolę do wszystkiego? A jeśli nie rola, to gdzie mam to napisać – w pre czy post?

Jeśli na te pytania nie ma uzasadnionej odpowiedzi, jest to oznaką braku intuicji, czyli tych samych „chwiejnych podstaw”. Rozwiążmy to. Najpierw pytanie zabezpieczające: jeśli gra tak pre_tasks и post_tasks (i nie ma żadnych zadań ani ról), to czy coś może się zepsuć, jeśli wykonam pierwsze zadanie z post_tasks Przeniosę to na koniec pre_tasks?

Oczywiście sformułowanie pytania wskazuje, że się zepsuje. Ale co dokładnie?

... Opiekunowie. Lektura podstaw ujawnia ważny fakt: wszystkie programy obsługi są automatycznie opróżniane po każdej sekcji. Te. wszystkie zadania z pre_tasks, a następnie wszystkie osoby zajmujące się obsługą, które zostały powiadomione. Następnie wykonywane są wszystkie role i wszystkie procedury obsługi, które zostały powiadomione w rolach. Po post_tasks i ich opiekunowie.

Zatem, jeśli przeciągniesz zadanie z post_tasks в pre_tasks, to potencjalnie wykonasz go przed wykonaniem procedury obsługi. na przykład, jeśli w pre_tasks serwer WWW jest zainstalowany i skonfigurowany, oraz post_tasks coś jest do niego wysyłane, a następnie przenieś to zadanie do sekcji pre_tasks doprowadzi do tego, że w momencie „wysyłania” serwer nie będzie jeszcze uruchomiony i wszystko się zepsuje.

Teraz zastanówmy się jeszcze raz, dlaczego potrzebujemy pre_tasks и post_tasks? Na przykład, aby zakończyć wszystko, co niezbędne (w tym osoby obsługujące) przed wypełnieniem roli. A post_tasks pozwoli nam pracować z wynikami wykonywania ról (w tym handlerów).

Wnikliwy ekspert Ansible powie nam, co to jest. meta: flush_handlers, ale po co nam obsługi koloru, jeśli możemy polegać na kolejności wykonywania sekcji w grze? Co więcej, użycie meta: Flush_handlers może dać nam nieoczekiwane rzeczy ze zduplikowanymi procedurami obsługi, dając nam dziwne ostrzeżenia, gdy zostaną użyte when у block itp. Im lepiej znasz ansible, tym więcej niuansów możesz nazwać „trudnym” rozwiązaniem. A proste rozwiązanie – zastosowanie naturalnego podziału na pre/role/post – nie powoduje niuansów.

I wracając do naszego „foo”. Gdzie mam to umieścić? Przed, po czy w rolach? Oczywiście zależy to od tego, czy potrzebujemy wyników procedury obsługi foo. Jeśli ich tam nie ma, foo nie trzeba umieszczać ani w pre, ani w postie - te sekcje mają specjalne znaczenie - wykonują zadania przed i po głównej części kodu.

Teraz odpowiedź na pytanie „rola czy zadanie” sprowadza się do tego, co już jest w grze - jeśli są tam zadania, to musisz je dodać do zadań. Jeśli istnieją role, musisz je utworzyć (nawet z jednego zadania). Przypominam, że zadań i ról nie używa się jednocześnie.

Zrozumienie podstaw Ansible zapewnia rozsądne odpowiedzi na pozornie kwestie gustu.

Zadania i role (część druga)

Omówmy teraz sytuację, gdy dopiero zaczynasz pisać podręcznik. Musisz zrobić foo, bar i baz. Czy są to trzy zadania, jedna rola czy trzy role? Reasumując pytanie: w którym momencie należy zacząć pisać role? Jaki jest sens pisania ról, skoro można pisać zadania?... Czym jest rola?

Jednym z największych błędów (już o tym mówiłem) jest myślenie, że rola jest jak funkcja w bibliotece programu. Jak wygląda ogólny opis funkcji? Akceptuje argumenty wejściowe, współdziała z przyczynami ubocznymi, wywołuje skutki uboczne i zwraca wartość.

Teraz uwaga. Co można z tego zrobić w tej roli? Zawsze możesz wywołać skutki uboczne, to jest istota całego Ansible - tworzenie efektów ubocznych. Czy mają przyczyny uboczne? Podstawowy. Ale w przypadku „przekaż wartość i zwróć ją” – w tym przypadku to nie działa. Po pierwsze, nie można przekazać wartości do roli. Możesz ustawić zmienną globalną z całkowitym rozmiarem gry w sekcji vars dla tej roli. Wewnątrz roli możesz ustawić zmienną globalną z czasem trwania. A nawet z żywotnością podręczników (set_fact/register). Ale nie możesz mieć „zmiennych lokalnych”. Nie można „wziąć wartości” i „zwrócić jej”.

Najważniejsze z tego wynika: nie da się napisać czegoś w Ansible bez powodowania skutków ubocznych. Zmiana zmiennych globalnych jest zawsze efektem ubocznym funkcji. Na przykład w Rust zmiana zmiennej globalnej ma miejsce unsafe. A w Ansible jest to jedyna metoda wpływania na wartości roli. Zwróć uwagę na użyte słowa: nie „przekaż wartość do roli”, ale „zmień wartości używane przez rolę”. Nie ma podziału na role. Nie ma podziału na zadania i role.

Razem: rola nie jest funkcją.

Co jest dobrego w tej roli? Po pierwsze, rola ma wartości domyślne (/default/main.yaml), po drugie, rola posiada dodatkowe katalogi do przechowywania plików.

Jakie są zalety wartości domyślnych? Ponieważ w piramidzie Maslowa, czyli w dość zniekształconej tabeli zmiennych priorytetów Ansible, domyślne role mają najniższy priorytet (minus parametry wiersza poleceń Ansible). Oznacza to, że jeśli potrzebujesz podać wartości domyślne i nie martwić się, że zastąpią one wartości ze zmiennych inwentarzowych lub grupowych, to domyślne role są dla Ciebie jedynym właściwym miejscem. (Trochę kłamię – jest ich więcej |d(your_default_here), ale jeśli mówimy o miejscach stacjonarnych, to tylko role domyślne).

Co jeszcze jest wspaniałego w rolach? Ponieważ mają własne katalogi. Są to katalogi dla zmiennych, zarówno stałych (tj. obliczonych dla roli), jak i dynamicznych (istnieje albo wzorzec, albo antywzorzec - include_vars razem z {{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml.). To są katalogi dla files/, templates/. Umożliwia także posiadanie własnych modułów i wtyczek (library/). Jednak w porównaniu z zadaniami w podręczniku (który również może zawierać to wszystko) jedyną korzyścią jest to, że pliki nie są umieszczane na jednym stosie, ale na kilku oddzielnych stosach.

Jeszcze jeden szczegół: możesz spróbować utworzyć role, które będą dostępne do ponownego użycia (przez galaktykę). Wraz z pojawieniem się kolekcji podział ról można uznać za niemal zapomniany.

Zatem role mają dwie ważne cechy: mają wartości domyślne (unikalna cecha) i pozwalają na strukturę kodu.

Wracając do pierwotnego pytania: kiedy wykonywać zadania, a kiedy role? Zadania w podręczniku najczęściej wykorzystywane są albo jako „klej” przed/po rolach, albo jako samodzielny element konstrukcyjny (wtedy nie powinno być żadnych ról w kodzie). Stos normalnych zadań pomieszanych z rolami to jednoznaczna niechlujstwo. Powinieneś trzymać się określonego stylu – albo zadania, albo roli. Role zapewniają oddzielenie jednostek od wartości domyślnych, zadania umożliwiają szybsze czytanie kodu. Zwykle bardziej „stacjonarny” (ważny i złożony) kod jest przypisywany do ról, a skrypty pomocnicze pisane są w stylu zadaniowym.

Możliwe jest wykonanie import_role jako zadania, ale jeśli to napiszesz, przygotuj się na wyjaśnienie własnemu poczuciu piękna, dlaczego chcesz to zrobić.

Wnikliwy czytelnik może powiedzieć, że role mogą importować role, role mogą mieć zależności poprzez galaxy.yml, a także istnieje okropny i straszny include_role — Przypominam, że doskonalimy umiejętności w zakresie podstawowego Ansible, a nie gimnastyki figurowej.

Opiekunowie i zadania

Porozmawiajmy o kolejnej oczywistej rzeczy: handlerach. Umiejętność ich prawidłowego użycia jest niemal sztuką. Jaka jest różnica między modułem obsługi a przeciąganiem?

Skoro już pamiętamy podstawy, oto przykład:

- hosts: group1
  tasks:
    - foo:
      notify: handler1
  handlers:
     - name: handler1
       bar:

Procedury obsługi roli znajdują się w pliku rolename/handlers/main.yaml. Opiekunowie szperają pomiędzy wszystkimi uczestnikami zabawy: zadania pre/post_tasks mogą ciągnąć osoby zajmujące się obsługą ról, a rola może wyciągać osoby zajmujące się obsługą ról z gry. Jednakże wywołania funkcji obsługi typu „cross-role” powodują znacznie więcej wtf niż powtarzanie trywialnej procedury obsługi. (Kolejnym elementem najlepszych praktyk jest staranie się nie powtarzać nazw procedur obsługi).

Główna różnica polega na tym, że zadanie jest zawsze wykonywane (idempotentnie) (znaczniki plus/minus i when), a procedura obsługi - poprzez zmianę stanu (powiadamiaj pożary tylko wtedy, gdy został zmieniony). Co to znaczy? Na przykład fakt, że po ponownym uruchomieniu, jeśli nic się nie zmieniło, nie będzie procedury obsługi. Dlaczego może się zdarzyć, że będziemy musieli wykonać procedurę obsługi, skoro nie było żadnych zmian w zadaniu generującym? Na przykład dlatego, że coś się zepsuło i zmieniło, ale wykonanie nie dotarło do modułu obsługi. Na przykład dlatego, że sieć była chwilowo niedostępna. Konfiguracja została zmieniona, usługa nie została zrestartowana. Przy następnym uruchomieniu konfiguracja nie będzie się już zmieniać, a usługa pozostanie ze starą wersją konfiguracji.

Nie da się rozwiązać sytuacji z konfiguracją (dokładniej możesz wymyślić dla siebie specjalny protokół restartu z flagami plików itp., ale nie jest to już „podstawowy ansible” w żadnej formie). Ale jest jeszcze jedna wspólna historia: zainstalowaliśmy aplikację, nagraliśmy ją .service-file i teraz go chcemy daemon_reload и state=started. Naturalnym miejscem do tego wydaje się być przewodnik. Ale jeśli nie uczynisz tego procedurą obsługi, ale zadaniem na końcu listy zadań lub roli, wówczas będzie ono wykonywane idempotentnie za każdym razem. Nawet jeśli podręcznik zepsuł się w środku. To w ogóle nie rozwiązuje problemu z restartem (nie możesz wykonać zadania z atrybutem restarted, ponieważ idempotencja została utracona), ale zdecydowanie warto zrobić state=started, zwiększa się ogólna stabilność playbooków, ponieważ zmniejsza się liczba połączeń i stan dynamiczny.

Kolejną pozytywną właściwością modułu obsługi jest to, że nie zatyka wyjścia. Nie było żadnych zmian - żadne dodatkowe pominięte lub OK na wyjściu - łatwiejsze do odczytania. Jest to również właściwość ujemna - jeśli już przy pierwszym uruchomieniu znajdziesz literówkę w liniowo wykonywanym zadaniu, procedury obsługi zostaną wykonane dopiero po zmianie, tj. pod pewnymi warunkami - bardzo rzadko. Na przykład po raz pierwszy w życiu pięć lat później. I oczywiście będzie literówka w nazwie i wszystko się zepsuje. A jeśli nie uruchomisz ich drugi raz, nie będzie żadnych zmian.

Osobno musimy porozmawiać o dostępności zmiennych. Na przykład, jeśli powiadomisz zadanie za pomocą pętli, co będzie zawarte w zmiennych? Można zgadywać analitycznie, ale nie zawsze jest to trywialne, szczególnie jeśli zmienne pochodzą z różnych miejsc.

... Zatem programy obsługi są znacznie mniej przydatne i znacznie bardziej problematyczne, niż się wydaje. Jeśli możesz napisać coś pięknie (bez zbędnych bajerów) bez handlerów, lepiej zrobić to bez nich. Jeśli nie wyszło pięknie, to lepiej z nimi.

Żrący czytelnik słusznie zwraca uwagę, że nie omawialiśmy tego listenże handler może wywołać notify dla innego handlera, że ​​handler może zawierać import_tasks (co może wykonać include_role z with_items), że system obsługi w Ansible jest kompletny w Turingu, że programy obsługi z include_role krzyżują się w ciekawy sposób z handlerami z gry, itp. .d. - to wszystko wyraźnie nie jest „podstawą”).

Chociaż istnieje jedno konkretne WTF, które w rzeczywistości jest funkcją, o której należy pamiętać. Jeśli Twoje zadanie jest wykonywane za pomocą delegate_to i został powiadomiony, wówczas odpowiednia procedura obsługi jest wykonywana bez delegate_to, tj. na hoście, do którego przypisano grę. (Chociaż przewodnik oczywiście mógł to zrobić delegate_to To samo).

Osobno chcę powiedzieć kilka słów o rolach wielokrotnego użytku. Zanim pojawiły się kolekcje, istniał pomysł, że można stworzyć uniwersalne role ansible-galaxy install i poszedł. Działa na wszystkich systemach operacyjnych, we wszystkich wariantach, w każdej sytuacji. Więc moja opinia: to nie działa. Dowolna rola z masą include_vars, obsługujący 100500 1 skrzynek, jest skazany na otchłań błędów narożnych. Można je objąć masowymi testami, ale jak w przypadku każdego testowania, albo masz iloczyn kartezjański wartości wejściowych i funkcji całkowitej, albo masz „uwzględnione indywidualne scenariusze”. Moim zdaniem znacznie lepiej jest, jeśli rola jest liniowa (złożoność cyklomatyczna XNUMX).

Im mniej ifów (jawnych lub deklaratywnych - w formularzu when lub formularz include_vars według zestawu zmiennych), tym lepsza rola. Czasami trzeba zrobić gałęzie, ale powtarzam, im mniej, tym lepiej. Więc wydaje się, że to dobra rola z galaxy (to działa!) z mnóstwem when może być mniej preferowana niż „własna” rola z pięciu zadań. Momentem, w którym rola z galaktyką jest lepsza, jest moment, w którym zaczynasz coś pisać. Moment, w którym jest gorzej, to wtedy, gdy coś się psuje i masz podejrzenie, że dzieje się tak z powodu „roli z galaktyką”. Otwierasz, a tam jest pięć dodatków, osiem arkuszy zadań i stos when'Och... I musimy to rozgryźć. Zamiast 5 zadań liniowa lista, w której nie ma się czego łamać.

W kolejnych częściach

  • Trochę o zasobach, zmiennych grupowych, wtyczce host_group_vars, hostvars. Jak zawiązać węzeł gordyjski spaghetti. Zmienne zakresu i pierwszeństwa, model pamięci Ansible. „Gdzie zatem przechowujemy nazwę użytkownika w bazie danych?”
  • jinja: {{ jinja }} — nosql notype nosense miękka plastelina. Jest wszędzie, nawet tam, gdzie się tego nie spodziewasz. Trochę o !!unsafe i pyszny yaml.

Źródło: www.habr.com

Dodaj komentarz