From f8c219e32ada40700645bb1f175a317021020e30 Mon Sep 17 00:00:00 2001 From: Philipp Markiewka Date: Tue, 27 Oct 2020 23:56:21 +0100 Subject: [PATCH] Implements gitops flow Signed-off-by: Philipp Markiewka --- Jenkinsfile | 208 +++++++++++++++++++++++++++++---- k8s/production/deployment.yaml | 23 ++++ k8s/production/service.yaml | 16 +++ k8s/staging/deployment.yaml | 23 ++++ k8s/staging/service.yaml | 16 +++ pom.xml | 1 + 6 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 k8s/production/deployment.yaml create mode 100644 k8s/production/service.yaml create mode 100644 k8s/staging/deployment.yaml create mode 100644 k8s/staging/service.yaml diff --git a/Jenkinsfile b/Jenkinsfile index 15a7a43a5..17a6d56ff 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,22 +3,10 @@ // "Constants" String getCesBuildLibVersion() { '1.44.3' } String getCesBuildLibRepo() { 'https://github.com/cloudogu/ces-build-lib/' } -String getAppDockerRegistry() { 'eu.gcr.io/cloudogu-backend' } -String getCloudoguDockerRegistry() { 'ecosystem.cloudogu.com' } -String getRegistryCredentials() { 'jenkins' } -String getScmManagerCredentials() { 'jenkins' } -String getKubectlImage() { 'lachlanevenson/k8s-kubectl:v1.18.2' } -String getJdkImage() { 'bellsoft/liberica-openjdk-alpine' } -String getHelmImage() { "${cloudoguDockerRegistry}/build-images/helm-kubeval:gcl308.0.0-alpine-helmv3.3.1" } -String getKubevalImage() { 'garethr/kubeval:0.15.0' } -String getGitVersion() { '2.24.3' } -String getConfigRepositoryUrl() { "https://ecosystem.cloudogu.com/scm/repo/backend/k8s-gitops" } -String getConfigRepositoryPRUrl() { 'ecosystem.cloudogu.com/scm/api/v2/pull-requests/backend/k8s-gitops' } -String getDockerRegistryBaseUrl() { "https://console.cloud.google.com/gcr/images/cloudogu-backend/eu/${application}.cloudogu.com" } -String getHelmReleaseChartSourceUrl() { "ssh://k8s-git-ops@ecosystem.cloudogu.com:2222/repo/backend/myCloudogu-helm-chart" } -// Redundant definition of helm repo, because Flux connects via SSH and Jenkins via HTTPS. Best would be to use SSH on Jenkins -> No redundancy -String getHelmChartRepoSourceUrl() { "https://ecosystem.cloudogu.com/scm/repo/backend/myCloudogu-helm-chart" } -String getHelmChartTag() { "1.0.4" } +String getScmManagerCredentials() { 'scmm-user' } +String getConfigRepositoryUrl() { "http://scmm-scm-manager:9091/scm/repo/cluster/gitops" } +String getConfigRepositoryPRUrl() { 'http://scmm-scm-manager:9091/scm/api/v2/pull-requests/cluster/gitops' } +String getDockerRegistryBaseUrl() { "localhost:9092" } properties([ // Don't run concurrent builds, because the ITs use the same port causing random failures on concurrent builds. @@ -34,19 +22,23 @@ node { mvn = cesBuilbLib.MavenWrapper.new(this) + application = "spring-petclinic-plain" + catchError { stage('Checkout') { checkout scm + git.clean('') } + stage('Build') { mvn 'clean package -DskipTests' archiveArtifacts artifacts: '**/target/*.jar' } - String jacoco = "org.jacoco:jacoco-maven-plugin:0.8.1" + String jacoco = "org.jacoco:jacoco-maven-plugin:0.8.5" stage('Test') { mvn "${jacoco}:prepare-agent test ${jacoco}:report" @@ -60,24 +52,42 @@ node { stage('Static Code Analysis') { - } + String imageName = "" stage('Docker') { + String imageTag = createImageTag() + imageName = "${dockerRegistryBaseUrl}/${application}:${imageTag}" + mvn "spring-boot:build-image -DskipTests -Dspring-boot.build-image.imageName=${imageName}" + if (isBuildSuccessful()) { def docker = cesBuilbLib.Docker.new(this) - - String imageTag = createImageTag() - imageName = "docker-registry:9092/petclinic-plain:${imageTag}" - def dockerImage = docker.build(imageName) - dockerImage.push() + // The docker daemon cant use the k8s service name, because it is not running inside the cluster + docker.withRegistry("http://${dockerRegistryBaseUrl}") { + def image = docker.image(imageName) + image.push() + } } else { - echo 'Skipping docker push, because build not successful or neither on develop branch nor "forceDeployStaging" param set' + echo 'Skipping docker push, because build not successful' } } stage('Deploy') { + if (isBuildSuccessful()) { + def gitopsConfig = [ + scmmCredentialsId: scmManagerCredentials, + scmmConfigRepoUrl: configRepositoryUrl, + scmmPullRequestUrl: configRepositoryPRUrl, + updateImages: [ + [ deploymentFilename: "deployment.yaml", + containerName: "spring-petclinic-plain", + imageName: imageName ] + ] + ] + + pushToConfigRepo(gitopsConfig) + } } } @@ -85,6 +95,98 @@ node { junit allowEmptyResults: true, testResults: '**/target/failsafe-reports/TEST-*.xml,**/target/surefire-reports/TEST-*.xml' } +String pushToConfigRepo(Map gitopsConfig) { + + def git = cesBuilbLib.Git.new(this, scmManagerCredentials) + def applicationRepo = GitRepo.create(git) + def changesOnGitOpsRepo = '' + + git.committerName = 'Jenkins' + git.committerEmail = 'jenkins@cloudogu.com' + + def configRepoTempDir = '.configRepoTempDir' + + + try { + + dir(configRepoTempDir) { + + git url: gitopsConfig.scmmConfigRepoUrl, branch: 'master', changelog: false, poll: false + git.fetch() + + def repoChanges = new HashSet() + repoChanges += createApplicationForStageAndPushToBranch 'staging', 'master', applicationRepo, git, gitopsConfig + + git.checkoutOrCreate(application) + repoChanges += createApplicationForStageAndPushToBranch 'production', application, applicationRepo, git, gitopsConfig + + changesOnGitOpsRepo = aggregateChangesOnGitOpsRepo(repoChanges) + + if (changesOnGitOpsRepo) { + createPullRequest(gitopsConfig) + } + } + } finally { + sh "rm -rf ${configRepoTempDir}" + } + + + if (changesOnGitOpsRepo) { + // with GitOps we can only add a deployment marker for staging yet +// addDeploymentAnnotationToGrafana("staging") + } + + return changesOnGitOpsRepo +} + +private String aggregateChangesOnGitOpsRepo(changes) { + // Remove empty + (changes - '') + // and concat into string + .join('; ') +} + +String createApplicationForStageAndPushToBranch(String stage, String branch, GitRepo applicationRepo, def git, Map gitopsConfig) { + + String commitPrefix = stage == 'staging' ? '[S] ' : '' + + sh "mkdir -p ${stage}/${application}" + // copy extra resources like sealed secrets + echo "Copying k8s payload from application repo to gitOps Repo: 'k8s/${stage}/*' to '${stage}/${application}'" + sh "cp ${env.WORKSPACE}/k8s/${stage}/* ${stage}/${application}/ || true" + + gitopsConfig.updateImages.each { + updateImageVersion("${stage}/${application}/${it['deploymentFilename']}", it['containerName'], it['imageName']) + } + + git.add('.') + if (git.areChangesStagedForCommit()) { + git.commit(commitPrefix + createApplicationCommitMessage(git, applicationRepo), applicationRepo.authorName, applicationRepo.authorEmail) + + // If some else pushes between the pull above and this push, the build will fail. + // So we pull if push fails and try again + git.pushAndPullOnFailure("origin ${branch}") + return "${stage} (${git.commitHashShort})" + } else { + echo "No changes on gitOps repo for ${stage} (branch: ${branch}). Not committing or pushing." + return '' + } +} + +/** + * Reflect build parameters in commit message used for GitOps. + * This is meant to bring more transparency into GitOps repo. + */ +String createApplicationCommitMessage(def git, def applicationRepo) { + String issueIds = (applicationRepo.commitMessage =~ /#\d*/).collect { "${it} " }.join('') + + String[] urlSplit = applicationRepo.repositoryUrl.split('/') + def repoNamespace = urlSplit[-2] + def repoName = urlSplit[-1] + String message = "${issueIds}${repoNamespace}/${repoName}@${applicationRepo.commitHash}" + + return message +} /** * Reflect build parameters version name. @@ -101,3 +203,61 @@ String createImageTag() { return "${new Date().format('yyyyMMddHHmm')}-${git.commitHashShort}${branchSuffix}" } + +void updateImageVersion(String deploymentFilePath, String containerName, String newImageTag) { + def data = readYaml file: deploymentFilePath + def containers = data.spec.template.spec.containers + def updateContainer = containers.find {it.name == containerName} + updateContainer.image = newImageTag + writeYaml file: deploymentFilePath, data: data, overwrite: true +} + +void createPullRequest(Map gitopsConfig) { + + withCredentials([usernamePassword(credentialsId: gitopsConfig.scmmCredentialsId, passwordVariable: 'GIT_PASSWORD', usernameVariable: 'GIT_USER')]) { + + String script = + 'curl -s -o /dev/null -w "%{http_code}" ' + + "-u ${GIT_USER}:${GIT_PASSWORD} " + + '-H "Content-Type: application/vnd.scmm-pullRequest+json;v=2" ' + + '--data \'{"title": "created by service ' + application + '", "source": "' + application + '", "target": "master"}\' ' + + gitopsConfig.scmmPullRequestUrl + + // For debugging the quotation of the shell script, just do: echo script + String http_code = sh returnStdout: true, script: script + + // At this point we could write a mail to the last committer that his commit triggered a new or updated GitOps PR + + echo "http_code: ${http_code}" + // PR exists if we get 409 + if (http_code != "201" && http_code != "409") { + unstable 'Could not create pull request' + } + } +} + +String application +def cesBuilbLib + +class GitRepo { + + static GitRepo create(git) { + // Constructors can't be used in Jenkins pipelines due to CPS + // https://www.jenkins.io/doc/book/pipeline/cps-method-mismatches/#constructors + return new GitRepo(git.commitAuthorName, git.commitAuthorEmail ,git.commitHashShort, git.commitMessage, git.repositoryUrl) + } + + GitRepo(String authorName, String authorEmail, String commitHash, String commitMessage, String repositoryUrl) { + this.authorName = authorName + this.authorEmail = authorEmail + this.commitHash = commitHash + this.commitMessage = commitMessage + this.repositoryUrl = repositoryUrl + } + + final String authorName + final String authorEmail + final String commitHash + final String commitMessage + final String repositoryUrl +} diff --git a/k8s/production/deployment.yaml b/k8s/production/deployment.yaml new file mode 100644 index 000000000..217863ea4 --- /dev/null +++ b/k8s/production/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spring-petclinic-plain + namespace: production +spec: + replicas: 1 + template: + metadata: + labels: + app: spring-petclinic-plain + spec: + containers: + - name: spring-petclinic-plain + image: localhost:9092/petclinic-plain:1 + ports: + - containerPort: 8080 + env: + - name: SOME_ENV + value: "Some Value 12345" + selector: + matchLabels: + app: spring-petclinic-plain diff --git a/k8s/production/service.yaml b/k8s/production/service.yaml new file mode 100644 index 000000000..e442d0447 --- /dev/null +++ b/k8s/production/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + namespace: production + name: spring-petclinic-plain + labels: + app: spring-petclinic-plain +spec: + type: LoadBalancer + ports: + - port: 9094 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: spring-petclinic-plain diff --git a/k8s/staging/deployment.yaml b/k8s/staging/deployment.yaml new file mode 100644 index 000000000..91d12a145 --- /dev/null +++ b/k8s/staging/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spring-petclinic-plain + namespace: staging +spec: + replicas: 1 + template: + metadata: + labels: + app: spring-petclinic-plain + spec: + containers: + - name: spring-petclinic-plain + image: localhost:9092/petclinic-plain:1 + ports: + - containerPort: 8080 + env: + - name: SOME_ENV + value: "Some Value 12345" + selector: + matchLabels: + app: spring-petclinic-plain diff --git a/k8s/staging/service.yaml b/k8s/staging/service.yaml new file mode 100644 index 000000000..a675f12af --- /dev/null +++ b/k8s/staging/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + namespace: staging + name: spring-petclinic-plain + labels: + app: spring-petclinic-plain +spec: + type: LoadBalancer + ports: + - port: 9093 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: spring-petclinic-plain diff --git a/pom.xml b/pom.xml index 9102a51c7..5cde85678 100644 --- a/pom.xml +++ b/pom.xml @@ -241,6 +241,7 @@ + true true yyyy-MM-dd'T'HH:mm:ssZ true