Delivery Pipelines as Code – Ein Überblick
Wer sich in letzter Zeit intensiver damit beschäftigt hat, wie Software vom Entwicklungsteam in Produktion gelangt, der ist höchstwahrscheinlich schon über Konzepte wie "Continuous Delivery" und "Infrastructure as Code" gestolpert. Für viele von uns sind sie selbstverständlich. Sie erlauben uns, Software und Infrastruktur jederzeit zuverlässig, sicher, reproduzierbar, getestet und in hoher Qualität zu liefern.
Und doch sind wir nicht immer konsequent: Gerade das Herzstück des Delivery-Prozesses, die Implementierung der Continuous Delivery-Pipeline – sei es nun mit Jenkins, GoCD oder einem anderen Tool - verstößt oft genug gegen viele Best Practices: Der Server steht unter dem Schreibtisch des Kollegen, in mühsamer Handarbeit über Jahre gehegt und gepflegt und ein nach vielen Schmerzen sorgsam zusammengebautes Konstrukt aus Delivery-Tools, speziellen Plugin-Versionen, Inline-Scripts, Konfiguration und Betriebssystem-Paketen enthält er das gesamte Wissen einer Organisation wie Software entwickelt, gebaut, getestet und released wird.
Doch dieses Setup gerät schnell an seine Grenzen: Was, wenn ein Betriebssystem-Upgrade fällig wird oder eine Änderung eine neue Version des Jenkins-Pipeline-Plugins benötigt? Wie sicher sind wir uns, dass diese Änderung nichts kaputt macht? Was, wenn doch etwas schief geht? Wenn die altersschwache Festplatte den Geist aufgibt oder der Kollege einfach nur aus Versehen den falschen Button geklickt hat und die Pipeline ins Nirvana befördert hat? Bekommen wir die Daten jemals wieder? Wer kann sich noch erinnern, welche Tools und Plugins installiert waren, wie sie konfiguriert waren und welche Scripts liefen? Haben wir eine Versionskontrolle, die uns sagen kann, wer, wann, warum welche Änderungen gemacht hat? Kann ein neuer Entwickler ohne weiteres verstehen, wie die Pipeline funktioniert und können wir sie schnell an wechselnde Bedürfnisse anpassen? Wie lange dauert es, das Ganze für ein neues Projekt aufzusetzen?
Infrastructure Automation to the Rescue!
Einigen von uns kommen diese Probleme vermutlich bekannt vor: Es sind genau diese Probleme, die uns früher das Infrastrukturleben zur Hölle machten und uns motivierten, unsere Systeme zu scripten und in Code zu beschreiben. Aber aus irgendeinem Grund ist diese Denkweise oft nicht bei unserem Delivery-Prozess angekommen. Schlamperei ist hier scheinbar erlaubt, es ist ja schließlich kein Produktivsystem. Aber heißt das, dass es nicht wichtig ist? Können wir uns bei unserem Build-Server Fehler erlauben?
Unsere Build-Infrastruktur verdient Liebe!
Wenn wir Continuous Delivery ernst nehmen ist der Build-Server alles, was zwischen einem Commit und einer Änderung im Produktivsystem steht: Er koordiniert den Prozess, führt unsere Tests und Deployments aus, interpretiert und dokumentiert Ergebnisse und erlaubt autorisierten Personen kritische Aktionen auszuführen. In den meisten Fällen verfügt er auch über weitreichende Rechte, vom Zugriff auf Versionskontrollsysteme und Testinstanzen bis zum SSH-Zugriff auf die gesamte Infrastruktur.
Fehler können hier also fatale Folgen haben: Eine kaputte oder verloren gegangene Pipeline verhindert das Ausrollen von wertvollen Features, Fixes für peinliche Bugs oder sicherheitskritische Patches. Eine falsche Konfiguration führt zu nicht erkannten Problemen, defekten Deployments oder schweren Sicherheitslücken. Und in jedem Fall ist das Produktivsystem betroffen, sei es von Änderungen, die dort nicht hin sollten (z. B. weil ein roter Test nach einem Konfigurationsfehler die Pipeline nicht mehr stoppt) oder von fehlenden Verbesserungen (z. B. weil eine kaputte Pipeline das Deployment verhindert).
Die Tools, die unseren Delivery-Prozess implementieren, verdienen also Liebe und die selben Ansprüche an Qualität, Lesbarkeit, Automatisierung, Testbarkeit und Nachvollziehbarkeit wie unsere Produktivumgebung.
Dieser Artikel versucht, dies anschaulich zu machen und für einige Tools (Jenkins [1], GoCD [2], LambdaCD [3] und Concourse [4]) Wege aufzuzeigen, diese vollautomatisch konfigurieren zu können. Ziel ist eine Continuous Delivery-Infrastruktur, die sich per Knopfdruck aufsetzen, per Commit ändern und in Code warten lässt.
Die Grundlage: Infrastructure as Code
Der erste Schritt zum Aufsetzen einer Continuous Delivery-Infrastruktur ist in jedem Fall das Installieren und Konfigurieren der nötigen Tools: Der Build-Server selbst, die verwendeten Compiler und Laufzeitumgebungen, Build- und Testtools, Xvfb, Browser, Anbindung an Directories, Logging, Monitoring und Mail-Systeme und vieles mehr. Nichts davon ist übermäßig schwierig, trotzdem ist man auch für einen einzelnen Server schnell einige Stunden oder Tage beschäftigt, all diese Schritte manuell auszuführen. Und in fast allen Fällen erinnert man sich am Ende doch nicht an alle Details und stößt erst im Lauf der Zeit auf fehlende oder falsche Konfiguration.
Will man mehrere Build-Agents betreiben, vervielfacht sich dieser Aufwand schnell, vom Aufwand, ein Setup zwischen mehreren Agents konsistent zu halten, ganz abgesehen. Jeder, der sich schon einmal gewundert hat, warum Tests nur auf einem von drei Agents laufen, wird dieses Problem kennen. Wollen wir also ein Setup, das reproduzierbar und konsistent ausgerollt werden kann, das wir testen, versionieren und nach unseren Wünschen anpassen können, dann müssen wir hier automatisieren.
Zum Glück stehen uns hierfür inzwischen eine Reihe von ausgereiften Tools zur Verfügung: Puppet [5], Chef [6], Ansible [7] und weitere eignen sich nicht nur hervorragend zum Aufsetzen des Produktivsystems, sie sollten auch für alle anderen Systeme verwendet werden. Für die meisten Tools stellt die Community darüber hinaus Module zur Verfügung, die das Installieren des Build-Servers und wichtiger Plugins vereinfachen:
include jenkins
jenkins::plugin { 'git':
version => '1.1.11',
}
Des Weiteren helfen Tools wie rspec-puppet, serverspec und infrataster, unseren Infrastruktur-Code zu testen.
Pipelines as Code
Haben wir unseren Build-Server und ggf. Agents und andere Bestandteile der Infrastruktur erfolgreich automatisch provisioniert, so stehen wir jetzt vor einem mächtigen, bis jetzt aber leeren und damit noch völlig nutzlosen Continuous Delivery-Setup. Das gilt es jetzt mit Leben, bzw. Pipelines zu füllen.
Es wäre an dieser Stelle möglich, automatisiert Backups eines bereits existierenden, manuell konfigurierten Systems einzuspielen. Module wie puppet-jenkins [8] bieten dafür sogar vorgefertigte Lösungen um z. B. Jenkins mit XML-Konfiguration zu füllen. Dieser Workflow lässt allerdings im täglichen Betrieb einiges zu wünschen übrig: Zum Einen erschweren die üblicherweise Snapshot-basierten Backups die Nachvollziehbarkeit: Wir sehen zwar Konfigurationsänderungen zwischen Snapshots, aber nicht unbedingt, wer diese Änderungen durchgeführt hat und warum. Zum Anderen sind Änderungen an internen Konfigurationsdaten in der Regel nicht besonders gut zu lesen (ganz zu schweigen von Merge-Konflikten in komplexem XML). Glücklicherweise verfügen die meisten Continuous Delivery-Tools über Möglichkeiten, Pipelines in Text deklarativ oder mithilfe einer Programmiersprache zu beschreiben und wie normalen Code zu versionieren. Im Folgenden wollen wir uns einige dieser Möglichkeiten genauer ansehen.
Der alte Hase: Jenkins JobDSL
Das Jenkins-JobDSL-Plugin [9] ist vermutlich eine der bekanntesten und ältesten Möglichkeiten, CI/CD-Server mithilfe einer deklarativen Sprache zu konfigurieren. Hierbei handelt es sich um eine Groovy-DSL, die XML im Jenkins-Konfigurationsformat generiert. Typischerweise existiert ein Seed-Job, der JobDSL-Code ausführt, um den Rest der Konfiguration zu generieren.
Die etwas mühsame und unübersichtliche Konfiguration einer Pipeline in Jenkins, mit einer Kombination von Downstream-Relationen zwischen Jobs, Parameterized Builds, Fork/Join-plugin und View kann die JobDSL also nur vereinfachen, nicht aber grundlegend verbessern.
repo = 'https://github.com/flosell/simple-maven-project-with-tests.git'
def deployQAJobName = deployJob("qa")
def deployCIJobName = deployJob("ci", deployQAJobName)
job("build") {
scm {
git(repo, "master")
}
triggers {
scm('H/15 * * * *')
}
steps {
shell("mvn test")
}
publishers {
downstream(deployCIJobName, 'SUCCESS')
}
}
deliveryPipelineView('pipeline view') {
pipelineInstances(5)
showAggregatedPipeline()
columns(2)
sorting(Sorting.TITLE)
updateInterval(60)
enableManualTriggers()
showAvatars()
showChangeLog()
pipelines {
component('The System', 'build')
}
}
def deployJob(environment, downstreamJob = null) {
def jobName = "deploy ${environment}"
job(jobName) {
scm {
git(repo, "master")
}
steps {
shell("./deploy.sh ${environment}")
}
if (downstreamJob) {
publishers {
downstream(downstreamJob, 'SUCCESS')
}
}
}
return jobName
}
Sie ist aber in jedem Fall ein einfach umzusetzender erster Schritt hin zu versionierter Pipeline-Konfiguration für alle Entwickler, die bisher Jenkins-Konfigurationen manuell und unversioniert warten.
Jenkins Revisited: Jenkins 2 mit Pipeline-Plugin
Mit dem Pipeline-Plugin und Jenkins 2 versucht die Jenkins-Community Continuous Delivery-Pipelines und Pipelines as Code endlich zum First Class Citizen zu machen:
Das Pipeline-Plugin [10] vereinheitlicht hierfür was bisher verstreut über Freestyle Jobs, Fork/Join-Plugin, Pipeline-Visualisierungen und ähnliches implementiert wurde. Als Nutzer definiert man Pipelines in Groovy Scripts, die die Orchestrierung der Pipeline-Jobs übernehmen:
stage name: 'build and test'
node {
git url: 'https://github.com/flosell/simple-maven-project-with-tests.git'
def mvnHome = tool 'M3'
sh "${mvnHome}/bin/mvn -B -Dmaven.test.failure.ignore verify"
step([$class: 'ArtifactArchiver', artifacts: '**/target/*.jar', fingerprint: true])
step([$class: 'JUnitResultArchiver', testResults: '**/target/surefire-reports/TEST-*.xml'])
}
stage name: 'ci and qa'
parallel(ci: {
node {
deploy('ci')
}
},qa: {
node {
deploy('qa')
}
})
stage name: 'prod'
input 'Ready for Production?'
node {
deploy('prod')
}
def deploy(String env) {
echo "deploying to ${env}"
sh "deploy.sh ${env}"
}
Anders als bisher dient der Code hier nicht mehr dazu, Konfiguration zu generieren. Stattdessen ist es der Code selbst, der von Jenkins beim Build ausgeführt wird und den Pipeline-Ablauf orchestriert. Der Jenkins-Server selbst wird zur Laufzeit- und Visualisierungsumgebung reduziert, wohingegen die Pipeline selbst in den Code wandert.
Dadurch wird nicht nur die Konfiguration übersichtlicher und versionierbar. Man kann nun auf die Features einer mächtigen Programmiersprache zurückgreifen, um Pipelines zu entwickeln.
Insgesamt ist das Pipeline-Plugin also eine starke Verbesserung, wenn auch die Jenkins-UI zur Visualisierung von Pipelines weiterhin Nachholbedarf hat (z. B. werden parallele Schritte momentan nicht explizit dargestellt).
BlueOcean [11], eine neu entwickelte UI für Jenkins, sollte viele dieser Probleme angehen. Für den Moment sind allerdings viele Features noch nicht implementiert oder ausgereift, sodass sie die alte Jenkins UI zur Zeit noch nicht ersetzen kann.
Der Pipeline-Experte: GoCD mit GoMatic
Wer nach einem Tool sucht, das viele Jahre Erfahrung mit Continuous Delivery abbildet, für den ist GoCD [2] eine gute Option. Ursprünglich von einem Team um Jez Humble (dem Co-Autor von Continuous Delivery) bei ThoughtWorks (der Firma hinter CruiseControl, dem ersten CI-Server überhaupt) entwickelt, ist es inzwischen als Open Source-Tool für jedermann verfügbar. Ähnlich wie bei Jenkins ist das Konzept von Pipelines as Code hier nicht von vornherein eingebaut. Stattdessen kann diese Funktionalität mithilfe der Python-Bibliothek Gomatic [12] nachgerüstet werden.
Hierbei handelt es sich – im Gegensatz zu den Lösungen für Jenkins – nicht um ein Plugin, sondern um eine Bibliothek, die es möglich macht, die GoCD-API aus Python-Code anzusprechen und so Pipelines zu konfigurieren. Man hat also die gesamte Stärke von Python zur Verfügung, um Pipelines zu beschreiben, zu testen, Abstraktionen zu bauen und wiederzuverwenden. Ähnlich wie bei Jenkins ist dies aber ein reiner Konfigurationsmechanismus. Es gibt keine Möglichkeit, GoCDs Verhalten selbst zu beeinflussen. Hierfür sind weiterhin wie bei Jenkins separate Pluginschnittstellen nötig. Beschrieben werden Pipelines im von GoCD bekannten Datenmodell aus Pipelines, Pipeline Material, Stages, Tasks und Jobs:
#!/usr/bin/env python
from gomatic import *
def ensure_deployment(env):
pipeline.ensure_stage("deploy"+env) \
.ensure_job("deploy"+env) \
.add_task(ExecTask(['/bin/bash', '-c', './deploy.sh '+env]))
configurator = GoCdConfigurator(HostRestClient("localhost:8153"))
pipeline_group = configurator.ensure_pipeline_group("demo-pipeline-group")
pipeline = pipeline_group \
.ensure_replacement_of_pipeline("demo-pipeline") \
.set_git_url("https://github.com/flosell/simple-maven-project-with-tests.git")
pipeline.ensure_stage("test")\
.ensure_job("test") \
.add_task(ExecTask(['/bin/bash', '-c', 'mvn test']))
ensure_deployment('ci')
ensure_deployment('qa')
configurator.save_updated_config()
Für Umsteiger besonders interessant ist Gomatics reverse-engineering Feature. Mit einem einfachen Befehl
python -m gomatic.go_cd_configurator -s <GoCD server hostname> -p <pipeline name> > pipeline.py
kann hiermit der Gomatic-Code für eine von Hand gepflegte Pipeline generiert werden.
Für Fortgeschrittene: LambdaCD
Einen etwas radikaleren Weg als die anderen Tools geht LambdaCD [3]: Es schafft die Trennung zwischen Build-Server und dessen Konfiguration komplett ab. Stattdessen wird hier der Build-Server selbst mit allen seinen Funktionen zur Bibliothek, aus der sich Entwickler bedienen können, um einen Build-Server ganz nach ihren Wünschen zusammenzustellen.
Der "as code"-Ansatz wird hier konsequent verfolgt: Es existiert keine Oberfläche, mit der die Konfiguration am Code vorbei manuell verändert werden könnte. Stattdessen wird das gesamte Verhalten der Pipelines in Clojure-Code definiert: die Pipeline ist eine Clojure-Datenstruktur, Build Steps und Kontrollfluss werden über Clojure-Funktionen implementiert.
(ns lambdacd-example.core
(:require
[ring.server.standalone :as ring-server]
[clojure.tools.logging :as log]
[lambdacd.ui.ui-server :as ui]
[lambdacd.runners :as runners]
[lambdacd.util :as util]
[lambdacd.core :as lambdacd]
[lambdacd.steps.manualtrigger :as manualtrigger]
[lambdacd.steps.control-flow :refer [either with-workspace]]
[lambdacd-git.core :as lambdacd-git]
[lambdacd.steps.shell :as shell])
(:gen-class))
; --- build steps: ---
(def repo-uri "https://github.com/flosell/simple-maven-project-with-tests.git")
(def repo-branch "master")
(defn clone [args ctx]
(lambdacd-git/clone ctx repo-uri repo-branch (:cwd args)))
(defn wait-for-commit [args ctx]
(lambdacd-git/wait-for-git ctx repo-uri))
(defn run-tests [args ctx]
(shell/bash ctx (:cwd args)
"mvn test"))
(defn deploy [env]
(fn [args ctx]
(shell/bash ctx (:cwd args)
(str "echo deploying to " env)
(str "./deploy.sh" env))))
; --- pipeline structure: ---
(def pipeline-structure
`(
(either
wait-for-commit
manualtrigger/wait-for-manual-trigger)
(with-workspace
clone
run-tests
(deploy :ci)
(deploy :qa))))
; --- wiring to start the server and pipeline ---
(defn -main [& args]
(let [home-dir (util/create-temp-dir)
config {:home-dir home-dir
:name "lambdacd example"}
pipeline (lambdacd/assemble-pipeline pipeline-structure config)
app (ui/ui-for pipeline)]
(log/info "LambdaCD Home Directory is " home-dir)
(runners/start-one-run-after-another pipeline)
(ring-server/serve app {:open-browser? false
:port 8080})))
Durch diese Architektur können Entwickler die Build-Pipeline wie ein gewöhnliches Stück Software behandeln: Zum Betrieb und Deployment können damit die selben Mechanismen genutzt werden, die das Entwicklungsteam bereits für "normale" Applikationen verwendet.
Zur Entwicklung steht das gesamte Clojure-Ökosystem bereit, um Build-Steps und Kontrollfluss den eigenen Wünschen anzupassen, zu testen und über Teams hinweg wiederzuverwenden. Es ist daher das vermutlich flexibelste der hier angesprochenen Tools. Mit dieser Flexibilität kommt allerdings auch eine gewisse Lernkurve, will man die Möglichkeiten des Tools auch tatsächlich ausschöpfen. Auch in Sachen Feature-Reichtum und Community bleibt dieses noch relativ junge Tool hinter seinen großen und älteren Konkurrenten zurück.
Für YAML-Freunde: Concourse
Von den hier vorgestellten Tools ist Concourse [4] vermutlich am schwierigsten aufzusetzen: Im vollen Ausbaustadium hat man es hier mit einem kompletten BOSH Cluster zu tun. Zum Glück bieten die Entwickler inzwischen Vagrant-Boxen und Docker-Container an, die für den Anfang völlig ausreichen.
Concourse entstand ursprünglich als CI/CD-Lösung für das PaaS-Projekt CloudFoundry und ist daher ähnlich wie GoCD optimiert auf komplexere Continuous Delivery-Pipelines in großen Softwareprojekten. Auch hier wird der "as code"-Gedanke konsequent verfolgt: Pipelines werden ausschließlich über YAML-Files definiert und per API in den Build-Server deployed.
Diese YAML-Files definieren Tasks, Resources und Jobs. Dabei definieren Tasks die auszuführenden Aufgaben einer Pipeline, z. B. das Ausführen von Tests oder das Erstellen von deployment-Artefakten. Git-Repositories für Source-Code oder S3-Buckets zum Ablegen von Artefakten werden als Resources modelliert. Jobs bringen beide Konzepte zusammen, sie definieren die Ein- und Ausgaberessourcen für einen Task. Die Pipeline entsteht über diese Abhängigkeitsstrukturen. Zur einfacheren Wartbarkeit können Tasks parametrisiert und in eigene Dateien ausgelagert werden, die zusammen mit der Anwendung versioniert werden.
---
resources:
- name: repo
type: git
source:
uri: github.com/flosell/simple-maven-project-with-tests.git
branch: concourse
jobs:
- name: test
plan:
- get: repo
trigger: true
- task: run-tests
config:
platform: linux
image_resource:
type: docker-image
source: {repository: maven}
inputs:
- name: repo
run:
path: "mvn"
args: ["test"]
dir: repo
- task: deploy-ci
file: repo/hello-deploy.yaml
params:
TARGET_ENV: "ci"
- task: deploy-qa
file: repo/hello-deploy.yaml
params:
TARGET_ENV: "qa"
---
platform: linux
image_resource:
type: docker-image
source: {repository: maven}
run:
path: "bash"
args: ["-c", "./deploy.sh $TARGET_ENV"]
dir: "repo"
inputs:
- name: repo
Pipelines as Code im täglichen Leben
Da wir jetzt einen guten Überblick über die Eigenschaften und die grundlegende Mechanik verschiedener Tools haben, sei im Folgenden noch auf einige Aspekte hingewiesen, die wir für das tägliche Leben mit unseren Continuous Delivery-Tools im Hinterkopf behalten sollten.
Testing und lokaler Workflow
Wenn wir Software entwickeln, erwarten wir in aller Regel diese auf unserem eigenen Laptop ausführen und testen zu können, bevor wir Änderungen committen oder gar auf ein Produktivsystem ausrollen. Dieser Standard sollte auch für unsere Delivery-Pipelines gelten. Bei Tools wie LambdaCD, die keinen Unterschied zwischen Konfiguration und Laufzeitumgebung machen, ist dies trivial: Der Code auf dem Rechner des Entwicklers beschreibt den kompletten Build-Server und kann dementsprechend einfach lokal ausgeführt und getestet werden.
Andere Tools, die Konfiguration und Laufzeitumgebung trennen, machen das schwieriger. Wenn sie, wie die Lösungen für Jenkins und GoCD, auf einer vollwertigen Programmiersprache basieren, ist es allerdings zumindest möglich, Unit-Tests zu schreiben und die generierte Konfiguration zu inspizieren. So kann die Jenkins JobDSL mit geringem Aufwand [13] lokal ausgeführt werden, um die generierte Jenkins XML-Konfiguration zu inspizieren. Gomatic enthält eine dry-run-Funktionalität mit deren Hilfe sich nicht nur die generierte Konfiguration sondern auch Änderungen zum aktuellen Stand inspizieren lassen. In beiden Fällen lassen sich allerdings nur sehr begrenzt Aussagen darüber treffen, wie sich die generierte Konfiguration im Build-Server tatsächlich verhalten wird.
Concourse bietet solche Möglichkeiten nur sehr begrenzt: Während die YAML-Konfigurationssprache selbst verglichen mit denen anderer Tools weniger komplex und fehleranfällig ist, so ist das Zusammenspiel von Tasks, Jobs und Resources nicht immer intuitiv. Hier würde man sich eine Möglichkeit wünschen, diese Konfiguration lokal zu testen. Concourse bietet solche Möglichkeiten momentan allerdings nur für Tasks an. Sie können auch außerhalb des Pipeline-Kontexts direkt vom Entwicklerrechner auf dem Cluster ausgeführt werden.
Wiederverwendbarkeit
In vielen großen Softwareprojekten existiert mehr als nur eine Pipeline und innerhalb dieser evtl. auch Schritte, die sich ähnlich sehen. Deployments sollten z. B. für alle Umgebungen identisch ablaufen. Wir sollten also in der Lage sein, solche Aspekte zu abstrahieren und wiederzuverwenden. Hier hilft es, wie bei GoCD, Jenkins und LambdaCD, eine universelle Programmiersprache in der Hinterhand zu haben um Variablendefinition, Methoden, Bibliotheken und ähnliches auszunutzen um Pipeline-Konfigurationen möglichst lesbar, duplikatfrei und wartbar zu halten. Concourses YAML-basierte Konfiguration lässt dies nur sehr begrenzt zu, man ist hier auf die Möglichkeiten der Konfigurationssprache selbst angewiesen.
Eine Pipeline für die Pipeline
Ist die Provisionierung des Continuous Delivery-Systems einmal in Code gegossen, empfiehlt es sich, sie auch als solche zu behandeln: Änderungen an Pipelines oder an der Infrastruktur passieren nur noch über den Code im Versionskontrollsystem und werden durch eine eigene Pipeline validiert, getestet und ausgerollt. Manuelle Änderungen an der Konfiguration sollten ab jetzt die Ausnahme sein oder gleich ganz unterbunden werden, z. B. indem Zugriff über SSH und Admin-UI blockiert wird. Dies mag für den Einen oder Anderen am Anfang eine Umstellung bedeuten, es schafft aber die Grundlage für eine Delivery-Infrastruktur, der wir vertrauen können: Code definiert und dokumentiert alles, was nötig ist, um Software zu bauen, zu testen und zu releasen. Er ist verständlich, les- und wartbar. Wir können unsere Infrastruktur automatisiert aufbauen, testen und anpassen. Wir können Änderungen nachvollziehen und wenn nötig zurückrollen.
Kurz: Unsere Delivery-Infrastruktur entspricht Qualitätsstandards, die für unsere Software schon lange selbstverständlich sind.