Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

Všechno to začalo, když nás vedoucí týmu jednoho z našich vývojových týmů požádal, abychom otestovali jejich novou aplikaci, která byla den předtím kontejnerována. Zveřejnil jsem to. Asi po 20 minutách přišla žádost o aktualizaci aplikace, protože tam byla přidána velmi potřebná věc. obnovil jsem. Po dalších pár hodinách... no, můžete hádat, co se začalo dít dál...

Musím se přiznat, že jsem docela líný (nepřiznal jsem to dříve? Ne?), a vzhledem k tomu, že vedoucí týmů mají přístup k Jenkinsovi, ve kterém máme všechny CI/CD, pomyslel jsem si: ať nasadí jako jak chce! Vzpomněl jsem si na vtip: dej člověku rybu a bude jeden den jíst; nazvěte člověka Fedem a bude Fed celý život. A šel hrát triky v práci, který by dokázal do Kuberu nasadit kontejner obsahující aplikaci libovolné úspěšně zabudované verze a přenést do něj libovolné hodnoty ENV (můj dědeček, filolog, v minulosti učitel angličtiny, by teď po přečtení této věty kroutil prstem na spánku a díval se na mě velmi expresivně).

Takže v této poznámce vám řeknu, jak jsem se naučil:

  1. Dynamicky aktualizujte úlohy v Jenkins ze samotné úlohy nebo z jiných úloh;
  2. Připojte se ke cloudové konzole (Cloud Shell) z uzlu s nainstalovaným agentem Jenkins;
  3. Nasaďte pracovní zátěž na Google Kubernetes Engine.


Ve skutečnosti jsem samozřejmě poněkud nedůvěřivý. Předpokládá se, že máte alespoň část infrastruktury v cloudu Google, a tedy jste jeho uživatelem a samozřejmě máte účet GCP. Ale o tom tato poznámka není.

Toto je můj další cheat. Takové poznámky chci napsat jen v jednom případě: Stál jsem před problémem, zpočátku jsem nevěděl, jak ho vyřešit, řešení nebylo vygooglováno hotové, tak jsem ho vygooglil po částech a nakonec problém vyřešil. A abych v budoucnu, až zapomenu, jak jsem to udělal, nemusel všechno znovu googlovat kus po kuse a sestavovat to dohromady, píšu si takové cheat sheety.

Disclaimer: 1. Poznámka byla napsána „pro sebe“, pro roli nejlepší praxe neplatí. Rád si v komentářích přečtu možnosti „bylo by lepší to udělat takto“.
2. Pokud je aplikovaná část noty považována za sůl, pak, stejně jako všechny mé předchozí poznámky, je i tato slabým solným roztokem.

Dynamická aktualizace nastavení úlohy v Jenkins

Předvídám vaši otázku: co s tím má společného dynamická aktualizace práce? Zadejte hodnotu parametru řetězce ručně a můžete vyrazit!

Odpovídám: Jsem fakt líný, nemám rád, když si stěžují: Míšo, nasazení padá, všechno je pryč! Začnete hledat a v hodnotě některého parametru spuštění úlohy je překlep. Proto raději dělám vše co nejefektivněji. Pokud je možné zabránit uživateli v přímém zadávání dat tím, že místo toho poskytnu seznam hodnot, ze kterých si může vybrat, uspořádám výběr.

Plán je tento: vytvoříme úlohu v Jenkins, ve které bychom před spuštěním mohli vybrat verzi ze seznamu, zadat hodnoty parametrů předávaných do kontejneru přes ENV, poté shromáždí kontejner a vloží jej do registru kontejnerů. Poté je kontejner spuštěn v cuber as pracovní zátěž s parametry uvedenými v zakázce.

Nebudeme uvažovat o procesu vytváření a nastavení pracovního místa v Jenkins, to je mimo téma. Budeme předpokládat, že úkol je připraven. K implementaci aktualizovaného seznamu s verzemi potřebujeme dvě věci: existující zdrojový seznam s a priori platnými čísly verzí a proměnnou jako Parametr volby v úkolu. V našem příkladu nechť se proměnná jmenuje BUILD_VERSION, nebudeme se jím podrobně zabývat. Pojďme se ale blíže podívat na zdrojový seznam.

Není tolik možností. Hned mě napadly dvě věci:

  • Používejte API pro vzdálený přístup, které Jenkins nabízí svým uživatelům;
  • Vyžádejte si obsah složky vzdáleného úložiště (v našem případě je to JFrog Artifactory, což není důležité).

API pro vzdálený přístup Jenkins

Dle zavedené výborné tradice bych se sáhodlouhému vysvětlování raději vyhnul.
Dovolím si jen volný překlad kousku prvního odstavce první stránka dokumentace API:

Jenkins poskytuje API pro vzdálený strojově čitelný přístup k jeho funkcím. <…> Vzdálený přístup je nabízen ve stylu REST. To znamená, že neexistuje jediný vstupní bod ke všem funkcím, ale místo toho adresa URL jako „.../api/", kde"...“ znamená objekt, na který se aplikují schopnosti API.

Jinými slovy, pokud je úloha nasazení, o které aktuálně mluvíme, dostupná na adrese http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build, pak jsou API whistle pro tento úkol k dispozici na adrese http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/

Dále máme na výběr, v jaké formě výstup přijímat. Zaměřme se na XML, jelikož API v tomto případě umožňuje pouze filtrování.

Pokusme se získat seznam všech úloh. Zajímá nás pouze název sestavy (displayName) a jeho výsledek (následek):

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]

Máš to?

Nyní vyfiltrujme pouze ty běhy, které skončí s výsledkem ÚSPĚCH. Použijme argument &vyloučit a jako parametr mu předáme cestu k hodnotě, která se nerovná ÚSPĚCH. Ano ano. Dvojitý zápor je prohlášení. Vylučujeme vše, co nás nezajímá:

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result!='SUCCESS']

Snímek obrazovky se seznamem úspěšných
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

No, pro zajímavost se přesvědčme, že nás filtr neklamal (filtry nikdy nelžou!) a zobrazme seznam „neúspěšných“:

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result='SUCCESS']

Snímek obrazovky se seznamem neúspěšných
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

Seznam verzí ze složky na vzdáleném serveru

Existuje druhý způsob, jak získat seznam verzí. Líbí se mi to ještě víc než přístup k Jenkins API. No, protože pokud byla aplikace úspěšně sestavena, znamená to, že byla zabalena a umístěna do úložiště v příslušné složce. Stejně jako úložiště je výchozí úložiště pracovních verzí aplikací. Jako. No, zeptejme se ho, jaké verze jsou v úložišti. Vzdálenou složku zvlníme, grepujeme a awk. Pokud má někdo zájem o oneliner, tak je pod spoilerem.

Jednořádkový příkaz
Vezměte prosím na vědomí dvě věci: podrobnosti o připojení předám v záhlaví a nepotřebuji všechny verze ze složky a vyberu pouze ty, které byly vytvořeny do měsíce. Upravte příkaz tak, aby vyhovoval vašim realitám a potřebám:

curl -H "X-JFrog-Art-Api:VeryLongAPIKey" -s http://arts.myre.po/artifactory/awesomeapp/ | sed 's/a href=//' | grep "$(date +%b)-$(date +%Y)|$(date +%b --date='-1 month')-$(date +%Y)" | awk '{print $1}' | grep -oP '>K[^/]+' )

Nastavení úloh a konfiguračního souboru úlohy v Jenkins

Přišli jsme na zdroj seznamu verzí. Výsledný seznam nyní začleníme do úkolu. Pro mě bylo zřejmým řešením přidat krok v úloze sestavení aplikace. Krok, který by byl proveden, pokud by byl výsledkem „úspěch“.

Otevřete nastavení úlohy sestavení a přejděte úplně dolů. Klikněte na tlačítka: Přidat krok sestavení -> Podmíněný krok (jeden). V nastavení kroku vyberte podmínku Aktuální stav sestavení, nastavte hodnotu ÚSPĚCH, akce, která má být provedena v případě úspěchu Spusťte příkaz shell.

A teď ta zábavná část. Jenkins ukládá konfigurace úloh do souborů. Ve formátu XML. Při cestě http://путь-до-задания/config.xml V souladu s tím si můžete stáhnout konfigurační soubor, upravit jej podle potřeby a vrátit jej tam, kde jste jej získali.

Pamatujte, že jsme se výše dohodli, že vytvoříme parametr pro seznam verzí BUILD_VERSION?

Pojďme si stáhnout konfigurační soubor a podívat se do něj. Jen abyste se ujistili, že je parametr na svém místě a požadovaného typu.

Snímek obrazovky pod spoilerem.

Váš fragment config.xml by měl vypadat stejně. Až na to, že obsah elementu options zatím chybí
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

Jsi si jistá? To je vše, pojďme napsat skript, který se spustí, pokud je sestavení úspěšné.
Skript obdrží seznam verzí, stáhne konfigurační soubor, zapíše do něj seznam verzí na místo, které potřebujeme, a poté jej vrátí zpět. Ano. To je správně. Napište seznam verzí v XML na místo, kde již je seznam verzí (bude v budoucnu, po prvním spuštění skriptu). Vím, že na světě jsou stále zuřiví fanoušci regulárních výrazů. Já k nim nepatřím. Prosím nainstalujte xmlstarler na stroj, kde bude upravována konfigurace. Zdá se mi, že to není tak velká cena, abych se vyhnul úpravám XML pomocí sed.

Pod spoilerem uvádím kód, který provádí výše uvedenou sekvenci v celém rozsahu.

Napište seznam verzí ze složky na vzdáleném serveru do souboru config

#!/bin/bash
############## Скачиваем конфиг
curl -X GET -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml -o appConfig.xml

############## Удаляем и заново создаем xml-элемент для списка версий
xmlstarlet ed --inplace -d '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' appConfig.xml

xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]' --type elem -n a appConfig.xml

xmlstarlet ed --inplace --insert '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a' --type attr -n class -v string-array appConfig.xml

############## Читаем в массив список версий из репозитория
readarray -t vers < <( curl -H "X-JFrog-Art-Api:Api:VeryLongAPIKey" -s http://arts.myre.po/artifactory/awesomeapp/ | sed 's/a href=//' | grep "$(date +%b)-$(date +%Y)|$(date +%b --date='-1 month')-$(date +%Y)" | awk '{print $1}' | grep -oP '>K[^/]+' )

############## Пишем массив элемент за элементом в конфиг
printf '%sn' "${vers[@]}" | sort -r | 
                while IFS= read -r line
                do
                    xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' --type elem -n string -v "$line" appConfig.xml
                done

############## Кладем конфиг взад
curl -X POST -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml --data-binary @appConfig.xml

############## Приводим рабочее место в порядок
rm -f appConfig.xml

Pokud dáváte přednost možnosti získat verze od Jenkinse a jste líní jako já, pak pod spoilerem je stejný kód, ale seznam od Jenkinse:

Napište seznam verzí od Jenkinse do konfigurace
Mějte to na paměti: název mé sestavy se skládá z pořadového čísla a čísla verze, oddělených dvojtečkou. V souladu s tím awk odřízne nepotřebnou část. Pro sebe změňte tuto řadu tak, aby vyhovovala vašim potřebám.

#!/bin/bash
############## Скачиваем конфиг
curl -X GET -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml -o appConfig.xml

############## Удаляем и заново создаем xml-элемент для списка версий
xmlstarlet ed --inplace -d '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' appConfig.xml

xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]' --type elem -n a appConfig.xml

xmlstarlet ed --inplace --insert '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a' --type attr -n class -v string-array appConfig.xml

############## Пишем в файл список версий из Jenkins
curl -g -X GET -u username:apiKey 'http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result!=%22SUCCESS%22]&pretty=true' -o builds.xml

############## Читаем в массив список версий из XML
readarray vers < <(xmlstarlet sel -t -v "freeStyleProject/allBuild/displayName" builds.xml | awk -F":" '{print $2}')

############## Пишем массив элемент за элементом в конфиг
printf '%sn' "${vers[@]}" | sort -r | 
                while IFS= read -r line
                do
                    xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' --type elem -n string -v "$line" appConfig.xml
                done

############## Кладем конфиг взад
curl -X POST -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml --data-binary @appConfig.xml

############## Приводим рабочее место в порядок
rm -f appConfig.xml

Teoreticky, pokud jste testovali kód napsaný na základě výše uvedených příkladů, pak byste v úloze nasazení již měli mít rozevírací seznam s verzemi. Je to jako na snímku obrazovky pod spoilerem.

Správně vyplněný seznam verzí
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

Pokud vše fungovalo, zkopírujte a vložte skript do Spusťte příkaz shell a uložit změny.

Připojování ke cloudovému prostředí

Máme sběrače v kontejnerech. Ansible používáme jako náš nástroj pro poskytování aplikací a správce konfigurace. V souladu s tím, pokud jde o vytváření kontejnerů, přicházejí na mysl tři možnosti: nainstalovat Docker v Dockeru, nainstalovat Docker na počítač s Ansible nebo vytvořit kontejnery v cloudové konzoli. Dohodli jsme se, že v tomto článku budeme mlčet o pluginech pro Jenkinse. Pamatovat si?

Rozhodl jsem se: dobře, protože kontejnery „out of the box“ lze shromažďovat v cloudové konzoli, tak proč se obtěžovat? Udržujte to čisté, že? Chci shromáždit kontejnery Jenkins v cloudové konzole a odtud je spustit do krychle. Navíc má Google v rámci své infrastruktury velmi bohaté kanály, což bude mít příznivý vliv na rychlost nasazení.

Chcete-li se připojit ke cloudové konzoli, potřebujete dvě věci: gcloud a přístupová práva Google Cloud API pro instanci virtuálního počítače, se kterou bude vytvořeno stejné připojení.

Pro ty, kteří se neplánují připojit vůbec z cloudu Google
Google ve svých službách povoluje možnost deaktivace interaktivní autorizace. To vám umožní připojit se ke konzoli i z kávovaru, pokud běží *nix a má samotnou konzoli.

Pokud je potřeba, abych se v rámci této poznámky věnoval této problematice podrobněji, napište do komentářů. Pokud získáme dostatek hlasů, napíšu aktualizaci na toto téma.

Nejjednodušší způsob udělování práv je přes webové rozhraní.

  1. Zastavte instanci virtuálního počítače, ze které se následně připojíte ke cloudové konzoli.
  2. Otevřete Podrobnosti o instanci a klikněte Změna.
  3. Úplně dole na stránce vyberte rozsah přístupu k instanci Plný přístup ke všem Cloud API.

    Screenshot
    Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

  4. Uložte změny a spusťte instanci.

Jakmile virtuální počítač dokončí načítání, připojte se k němu přes SSH a ujistěte se, že připojení proběhne bez chyby. Použijte příkaz:

gcloud alpha cloud-shell ssh

Úspěšné připojení vypadá asi takto
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

Nasadit do GKE

Protože se všemi možnými způsoby snažíme o úplný přechod na IaC (Infrastruktura jako kód), jsou naše soubory dockeru uloženy v Gitu. To je na jedné straně. A nasazení v kubernetes je popsáno souborem yaml, který používá pouze tato úloha, která sama o sobě je také jako kód. Tohle je z druhé strany. Obecně mám na mysli, že plán je tento:

  1. Bereme hodnoty proměnných BUILD_VERSION a volitelně hodnoty proměnných, které budou předány ENV.
  2. Stáhněte si dockerfile z Git.
  3. Vygenerujte yaml pro nasazení.
  4. Oba tyto soubory nahrajeme přes scp do cloudové konzole.
  5. Vytvoříme tam kontejner a vložíme ho do registru kontejnerů
  6. Soubor načtení nasazení aplikujeme na cuber.

Buďme konkrétnější. Jednou jsme začali mluvit o ENV, pak předpokládejme, že musíme předat hodnoty dvou parametrů: PARAM1 и PARAM2. Přidáme jejich úkol pro nasazení, typ - Parametr řetězce.

Screenshot
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

Vygenerujeme yaml jednoduchým přesměrováním minout do souboru. Předpokládá se samozřejmě, že máte ve svém dockerfile PARAM1 и PARAM2že název zatížení bude úžasná aplikacea sestavený kontejner s aplikací uvedené verze leží v Registr kontejnerů při cestě gcr.io/awesomeapp/awesomeapp-$BUILD_VERSIONKde $BUILD_VERSION byl právě vybrán z rozevíracího seznamu.

Seznam týmu

touch deploy.yaml
echo "apiVersion: apps/v1" >> deploy.yaml
echo "kind: Deployment" >> deploy.yaml
echo "metadata:" >> deploy.yaml
echo "  name: awesomeapp" >> deploy.yaml
echo "spec:" >> deploy.yaml
echo "  replicas: 1" >> deploy.yaml
echo "  selector:" >> deploy.yaml
echo "    matchLabels:" >> deploy.yaml
echo "      run: awesomeapp" >> deploy.yaml
echo "  template:" >> deploy.yaml
echo "    metadata:" >> deploy.yaml
echo "      labels:" >> deploy.yaml
echo "        run: awesomeapp" >> deploy.yaml
echo "    spec:" >> deploy.yaml
echo "      containers:" >> deploy.yaml
echo "      - name: awesomeapp" >> deploy.yaml
echo "        image: gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION:latest" >> deploy.yaml
echo "        env:" >> deploy.yaml
echo "        - name: PARAM1" >> deploy.yaml
echo "          value: $PARAM1" >> deploy.yaml
echo "        - name: PARAM2" >> deploy.yaml
echo "          value: $PARAM2" >> deploy.yaml

Jenkins agent po připojení pomocí gcloud alpha cloud-shell ssh interaktivní režim není k dispozici, takže příkazy odesíláme do cloudové konzole pomocí parametru --příkaz.

Vyčistíme domovskou složku v cloudové konzoli ze starého dockerfile:

gcloud alpha cloud-shell ssh --command="rm -f Dockerfile"

Umístěte čerstvě stažený dockerfile do domovské složky cloudové konzoly pomocí scp:

gcloud alpha cloud-shell scp localhost:./Dockerfile cloudshell:~

Shromáždíme, označíme a přesuneme kontejner do registru kontejnerů:

gcloud alpha cloud-shell ssh --command="docker build -t awesomeapp-$BUILD_VERSION ./ --build-arg BUILD_VERSION=$BUILD_VERSION --no-cache"
gcloud alpha cloud-shell ssh --command="docker tag awesomeapp-$BUILD_VERSION gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION"
gcloud alpha cloud-shell ssh --command="docker push gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION"

Totéž provedeme se souborem nasazení. Upozorňujeme, že níže uvedené příkazy používají fiktivní názvy clusteru, kde dochází k nasazení (awsm-cluster) a název projektu (úžasný projekt), kde se cluster nachází.

gcloud alpha cloud-shell ssh --command="rm -f deploy.yaml"
gcloud alpha cloud-shell scp localhost:./deploy.yaml cloudshell:~
gcloud alpha cloud-shell ssh --command="gcloud container clusters get-credentials awsm-cluster --zone us-central1-c --project awesome-project && 
kubectl apply -f deploy.yaml"

Spustíme úlohu, otevřeme výstup konzoly a doufáme, že uvidíme úspěšné sestavení kontejneru.

Screenshot
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

A pak úspěšné nasazení sestaveného kontejneru

Screenshot
Úlohu nasazení vytvoříme v GKE bez pluginů, SMS nebo registrace. Pojďme nakouknout pod Jenkinsovu bundu

Úmyslně jsem ignoroval nastavení Ingress. Z jednoho prostého důvodu: jakmile to nastavíte pracovní zátěž s daným názvem zůstane funkční bez ohledu na to, kolik nasazení s tímto názvem provedete. No, obecně je to trochu mimo rámec historie.

Namísto závěrů

Všechny výše uvedené kroky pravděpodobně nebylo možné provést, ale jednoduše nainstalovat nějaký plugin pro Jenkinse, jejich muuulion. Ale z nějakého důvodu nemám rád pluginy. Tedy přesněji řečeno, uchýlím se k nim jen ze zoufalství.

A já prostě rád vychytám nějaké nové téma pro mě. Výše uvedený text je také způsob, jak se podělit o poznatky, ke kterým jsem dospěl při řešení problému popsaného na samém začátku. Podělte se s těmi, kteří stejně jako on nejsou vůbec hrozným vlkem v devopsu. Pokud mé poznatky pomohou alespoň někomu, budu rád.

Zdroj: www.habr.com

Přidat komentář