Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ease and document Tailor-based preview deploy #340

Merged
merged 8 commits into from
May 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ codenarc {
configFile = file('codenarc.groovy')
maxPriority1Violations = 0
maxPriority2Violations = 0
maxPriority3Violations = 1040
maxPriority3Violations = 1028
reportFormat = 'html'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ odsQuickstarterPipeline(

stage('Write go.mod') {
dir(context.targetDir) {
sh "go mod init module example.com/${context.projectId}/${context.componentId}'"
sh "go mod init module example.com/foo/bar"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,72 @@ you can follow the following steps:
git lfs migrate
----

=== Deploying OpenShift resources from source code

By default, the component pipeline uses existing OpenShift resources, and just creates new images / deployments related to them. However, it is possible to control all OpenShift resources in code, following the infrastructure-as-code approach. This can be done by defining the resources as https://docs.openshift.com/container-platform/3.11/dev_guide/templates.html[OpenShift templates] in the directory `openshift` of the repository, which will then get applied by https://github.com/opendevstack/tailor[Tailor] when running the pipeline. The advantage of this approach:

- All changes to OpenShift resources are traceble: who did the change and when?
- Moving your application between OpenShift projects or even clusters is trivial
- Changes to your application code that require a change in configuration (e.g. a new environment variable) as well can be done together in one commit.

If you have an existing component for which you want to enable this feature, you simply need to run:

[source,bash]
----
mkdir -p openshift
tailor -n foo-dev export -l app=foo-bar > openshift/template.yml
----

Commit the result and the component pipeline should show in the ouput whether there has been drift and how it was reconciled.

When using this approach, you need to keep a few things in mind:

- Any changes done in the OpenShift web console will effectively be reverted with each deploy. When you store templates in code, all changes must be applied to them.
- You can always preview the changes that will happen by running `tailor diff` from your local machine.
- `DeploymentConfig` resources allow to specify config and image triggers (and ODS configures them by default like this). When deploying via Tailor, it is recommended to remove the image trigger, otherwise you might trigger two deployments: one when config (such as an environment variable) changes, and one when the image changes.

Controlling your OpenShift resources in source code enables a lot of other use cases as well. For example, you might want to preview changes to a component before merging the source code. By using Tailor to deploy your templates, you create multiple running components from one repository, e.g. one per feature branch. To do this, you could do the following:

First, add `'feature/': 'dev'` to the `branchToEnvironmentMapping`. Then, create new variables in the pipeline block:
[source,groovy]
----
def componentSuffix = context.issueId ? "-${context.issueId}" : ''
def suffixedComponent = context.componentId + componentSuffix
----

With this in place, you can adapt the rollout stage:
[source,groovy]
----
odsComponentStageRolloutOpenShiftDeployment(
context,
[
resourceName: "${suffixedComponent}",
tailorSelector: "app=${context.projectId}-${suffixedComponent}",
tailorParams: ["COMPONENT_SUFFIX=${componentSuffix}"]
]
)
----

And finally, in your `openshift/template.yml`, you need to add the `COMPONENT_SUFFIX` parameter and append `${COMPONENT_SUFFIX}` everywhere the component ID is used in deployment relevant resources (such as `Service`, `DeploymentConfig`, `Route`). That's all you need to have automatic previews!

You might want to clean up when the code is merged, which can be achieved with something like this:
[source,groovy]
----
stage('Cleanup preview resources') {
if (context.environment != 'dev') {
echo "Not performing cleanup outside dev environment"; return
}
def mergedIssueId = org.ods.services.GitService.mergedIssueId(context.projectId, context.repoName, context.gitCommitMessage)
if (mergedIssueId) {
echo "Perform cleanup of suffix '-${mergedIssueId}'"
sh("oc -n ${context.targetProject} delete all -l app=${context.projectId}-${context.componentId}-${mergedIssueId}")
} else {
echo "Nothing to cleanup"
}
}
----


=== Automatically cloning environments on the fly

Caution! Cloning environments on-the-fly is an advanced feature and should only be used if you understand OpenShift well, as there are many moving parts and things can go wrong in multiple places.
Expand Down
268 changes: 123 additions & 145 deletions src/org/ods/component/Context.groovy
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.ods.component

import org.ods.util.Logger
import org.ods.services.GitService
import com.cloudbees.groovy.cps.NonCPS
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
Expand Down Expand Up @@ -285,6 +286,11 @@ class Context implements IContext {
config.componentId
}

@NonCPS
String getRepoName() {
config.repoName
}

String getGitCommit() {
config.gitCommit
}
Expand Down Expand Up @@ -358,99 +364,6 @@ class Context implements IContext {
return config.dockerDir
}

private String retrieveGitUrl() {
def gitUrl = script.sh(
returnStdout: true,
script: 'git config --get remote.origin.url',
label: 'getting GIT url'
).trim()
return gitUrl
}

private String retrieveGitCommit() {
script.sh(
returnStdout: true, script: 'git rev-parse HEAD',
label: 'getting GIT commit'
).trim()
}

private String retrieveGitCommitAuthor() {
script.sh(
returnStdout: true, script: "git --no-pager show -s --format='%an (%ae)' HEAD",
label: 'getting GIT commit author'
).trim()
}

private String retrieveGitCommitMessage() {
script.sh(
returnStdout: true, script: 'git log -1 --pretty=%B HEAD',
label: 'getting GIT commit message'
).trim()
}

private String retrieveLastSuccessfulCommit() {
def lastSuccessfulBuild = script.currentBuild.rawBuild.getPreviousSuccessfulBuild()
if (!lastSuccessfulBuild) {
logger.info 'There seems to be no last successful build.'
return ''
}
return commitHashForBuild(lastSuccessfulBuild)
}

private String commitHashForBuild(build) {
return build
.getActions(hudson.plugins.git.util.BuildData.class)
.find { action -> action.getRemoteUrls().contains(config.gitUrl) }
.getLastBuiltRevision().getSha1String()
}

private String[] retrieveGitCommitFiles(String lastSuccessfulCommitHash) {
if (!lastSuccessfulCommitHash) {
logger.info("Didn't find the last successful commit. Can't return the committed files.")
return []
}
return script.sh(
returnStdout: true,
label: 'getting git commit files',
script: "git diff-tree --no-commit-id --name-only -r ${config.gitCommit}"
).trim().split()
}

private String retrieveGitCommitTime() {
script.sh(
returnStdout: true,
script: 'git show -s --format=%ci HEAD',
label: 'getting GIT commit date/time'
).trim()
}

private String retrieveGitBranch() {
def branch
if (this.localCheckoutEnabled) {
def pipelinePrefix = "${config.openshiftProjectId}/${config.openshiftProjectId}-"
def buildConfigName = config.jobName.substring(pipelinePrefix.size())

def n = config.openshiftProjectId
branch = script.sh(
returnStdout: true,
label: 'getting GIT branch to build',
script: "oc get bc/${buildConfigName} -n ${n} -o jsonpath='{.spec.source.git.ref}'"
).trim()
} else {
// in case code is already checked out, OpenShift build config can not be used for retrieving branch
branch = script.sh(
returnStdout: true,
script: 'git rev-parse --abbrev-ref HEAD',
label: 'getting GIT branch to build').trim()
branch = script.sh(
returnStdout: true,
script: "git name-rev ${branch} | cut -d ' ' -f2 | sed -e 's|remotes/origin/||g'",
label: 'resolving to real GIT branch to build').trim()
}
logger.debug "resolved branch ${branch}"
return branch
}

boolean environmentExists(String name) {
def statusCode = script.sh(
script: "oc project ${name} &> /dev/null",
Expand All @@ -460,24 +373,8 @@ class Context implements IContext {
return statusCode == 0
}

// Given a branch like "feature/HUGO-4-brown-bag-lunch", it extracts
// "HUGO-4" from it.
private String extractBranchCode(String branch) {
if (branch.startsWith('feature/')) {
def list = branch.drop('feature/'.length()).tokenize('-')
"${list[0]}-${list[1]}"
} else if (branch.startsWith('bugfix/')) {
def list = branch.drop('bugfix/'.length()).tokenize('-')
"${list[0]}-${list[1]}"
} else if (branch.startsWith('hotfix/')) {
def list = branch.drop('hotfix/'.length()).tokenize('-')
"${list[0]}-${list[1]}"
} else if (branch.startsWith('release/')) {
def list = branch.drop('release/'.length()).tokenize('-')
"${list[0]}-${list[1]}"
} else {
branch
}
String getIssueId() {
GitService.issueIdFromBranch(config.gitBranch, config.projectId)
}

// This logic must be consistent with what is described in README.md.
Expand Down Expand Up @@ -528,40 +425,6 @@ class Context implements IContext {
config.cloneSourceEnv = ''
}

// Based on given +genericEnv+ (e.g. "preview") and +branchSuffix+ (e.g.
// "foo-123-bar"), it finds the most specific environment. This is either:
// - the +genericEnv+ suffixed with a numeric ticket ID
// - the +genericEnv+ suffixed with the +branchSuffix+
// - the +genericEnv+ without suffix
protected void setMostSpecificEnvironment(String genericEnv, String branchSuffix) {
def specifcEnv = genericEnv + '-' + branchSuffix

def ticketId = getTicketIdFromBranch(config.gitBranch, config.projectId)
if (ticketId) {
specifcEnv = genericEnv + '-' + ticketId
}

config.cloneSourceEnv = config.autoCloneEnvironmentsFromSourceMapping[genericEnv]
def autoCloneEnabled = !!config.cloneSourceEnv
if (autoCloneEnabled || environmentExists(specifcEnv)) {
config.environment = specifcEnv
} else {
config.environment = genericEnv
}
}

protected String getTicketIdFromBranch(String branchName, String projectId) {
def tokens = extractBranchCode(branchName).split('-')
def pId = tokens[0]
if (!pId || !pId.equalsIgnoreCase(projectId)) {
return ''
}
if (!tokens[1].isNumber()) {
return ''
}
return tokens[1]
}

Map<String, String> getCloneProjectScriptUrls() {
def scripts = ['clone-project.sh', 'import-project.sh', 'export-project.sh',]
def m = [:]
Expand Down Expand Up @@ -631,4 +494,119 @@ class Context implements IContext {
return config.domain
}

private String retrieveGitUrl() {
def gitUrl = script.sh(
returnStdout: true,
script: 'git config --get remote.origin.url',
label: 'getting GIT url'
).trim()
return gitUrl
}

private String retrieveGitCommit() {
script.sh(
returnStdout: true, script: 'git rev-parse HEAD',
label: 'getting GIT commit'
).trim()
}

private String retrieveGitCommitAuthor() {
script.sh(
returnStdout: true, script: "git --no-pager show -s --format='%an (%ae)' HEAD",
label: 'getting GIT commit author'
).trim()
}

private String retrieveGitCommitMessage() {
script.sh(
returnStdout: true, script: 'git log -1 --pretty=%B HEAD',
label: 'getting GIT commit message'
).trim()
}

private String retrieveLastSuccessfulCommit() {
def lastSuccessfulBuild = script.currentBuild.rawBuild.getPreviousSuccessfulBuild()
if (!lastSuccessfulBuild) {
logger.info 'There seems to be no last successful build.'
return ''
}
return commitHashForBuild(lastSuccessfulBuild)
}

private String commitHashForBuild(build) {
return build
.getActions(hudson.plugins.git.util.BuildData.class)
.find { action -> action.getRemoteUrls().contains(config.gitUrl) }
.getLastBuiltRevision().getSha1String()
}

private String[] retrieveGitCommitFiles(String lastSuccessfulCommitHash) {
if (!lastSuccessfulCommitHash) {
logger.info("Didn't find the last successful commit. Can't return the committed files.")
return []
}
return script.sh(
returnStdout: true,
label: 'getting git commit files',
script: "git diff-tree --no-commit-id --name-only -r ${config.gitCommit}"
).trim().split()
}

private String retrieveGitCommitTime() {
script.sh(
returnStdout: true,
script: 'git show -s --format=%ci HEAD',
label: 'getting GIT commit date/time'
).trim()
}

private String retrieveGitBranch() {
def branch
if (this.localCheckoutEnabled) {
def pipelinePrefix = "${config.openshiftProjectId}/${config.openshiftProjectId}-"
def buildConfigName = config.jobName.substring(pipelinePrefix.size())

def n = config.openshiftProjectId
branch = script.sh(
returnStdout: true,
label: 'getting GIT branch to build',
script: "oc get bc/${buildConfigName} -n ${n} -o jsonpath='{.spec.source.git.ref}'"
).trim()
} else {
// in case code is already checked out, OpenShift build config can not be used for retrieving branch
branch = script.sh(
returnStdout: true,
script: 'git rev-parse --abbrev-ref HEAD',
label: 'getting GIT branch to build').trim()
branch = script.sh(
returnStdout: true,
script: "git name-rev ${branch} | cut -d ' ' -f2 | sed -e 's|remotes/origin/||g'",
label: 'resolving to real GIT branch to build').trim()
}
logger.debug "resolved branch ${branch}"
return branch
}

// Based on given +genericEnv+ (e.g. "preview") and +branchSuffix+ (e.g.
// "foo-123-bar"), it finds the most specific environment. This is either:
// - the +genericEnv+ suffixed with a numeric ticket ID
// - the +genericEnv+ suffixed with the +branchSuffix+
// - the +genericEnv+ without suffix
private void setMostSpecificEnvironment(String genericEnv, String branchSuffix) {
def specifcEnv = genericEnv + '-' + branchSuffix

def ticketId = GitService.issueIdFromBranch(config.gitBranch, config.projectId)
if (ticketId) {
specifcEnv = genericEnv + '-' + ticketId
}

config.cloneSourceEnv = config.autoCloneEnvironmentsFromSourceMapping[genericEnv]
def autoCloneEnabled = !!config.cloneSourceEnv
if (autoCloneEnabled || environmentExists(specifcEnv)) {
config.environment = specifcEnv
} else {
config.environment = genericEnv
}
}

}
Loading