Vi laver en deploymentopgave i GKE uden plugins, SMS og registrering. Vi kigger ind under Jenkins' jakke

Det hele startede med, at teamlederen for et af vores udviklerhold bad om at offentliggøre deres nye applikation i testmode, som dagen før var blevet containeriseret. Det gjorde jeg. Cirka 20 minutter senere kom der en forespørgsel om at opdatere appen, fordi de havde tilføjet en meget nødvendig funktion. Jeg opdaterede. Efter et par timer... ja, I kan sikkert gætte, hvad der skete derefter...

Jeg må indrømme, at jeg er ret doven (har jeg ikke sagt det før? Nej?), og i betragtning af at teamlederne har adgang til Jenkins, hvor vi har hele vores CI/CD, tænkte jeg: lad dem selv deployere, så meget de vil! Jeg kom til at tænke på en vittighed: giv en mand en fisk, og han vil være mæt for en dag; kald ham Mæt, og han vil være mæt hele livet. Og så gik jeg i gang med at lave et job, som ville kunne deployere en container med applikationen af enhver vellykket bygget version og overføre hvilke som helst værdier ENV (min bedstefar, en filolog og tidligere engelsk underviser, ville nu dreje en finger ved tindingen og se meget udtryksfuldt på mig, hvis han læste denne sætning).

Så i denne note vil jeg fortælle om, hvordan jeg lærte at:

  1. Dynamisk opdatere jobs i Jenkins fra selve jobbet eller fra andre jobs;
  2. Forbinde til cloud-konsollen (Cloud shell) fra noden med den installerede Jenkins-agent;
  3. Deployere arbejdsbelastningen (workload) i Google Kubernetes Engine.


For faktisk, jeg overdriver lidt. Det forudsættes, at i det mindste en del af infrastrukturen er i Googles cloud, og derfor er du en bruger af den, og naturligvis har du en GCP-konto. Men notatet handler ikke om det.

Det er endnu en af mine huskesedler. Jeg har kun lyst til at skrive sådanne noter, når der står en opgave foran mig, som jeg i starten ikke vidste, hvordan jeg skulle løse, og hvor løsningen ikke blev fundet i et samlet format, så jeg har googlet den del for del og til sidst løst opgaven. Og for at jeg i fremtiden, når jeg glemmer, hvordan jeg gjorde det, ikke skal google det hele stykke for stykke og sammensætte det, skriver jeg sådanne huskesedler til mig selv.

Ansvarsfraskrivelse: 1. Notatet var skrevet "til mig selv", og det gør ikke krav på best practice . Jeg vil med glæde læse forslag til "det ville have været bedre at gøre sådan" i kommentarerne.
2. Hvis den anvendelsesorienterede del af notatet betragtes som salt, så er det, som i mine tidligere notater, en svag saltopløsning.

Dynamisk opdatering af jobindstillinger i Jenkins

Jeg kan forudse dit spørgsmål: hvad har dynamisk opdatering af job overhovedet at gøre med det? Jeg skriver manuelt værdien af den strengparameter og så af sted!

Jeg svarer: jeg er virkelig doven, jeg kan ikke lide, når folk klager: Misha, deployment går ned, alt er tabt! Man begynder at se nærmere på det, og så er der en tastefejl i værdien af en hvilken som helst jobstartparameter. Derfor foretrækker jeg at gøre alt så fejlsikkert som muligt. Hvis der er mulighed for at frarøve brugeren muligheden for at indtaste data direkte ved i stedet at give en liste af værdier at vælge imellem, så organiserer jeg valget.

Planen er som følger: vi opretter et job i Jenkins, hvor man før lanceringen kan vælge version fra en liste, angive værdier for parametre, der sendes til containeren via ENV, derefter bygger det containeren og sender den til Container Registry. Derefter startes containeren derfra i Kubernetes som workload med de parametre, der er angivet i jobbet.

Vi vil ikke se på processen for oprettelse og opsætning af job i Jenkins, det er off-topic. Lad os antage, at jobbet er klart. For at implementere en opdaterbar liste med versioner, har vi brug for to ting: en allerede eksisterende kilde liste med på forhånd valide versionsnumre og en variabel af typen Choice parameter i jobbet. I vores eksempel lad os antage, at variablen hedder BUILD_VERSION, vi vil ikke dykke dybere ind i det. Men lad os se nærmere på kilde listen.

Der er ikke så mange muligheder. Der popper straks to op i mit hoved:

  • Brug Remote access API, som Jenkins tilbyder sine brugere;
  • Anmode om indholdet af en fjernmappe fra depotet (i vores tilfælde er dette JFrog Artifactory, hvilket ikke er afgørende).

Jenkins Remote access API

I den skønne tradition vil jeg undgå lange forklaringer.
Jeg tillader mig kun et frit oversættelse af et stykke af første paragraf på første side af API dokumentationen:

Jenkins tilbyder API til fjernmaskinforståelig adgang til sin funktionalitet. Fjernadgang tilbydes i en REST-lignende stil. Det betyder, at der ikke er et enkelt adgangspunkt til alle funktioner, men i stedet bruges en URL som ".../api/", hvor "…" repræsenterer det objekt, som API-funktionerne anvendes på.

Med andre ord, hvis deployment jobbet, som vi i øjeblikket taler om, er tilgængeligt på adressen http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build, så API-udskrifterne til denne opgave er tilgængelige på adressen http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/

Dernæst har vi et valg om, i hvilken form vi vil modtage output. Vi vælger XML, da API'et kun giver mulighed for filtrering i dette tilfælde.

Lad os prøver at få en liste over alle kørseler af opgaven. Vi er kun interesserede i navnet på bygningen (displayName) og dens resultat (resultat):

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

Er det lykkedes?

Lad os nu filtrere kun de kørseler, der ender med resultatet SUCCESS. Vi bruger argumentet &exclude og vi sender stien til værdien, der ikke er lig med SUCCESS. Ja, ja. Dobbelt negation er en bekræftelse. Vi udelukker alt, hvad der ikke interesserer os:

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

Skærmbillede af listen over vellykkede
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Og for sjov vil vi sikre os, at filteret ikke har narret os (filtre lyver jo aldrig!) og udskrive listen over "ikke-vellykkede":

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

Skærmbillede af listen over ikke-vellykkede
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Liste over versioner fra mappen på den fjerne server

Der er også en anden måde at få listen over versioner. Jeg kan faktisk bedre lide den end at gå til Jenkins API. For hvis applikationen er blevet bygget korrekt, så er den pakket og lagt i repoet i den relevante mappe. Altså, repoet er som standard opbevaringsstedet for arbejdsversioner af applikationer. Så lad os spørge det, hvilke versioner der er på opbevaring. Den fjerne mappe vil vi curl'e, grep'e og awk'e. Hvis nogen er interesseret i en one-liner, så er den under spoiler.

Kommandoen i én linje
Vær opmærksom på to ting: jeg sender legitimationsoplysninger i headeren for at forbinde, og jeg har slet ikke brug for alle versioner fra mappen, jeg udvælger kun dem, der er oprettet inden for en måned. Redigér kommandoen i forhold til dine realiteter og behov:

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

Indstillingen af opgaverne og konfigurationsfilen til opgaven i Jenkins

Vi har nu klarlagt kilden til listen over versioner. Lad os derefter integrere den opnåede liste i opgaven. For mig var det indlysende at tilføje et trin i opgaven til at bygge applikationen. Et trin, der ville udføres, hvis resultatet var 'succes'.

Åbn indstillingerne for byggeopgaven og scroll ned til bunden. Klik på knapperne: Add build step -> Conditional step (single). I trinnes indstillinger vælger vi betingelsen Current build status, indstiller værdien SUCCESS, den udførte handling i tilfælde af succes Run shell command.

Og nu kommer det mest interessante. Jenkins-jobkonfigurationer gemmes i filer. I XML-format. På stien http://stien-til-jobbet/config.xml Derved kan vi downloade konfigurationsfilen, redigere den efter behov og lægge den tilbage, hvor vi tog den fra.

Husk, at vi tidligere er blevet enige om at lave en parameter til versionslisten. BUILD_VERSION?

Lad os downloade konfigurationsfilen og kigge indeni. Bare for at sikre, at parameteren er der og virkelig har det ønskede format.

Screenshot under spoileren.

Du skal have det uddrag af config.xml til at se således ud. Med den undtagelse, at indholdet af elementet choices endnu ikke er til stede.
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Har du bekræftet det? Nå, så skriver vi et script, der skal køres ved en vellykket bygning.
Scriptet vil hente en liste over versioner, downloade konfigurationsfilen, skrive listen over versioner ind i den ønskede placering i filen, og derefter lægge den tilbage. Ja. Det er korrekt. Skrive listen over versioner i XML'en på det sted, hvor der allerede er en liste over versioner (vil være i fremtiden, efter den første kørsel af scriptet). Jeg ved, at der stadig findes hardcore fans af regulære udtryk derude. Jeg tilhører ikke dem. Installer venligst xmlstarlet på den maskine, hvor konfigurationen skal redigeres. Jeg synes ikke, det er en så stor pris at betale for at undgå redigering af XML med sed.

Under spoileren giver jeg koden, der udfører ovenstående sekvens i sin helhed.

Vi skriver versionslisten fra mappen på den eksterne server ind i konfigurationen.

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

Hvis du bedre kan lide varianten med at hente versioner fra Jenkins, og du også er lige så doven som jeg, så er koden under spoileren den samme, men listen fra Jenkins:

Vi skriver versionslisten fra Jenkins ind i konfigurationen.
Bare husk dette: mit build-navn består af et rækkenummer og et versionsnummer, adskilt af et kolon. Derved klipper awk den unødvendige del. Tilpas denne linje efter dine behov.

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

Teoretisk set, hvis du har testet koden, der er baseret på eksemplerne ovenfor, skulle du allerede have en drop-down liste med versioner i deploy-jobbet. Sådan ser det cirka ud på screenshotet under spoileren.

Korrekt udfyldt liste over versioner.
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Hvis alt fungerede, så kopier scriptet til Run shell command og gem ændringerne.

Forbindelse til Cloud shell.

Vi har samlere i vores containere. Som leveringsmiddel til applikationer og konfigurationsmanager bruger vi Ansible. Når det kommer til byggecontainere, kommer jeg i tankerne på tre muligheder: at installere Docker i Docker, at installere Docker på en maskine med Ansible, eller at bygge containere i den cloud-baserede konsol. Om Jenkins-plugins har vi aftalt at holde os fra i denne note. Kan du huske det?

Jeg besluttede: nå, da containere "out of the box" kan bygges i den cloud-baserede konsol, hvorfor så besværliggøre det? Keep it clean, ikke sandt? Jeg vil bygge Jenkins-containere i den cloud-baserede konsol og derefter sende dem til Kubernetes derfra. Især fordi der inden for infrastrukturen hos Google er meget kraftige forbindelser, som vil være fordelagtige for deploy-hastigheden.

For at oprette forbindelse til den cloud-baserede konsol er der behov for to ting: gcloud og adgangsrettigheder til Google Cloud API for den VM-instans, som forbindelsen bliver udført fra.

For dem, der planlægger at oprette forbindelse fra et helt andet sted end Googles cloud
Google tillader muligheden for at deaktivere interaktiv godkendelse i sine tjenester. Dette vil gøre det muligt at oprette forbindelse til konsollen selv fra en kaffemaskine, hvis den kører *nix og har sin egen konsol.

Hvis der er behov for, at jeg uddyber dette emne i rammerne af denne note - skriv en kommentar. Hvis der kommer tilstrækkeligt mange stemmer - vil jeg skrive en opdatering om dette emne.

Den nemmeste måde at give adgangsrettigheder på er gennem webgrænsefladen.

  1. Stop VM-instansen, som i fremtiden vil blive brugt til at oprette forbindelse til den cloud-baserede konsol.
  2. Åbn instansoplysningerne og klik på Ændre.
  3. Nederst på siden vælg adgangsområde for instansen Fuldt adgang til alle Cloud API'er.

    Screenshot
    Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

  4. Gem ændringerne og start instansen.

Når VM'en er startet, skal du oprette forbindelse til den via SSH og sikre dig, at forbindelsen sker uden fejl. Brug kommandoen:

gcloud alpha cloud-shell ssh

En succesfuld forbindelse ser omtrent sådan ud
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Deploy til GKE

Da vi bestræber os på at overgå helt til IaC (Infrastructure as Code), gemmes vores Dockerfiles i Git. Det er den ene side. Og deployment til Kubernetes beskrives med en yaml-fil, der kun bruges til denne opgave, som i sig selv også er en slags kode. Det er den anden side. Generelt mener jeg, at planen er sådan:

  1. Vi tager værdierne for variablerne BUILD_VERSION og, og de valgfrie variabler, der vil blive sendt gennem ENV.
  2. Hent Dockerfile fra Git.
  3. Generer YAML til deployment.
  4. Upload begge disse filer via SCP til den cloud console.
  5. Byg der containeren og push den til Container registry.
  6. Anvend belastningsdeploymentsfilen i Kubernetes.

Lad os være mere specifikke. Når vi taler om ENV, lad os antage, at vi skal sende værdier for to parametre: PARAM1 og PARAM2. Tilføj deres opgave til deployment, type — Strengparameter.

Screenshot
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Vi genererer YAML ved simpel omdirigering echo til filen. Det antages selvfølgelig, at der i Dockerfile er tilstede PARAM1 og PARAM2, at belastningsnavnet bliver awesomeapp, og at den samlede container med den angivne version ligger i Container registry unix domain socket gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION, hvor $BUILD_VERSION som netop blev valgt fra dropdown-listen.

Liste over kommandoer

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

Til Jenkins-agenten, efter at have tilsluttet sig via gcloud alpha cloud-shell ssh er interaktiv tilstand ikke tilgængelig, så vi sender kommandoer til cloud console via parameteren --command.

Rydder hjemmebiblioteket i cloud console for den gamle Dockerfile:

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

Lægger den nydownloadede Dockerfile i hjemmebiblioteket i cloud console via SCP:

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

Samler, tagger og pusher containeren til Container registry:

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"

På samme måde gør vi med deploymentsfilen. Bemærk, at de nedenfor nævnte kommandoer bruger fiktive navne på klyngen, hvor deployment finder sted (awsm-cluster) og projektets navn (awesome-project), hvor klyngen befinder sig.

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"

Vi starter opgaven, åbner konsoloutputtet og håber at se en vellykket bygning af containeren.

Screenshot
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Og derefter en vellykket deployment af den samlede container.

Screenshot
Det hele begyndte med, at teamlederen for et af vores udviklingsteams bad om at eksponere deres nye applikation i testtilstand, som for nylig var blevet containeriseret.

Jeg har bevidst undgået opsætningen. IngressAf en simpel grund: når det først er sat op med et bestemt navn, vil det være i stand til at fungere, uanset hvor mange deployment der udføres med dette navn. Og generelt er det lidt uden for historien. workload Alle de ovenstående trin kunne sandsynligvis være undgået ved blot at installere en eller anden plugin til Jenkins, der er millioner af dem. Men af en eller anden grund kan jeg ikke lide plugins. Eller rettere sagt, jeg tyer kun til dem i desperation.

I stedet for konklusioner

Og jeg kan også rigtig godt lide at dykke ned i et eller andet nyt emne for mig. Teksten ovenfor er også en måde at dele de fund, jeg har gjort, mens jeg løste den opgave, der blev beskrevet i starten. At dele med dem, der ligesom ikke er en frygtelig ulv i devops. Hvis mine fund kan hjælpe bare én person, vil jeg være glad.

Vi laver en deployment-opgave i GKE uden plugins, sms'er og registrering. Vi kigger kort ind under Jenkins' frakke.

Kilde: habr.com

Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere 🔥 Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere | ProHoster