Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Totul a început când șeful echipei uneia dintre echipele noastre de dezvoltare ne-a cerut să testăm noua lor aplicație, care fusese containerizată cu o zi înainte. l-am postat. După aproximativ 20 de minute s-a primit o solicitare de actualizare a aplicației, pentru că acolo fusese adăugat un lucru foarte necesar. am reînnoit. După încă câteva ore... ei bine, puteți ghici ce a început să se întâmple în continuare...

Trebuie să recunosc, sunt destul de leneș (nu am recunoscut asta mai devreme? Nu?), și dat fiind faptul că liderii de echipă au acces la Jenkins, în care avem toți CI/CD, m-am gândit: lasă-l să se desfășoare ca cât vrea el! Mi-am adus aminte de o glumă: dă-i unui bărbat un pește și va mânca o zi; sunați o persoană Fed și el va fi alimentat toată viața. Si s-a dus juca feste la serviciu, care ar putea să implementeze un container care să conțină aplicarea oricărei versiuni construite cu succes în Kuber și să-i transfere orice valori ENV (bunicul meu, filolog, profesor de engleză în trecut, acum își învârtea degetul spre tâmplă și mă privea foarte expresiv după ce a citit această propoziție).

Deci, în această notă vă voi spune cum am învățat:

  1. Actualizați în mod dinamic joburile în Jenkins din jobul propriu-zis sau din alte joburi;
  2. Conectați-vă la consola cloud (Cloud shell) de la un nod cu agentul Jenkins instalat;
  3. Implementați sarcina de lucru în Google Kubernetes Engine.


De fapt, sunt, desigur, oarecum necinstit. Se presupune că aveți cel puțin o parte din infrastructura în cloud-ul Google și, prin urmare, sunteți utilizatorul acesteia și, desigur, aveți un cont GCP. Dar nu despre asta este vorba în această notă.

Aceasta este următoarea mea foaie de cheat. Vreau să scriu astfel de note doar într-un caz: m-am confruntat cu o problemă, inițial nu am știut cum să o rezolv, soluția nu a fost căutată pe google gata făcută, așa că am căutat-o ​​pe google pe părți și până la urmă am rezolvat problema. Și pentru ca în viitor, când uit cum am făcut-o, nu trebuie să caut totul pe google bucată cu bucată și să le compilez împreună, îmi scriu astfel de cheat sheets.

Avertisment: 1. Nota a fost scrisă „pentru mine”, pentru rol cele mai bune practici nu se aplica. Mă bucur să citesc opțiunile „ar fi fost mai bine să o faci așa” în comentarii.
2. Dacă partea aplicată a notei este considerată sare, atunci, ca toate notele mele anterioare, aceasta este o soluție slabă de sare.

Actualizarea dinamică a setărilor jobului în Jenkins

Vă prevăd întrebarea: ce legătură are actualizarea dinamică a locurilor de muncă? Introduceți manual valoarea parametrului șir și gata!

Răspund: sunt foarte leneș, nu-mi place când se plâng: Misha, implementarea se prăbușește, totul a dispărut! Începi să cauți și există o greșeală de tipar în valoarea unui parametru de lansare a activității. Prin urmare, prefer să fac totul cât mai eficient posibil. Dacă este posibil să împiedici utilizatorul să introducă date direct, oferind în schimb o listă de valori din care să aleagă, atunci organizez selecția.

Planul este următorul: creăm un job în Jenkins, în care, înainte de lansare, putem selecta o versiune din listă, să specificăm valori pentru parametrii trecuți containerului prin ENV, apoi colectează containerul și îl împinge în Registrul Containerului. Apoi de acolo containerul este lansat in cuber as volumul de muncă cu parametrii specificati in job.

Nu vom lua în considerare procesul de creare și înființare a unui loc de muncă în Jenkins, acesta este în afara subiectului. Vom presupune că sarcina este gata. Pentru a implementa o listă actualizată cu versiuni, avem nevoie de două lucruri: o listă sursă existentă cu numere de versiune valide a priori și o variabilă precum Parametru de alegere în sarcină. În exemplul nostru, lăsați variabila să fie numită BUILD_VERSION, nu ne vom opri asupra ei în detaliu. Dar să aruncăm o privire mai atentă la lista surselor.

Nu există atât de multe opțiuni. Două lucruri mi-au venit imediat în minte:

  • Utilizați API-ul de acces la distanță pe care Jenkins îl oferă utilizatorilor săi;
  • Solicitați conținutul folderului de depozit la distanță (în cazul nostru acesta este JFrog Artifactory, ceea ce nu este important).

API-ul Jenkins Remote Access

Conform tradiției excelente stabilite, aș prefera să evit explicațiile lungi.
Îmi voi permite doar o traducere gratuită a unei părți din primul paragraf prima pagină a documentației API:

Jenkins oferă un API pentru acces la funcționalitatea sa care poate fi citit de la distanță de mașină. <…> Accesul de la distanță este oferit într-un stil REST. Aceasta înseamnă că nu există un singur punct de intrare pentru toate funcțiile, ci în schimb o adresă URL ca „.../api/", Unde "...„ înseamnă obiectul căruia i se aplică capabilitățile API.

Cu alte cuvinte, dacă sarcina de implementare despre care vorbim în prezent este disponibilă la http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build, atunci fluierele API pentru această sarcină sunt disponibile la http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/

Apoi, avem de ales sub ce formă să primim rezultatul. Să ne concentrăm pe XML, deoarece API-ul permite filtrarea doar în acest caz.

Să încercăm doar să obținem o listă cu toate lucrările executate. Ne interesează doar numele ansamblului (Numele de afișare) și rezultatul său (rezultat):

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

Sa dovedit?

Acum haideți să filtram doar acele rulări care se termină cu rezultatul SUCCES. Să folosim argumentul &exclude iar ca parametru îi vom trece calea către o valoare diferită de SUCCES. Da Da. Un dublu negativ este o afirmație. Excludem tot ceea ce nu ne interesează:

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

Captură de ecran a listei de succes
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Ei bine, doar pentru distracție, să ne asigurăm că filtrul nu ne-a înșelat (filtrele nu mint niciodată!) și să afișăm o listă cu cele „nereușite”:

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

Captură de ecran a listei celor nereușiți
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Lista versiunilor dintr-un folder de pe un server la distanță

Există o a doua modalitate de a obține o listă de versiuni. Îmi place chiar mai mult decât accesarea API-ului Jenkins. Ei bine, pentru că dacă aplicația a fost construită cu succes, înseamnă că a fost ambalată și plasată în depozit în folderul corespunzător. De exemplu, un depozit este stocarea implicită a versiunilor de lucru ale aplicațiilor. Ca. Ei bine, să-l întrebăm ce versiuni sunt stocate. Vom curl, grep și awk folderul de la distanță. Dacă cineva este interesat de oneliner, atunci este sub spoiler.

O singură linie de comandă
Vă rugăm să rețineți două lucruri: trec detaliile de conectare în antet și nu am nevoie de toate versiunile din folder și le selectez doar pe cele care au fost create în decurs de o lună. Editați comanda pentru a se potrivi cu realitățile și nevoile dvs.:

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[^/]+' )

Configurarea joburilor și a fișierului de configurare a jobului în Jenkins

Am aflat sursa listei de versiuni. Să încorporăm acum lista rezultată în sarcină. Pentru mine, soluția evidentă a fost adăugarea unui pas în sarcina de construire a aplicației. Pasul care ar fi executat dacă rezultatul ar fi „succes”.

Deschideți setările sarcinii de asamblare și derulați până în partea de jos. Click pe butoanele: Adăugați pas de construcție -> Pas condiționat (singur). În setările pasului, selectați condiția Starea actuală a construcției, setați valoarea SUCCES, acțiunea care trebuie efectuată dacă are succes Rulați comanda shell.

Și acum partea distractivă. Jenkins stochează configurațiile jobului în fișiere. În format XML. Pe parcurs http://путь-до-задания/config.xml În consecință, puteți descărca fișierul de configurare, îl puteți edita după cum este necesar și îl puteți pune înapoi acolo unde l-ați luat.

Amintiți-vă, am convenit mai sus că vom crea un parametru pentru lista de versiuni BUILD_VERSION?

Să descarcăm fișierul de configurare și să aruncăm o privire în el. Doar pentru a vă asigura că parametrul este la locul său și de tipul dorit.

Captură de ecran sub spoiler.

Fragmentul dvs. config.xml ar trebui să arate la fel. Cu excepția faptului că conținutul elementului choices lipsește încă
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Esti sigur? Gata, hai să scriem un script care va fi executat dacă construirea are succes.
Scriptul va primi o listă de versiuni, va descărca fișierul de configurare, va scrie lista de versiuni în el în locul de care avem nevoie și apoi o va pune înapoi. Da. Asta e corect. Scrieți o listă de versiuni în XML în locul în care există deja o listă de versiuni (va fi în viitor, după prima lansare a scriptului). Știu că există încă fani înverșunați ai expresiilor regulate în lume. Eu nu le aparțin. Vă rugăm să instalați xmlstarler la mașina unde va fi editată configurația. Mi se pare că acesta nu este un preț atât de mare de plătit pentru a evita editarea XML folosind sed.

Sub spoiler, vă prezint codul care realizează secvența de mai sus în întregime.

Scrieți o listă de versiuni dintr-un folder de pe serverul de la distanță în 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

Dacă preferați opțiunea de a obține versiuni de la Jenkins și sunteți la fel de leneș ca mine, atunci sub spoiler este același cod, dar o listă de la Jenkins:

Scrieți o listă de versiuni de la Jenkins la config
Țineți cont de acest lucru: numele meu ansamblu constă dintr-un număr de secvență și un număr de versiune, separate prin două puncte. În consecință, awk oprește partea inutilă. Pentru tine, schimbă această linie pentru a se potrivi nevoilor tale.

#!/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

În teorie, dacă ați testat codul scris pe baza exemplelor de mai sus, atunci în sarcina de implementare ar trebui să aveți deja o listă derulantă cu versiuni. Este ca în captura de ecran de sub spoiler.

Lista de versiuni completată corect
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Dacă totul a funcționat, atunci copiați și lipiți scriptul în Rulați comanda shell și salvați modificările.

Conectarea la Cloud shell

Avem colectori în containere. Folosim Ansible ca instrument de livrare a aplicațiilor și manager de configurare. În consecință, atunci când vine vorba de construirea de containere, vin în minte trei opțiuni: instalați Docker în Docker, instalați Docker pe o mașină care rulează Ansible sau construiți containere într-o consolă cloud. Am fost de acord să păstrăm tăcerea despre pluginurile pentru Jenkins în acest articol. Tine minte?

Am decis: ei bine, din moment ce containerele „din cutie” pot fi colectate în consola cloud, atunci de ce să vă deranjați? Păstrează-l curat, nu? Vreau să colectez containere Jenkins în consola cloud și apoi să le lansez în cuber de acolo. Mai mult, Google are canale foarte bogate în cadrul infrastructurii sale, ceea ce va avea un efect benefic asupra vitezei de implementare.

Pentru a vă conecta la consola cloud, aveți nevoie de două lucruri: gcloud și drepturi de acces la API Google Cloud pentru instanța VM cu care se va face aceeași conexiune.

Pentru cei care plănuiesc să se conecteze deloc de la Google Cloud
Google permite posibilitatea dezactivării autorizației interactive în serviciile sale. Acest lucru vă va permite să vă conectați la consolă chiar și de la un aparat de cafea, dacă rulează *nix și are o consolă în sine.

Dacă este nevoie să acopăr această problemă mai detaliat în cadrul acestei note, scrieți în comentarii. Dacă obținem suficiente voturi, voi scrie o actualizare pe acest subiect.

Cel mai simplu mod de a acorda drepturi este prin interfața web.

  1. Opriți instanța VM de la care vă veți conecta ulterior la consola cloud.
  2. Deschideți Detalii instanță și faceți clic modifica.
  3. În partea de jos a paginii, selectați domeniul de acces al instanței Acces complet la toate API-urile Cloud.

    Screenshot
    Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

  4. Salvați modificările și lansați instanța.

Odată ce VM a terminat de încărcat, conectați-vă la el prin SSH și asigurați-vă că conexiunea are loc fără eroare. Utilizați comanda:

gcloud alpha cloud-shell ssh

O conexiune reușită arată cam așa
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Implementați în GKE

Deoarece ne străduim în toate modurile posibile să trecem complet la IaC (Infrastructura ca cod), fișierele noastre docker sunt stocate în Git. Aceasta este pe de o parte. Iar implementarea în kubernetes este descrisă de un fișier yaml, care este utilizat numai de această sarcină, care este, de asemenea, ca cod. Asta e din cealalta parte. În general, vreau să spun, planul este următorul:

  1. Luăm valorile variabilelor BUILD_VERSION și, opțional, valorile variabilelor care vor fi trecute ENV.
  2. Descărcați fișierul docker din Git.
  3. Generați yaml pentru implementare.
  4. Încărcăm ambele fișiere prin scp în consola cloud.
  5. Construim un container acolo și îl introducem în registrul Container
  6. Aplicăm fișierul de implementare a încărcării cuberului.

Să fim mai specifici. Odată am început să vorbim despre ENV, atunci să presupunem că trebuie să trecem valorile a doi parametri: PARAM1 и PARAM2. Adăugăm sarcina lor pentru implementare, tastați - Parametru șir.

Screenshot
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Vom genera yaml cu o simplă redirecționare ecou la dosar. Se presupune, desigur, că aveți în fișierul docker PARAM1 и PARAM2că numele de încărcare va fi minunata aplicatie, iar containerul asamblat cu aplicarea versiunii specificate se află în Registrul containerelor pe parcurs gcr.io/awesomeapp/awesomeapp-$BUILD_VERSIONUnde $BUILD_VERSION tocmai a fost selectat din lista derulantă.

Lista echipei

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

Agent Jenkins după conectare folosind gcloud alpha cloud-shell ssh modul interactiv nu este disponibil, așa că trimitem comenzi către consola cloud folosind parametrul --comanda.

Curățăm folderul de acasă din consola cloud din vechiul fișier docker:

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

Plasați fișierul docker proaspăt descărcat în folderul de pornire al consolei cloud folosind scp:

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

Colectăm, etichetăm și împingem containerul în registrul Container:

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"

Facem același lucru cu fișierul de implementare. Vă rugăm să rețineți că comenzile de mai jos folosesc nume fictive ale cluster-ului în care are loc implementarea (awsm-cluster) și numele proiectului (minunat-proiect), unde se află clusterul.

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"

Executăm sarcina, deschidem ieșirea consolei și sperăm să vedem asamblarea cu succes a containerului.

Screenshot
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Și apoi desfășurarea cu succes a containerului asamblat

Screenshot
Creăm o sarcină de implementare în GKE fără pluginuri, SMS sau înregistrare. Să aruncăm o privire sub jacheta lui Jenkins

Am ignorat în mod deliberat setarea Pătrundere. Dintr-un motiv simplu: odată ce l-ați configurat volumul de muncă cu un nume dat, va rămâne operațional, indiferent de câte implementări cu acest nume efectuați. Ei bine, în general, acest lucru este puțin dincolo de sfera istoriei.

În loc de concluzii

Probabil că toți pașii de mai sus nu ar fi putut fi făcuți, ci pur și simplu a instalat un plugin pentru Jenkins, muuulionul lor. Dar din anumite motive nu-mi plac pluginurile. Ei bine, mai exact, recurg la ele doar din disperare.

Și îmi place doar să aleg un subiect nou pentru mine. Textul de mai sus este, de asemenea, o modalitate de a împărtăși constatările pe care le-am făcut în timp ce rezolvam problema descrisă la început. Împărtășiți cu cei care, ca și el, nu sunt deloc un lup îngrozitor în devops. Dacă descoperirile mele ajută măcar pe cineva, voi fi fericit.

Sursa: www.habr.com

Adauga un comentariu