package org.wikimedia.integration
|
|
|
|
import java.io.FileNotFoundException
|
|
|
|
import static org.wikimedia.integration.Utility.*
|
|
|
|
import org.wikimedia.integration.GerritPipelineComment
|
|
import org.wikimedia.integration.GerritReview
|
|
|
|
/**
|
|
* Provides an interface to common pipeline build/run/deploy functions.
|
|
*
|
|
* You must provide the Jenkins workflow script sandbox object that will be
|
|
* used to declare Jenkins pipeline steps.
|
|
*
|
|
* {@code
|
|
* // With just the Jenkins context
|
|
* def pipeline = new PipelineRunner(this)
|
|
*
|
|
* // Or with a map of additional settings
|
|
* def pipeline = new PipelineRunner(this, configPath: "dist/pipeline", registry: "foo.registry")
|
|
* }
|
|
*/
|
|
class PipelineRunner implements Serializable {
|
|
/**
|
|
* Relative path to Blubber config file.
|
|
*/
|
|
def blubberConfig = "blubber.yaml"
|
|
|
|
/**
|
|
* Base URL for the Blubberoid service.
|
|
*/
|
|
def blubberoidURL = "https://blubberoid.wikimedia.org/v1/"
|
|
|
|
/**
|
|
* Directory in which pipeline configuration is stored.
|
|
*/
|
|
def configPath = ".pipeline"
|
|
|
|
/**
|
|
* Relative path to Helm config file.
|
|
*/
|
|
def helmConfig = "helm.yaml"
|
|
|
|
/**
|
|
* Absolute path to a Kubernetes config file to specify when executing
|
|
* `helm` or other k8s related commands. By default, none will be specified.
|
|
*/
|
|
def kubeConfig = null
|
|
|
|
/**
|
|
* Namespace used for Helm/Kubernetes deployments.
|
|
*/
|
|
def namespace = "ci"
|
|
|
|
/**
|
|
* Docker pull policy when deploying.
|
|
*/
|
|
def pullPolicy = "IfNotPresent"
|
|
|
|
/**
|
|
* Default Docker registry host used when qualifying public image URLs.
|
|
*/
|
|
def registry = "docker-registry.wikimedia.org"
|
|
|
|
/**
|
|
* Alternative Docker registry host used only when registering images.
|
|
*/
|
|
def registryInternal = "docker-registry.discovery.wmnet"
|
|
|
|
/**
|
|
* Default Docker registry repository used for tagging and registering images.
|
|
*/
|
|
def repository = "wikimedia"
|
|
|
|
/**
|
|
* Timeout for deployment using Helm.
|
|
*/
|
|
def timeout = 120
|
|
|
|
/**
|
|
* Jenkins Pipeline Workflow context.
|
|
*/
|
|
final def workflowScript
|
|
|
|
/**
|
|
* Constructor with Jenkins workflow script context and settings.
|
|
*
|
|
* @param settings Property map.
|
|
* @param workflowScript Jenkins workflow script sandbox object.
|
|
*/
|
|
PipelineRunner(Map settings = [:], workflowScript) {
|
|
this.workflowScript = workflowScript
|
|
|
|
settings.each { prop, value -> this.@"${prop}" = value }
|
|
}
|
|
|
|
/**
|
|
* Builds the given image variant and returns an ID for the resulting image.
|
|
*
|
|
* @param variant Image variant name that should be built.
|
|
* @param labels Additional name/value labels to add to the image metadata.
|
|
*/
|
|
String build(String variant, Map labels = [:]) {
|
|
def cfg = getConfigFile(blubberConfig)
|
|
|
|
if (!workflowScript.fileExists(cfg)) {
|
|
throw new FileNotFoundException("failed to build image: no Blubber config found at ${cfg}")
|
|
}
|
|
|
|
def blubber = new Blubber(workflowScript, cfg, blubberoidURL)
|
|
def dockerfile = getTempFile("Dockerfile.")
|
|
|
|
workflowScript.writeFile(text: blubber.generateDockerfile(variant), file: dockerfile)
|
|
|
|
def labelFlags = labels.collect { k, v -> "--label ${arg(k + "=" + v)}" }.join(" ")
|
|
def dockerBuild = "docker build --pull ${labelFlags} --file ${arg(dockerfile)} ."
|
|
|
|
def output = workflowScript.sh(returnStdout: true, script: dockerBuild)
|
|
|
|
// Return just the image ID from `docker build` output
|
|
output.substring(output.lastIndexOf(" ") + 1).trim()
|
|
}
|
|
|
|
/**
|
|
* Deploys the given registered image using the configured Helm chart and
|
|
* returns the name of the release.
|
|
*
|
|
* @param imageName Name of the registered image to deploy.
|
|
* @param imageTag Tag of the registered image to use.
|
|
* @param overrides Additional Helm value overrides to set.
|
|
*/
|
|
String deploy(String imageName, String imageTag, Map overrides = [:]) {
|
|
def cfg = workflowScript.readYaml(file: getConfigFile(helmConfig))
|
|
|
|
assert cfg instanceof Map && cfg.chart : "you must define 'chart: <helm chart url>' in ${cfg}"
|
|
|
|
deployWithChart(cfg.chart, imageName, imageTag, overrides)
|
|
}
|
|
|
|
/**
|
|
* Deploys the given registered image using the given Helm chart and returns
|
|
* the name of the release.
|
|
*
|
|
* @param chart Chart URL.
|
|
* @param imageName Name of the registered image to deploy.
|
|
* @param imageTag Tag of the registered image to use.
|
|
* @param overrides Additional Helm value overrides to set.
|
|
*/
|
|
String deployWithChart(String chart, String imageName, String imageTag, Map overrides = [:]) {
|
|
def values = [
|
|
"docker.registry": registry,
|
|
"docker.pull_policy": pullPolicy,
|
|
"main_app.image": [repository, imageName].join("/"),
|
|
"main_app.version": imageTag,
|
|
] + overrides
|
|
|
|
values = values.collect { k, v -> arg(k + "=" + v) }.join(",")
|
|
|
|
def release = imageName + "-" + randomAlphanum(8)
|
|
|
|
helm("install --namespace=${arg(namespace)} --set ${values} -n ${arg(release)} " +
|
|
"--debug --wait --timeout ${timeout} ${arg(chart)}")
|
|
|
|
release
|
|
}
|
|
|
|
/**
|
|
* Returns a path under configPath to the given config file.
|
|
*
|
|
* @param filePath Relative file path.
|
|
*/
|
|
String getConfigFile(String filePath) {
|
|
[configPath, filePath].join("/")
|
|
}
|
|
|
|
/**
|
|
* Returns a path under configPath to a temp file with the given base name.
|
|
*
|
|
* @param baseName File base name.
|
|
*/
|
|
String getTempFile(String baseName) {
|
|
getConfigFile("${baseName}${randomAlphanum(8)}")
|
|
}
|
|
|
|
/**
|
|
* Deletes and purges the given Helm release.
|
|
*
|
|
* @param release Previously deployed release name.
|
|
*/
|
|
void purgeRelease(String release) {
|
|
purgeReleases([release])
|
|
}
|
|
|
|
/**
|
|
* Deletes and purges the given Helm release.
|
|
*
|
|
* @param release Previously deployed release name.
|
|
*/
|
|
void purgeReleases(List releases) {
|
|
if (releases.size() > 0) {
|
|
helm("delete --purge ${args(releases)}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Name and push an image specified by the given image ID to the WMF Docker
|
|
* registry.
|
|
*
|
|
* The repo name is enforced as "docker-registry.wikimedia.org", and the
|
|
* remote path prefix is enforced as "/wikimedia/".
|
|
*
|
|
* {@code
|
|
* // Pushes built image to docker-registry.wikimedia.org/wikimedia/mathoid:build-123
|
|
* def image = pipeline.build("production")
|
|
* pipeline.registerAs(image, "mathoid", "build-123")
|
|
* }
|
|
* @param imageID Image ID.
|
|
* @param name Remote name to use for the image.
|
|
* @param tag Remote tag to use for the image.
|
|
*/
|
|
String registerAs(String imageID, String name, String tag) {
|
|
def nameAndTag = qualifyRegistryPath(name, registryInternal) + ":" + tag
|
|
|
|
workflowScript.sh("docker tag ${arg(imageID)} ${arg(nameAndTag)} && " +
|
|
"sudo /usr/local/bin/docker-pusher ${arg(nameAndTag)}")
|
|
|
|
nameAndTag
|
|
}
|
|
|
|
/**
|
|
* Removes the given image from the local cache. All tags are removed from
|
|
* the image as well.
|
|
*
|
|
* @param imageID ID of the image to remove.
|
|
*/
|
|
void removeImage(String imageID) {
|
|
removeImages([imageID])
|
|
}
|
|
|
|
/**
|
|
* Removes the given images from the local cache.
|
|
*
|
|
* @param imageIDs IDs of images to remove.
|
|
*/
|
|
void removeImages(List imageIDs) {
|
|
if (imageIDs.size() > 0) {
|
|
workflowScript.sh("docker rmi --force ${args(imageIDs)}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submits a comment to Gerrit with the build result and links to published
|
|
* images.
|
|
*
|
|
* @param imageName Fully qualified name of published image.
|
|
* @param imageTags Image tags.
|
|
*/
|
|
void reportToGerrit(imageName, imageTags = []) {
|
|
def comment
|
|
|
|
if (workflowScript.currentBuild.result == 'SUCCESS' && imageName) {
|
|
comment = new GerritPipelineComment(
|
|
jobName: workflowScript.env.JOB_NAME,
|
|
buildNumber: workflowScript.env.BUILD_NUMBER,
|
|
jobStatus: workflowScript.currentBuild.result,
|
|
image: imageName,
|
|
tags: imageTags,
|
|
)
|
|
} else {
|
|
comment = new GerritPipelineComment(
|
|
jobName: workflowScript.env.JOB_NAME,
|
|
buildNumber: workflowScript.env.BUILD_NUMBER,
|
|
jobStatus: workflowScript.currentBuild.result,
|
|
)
|
|
}
|
|
|
|
GerritReview.post(workflowScript, comment)
|
|
}
|
|
|
|
/**
|
|
* Runs a container using the image specified by the given ID.
|
|
*
|
|
* @param imageID Image ID.
|
|
* @param arguments Entry-point arguments.
|
|
*/
|
|
void run(String imageID, List arguments = []) {
|
|
workflowScript.timeout(time: 20, unit: "MINUTES") {
|
|
workflowScript.sh("exec docker run --rm ${args([imageID] + arguments)}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fully qualifies an image name to a public registry path.
|
|
*
|
|
* @param name Image name.
|
|
* @param registryName Alternative registry. Defaults to {@link registry}.
|
|
*/
|
|
String qualifyRegistryPath(String name, String registryName = "") {
|
|
assert !name.contains("/") : "image name ${name} cannot contain slashes"
|
|
|
|
[registryName ?: registry, repository, name].join("/")
|
|
}
|
|
|
|
/**
|
|
* Runs end-to-end tests for the given release via `helm test`.
|
|
*
|
|
* @param release Previously deployed release name.
|
|
*/
|
|
void testRelease(String release) {
|
|
helm("test --cleanup ${arg(release)}")
|
|
}
|
|
|
|
private
|
|
|
|
/**
|
|
* Execute a helm command, specifying the right tiller namespace.
|
|
*/
|
|
void helm(String cmd) {
|
|
kubeCmd("helm --tiller-namespace=${arg(namespace)} ${cmd}")
|
|
}
|
|
|
|
/**
|
|
* Execute a Kubernetes related command, specifying the configured
|
|
* kubeConfig.
|
|
*/
|
|
void kubeCmd(String cmd) {
|
|
def env = kubeConfig ? "KUBECONFIG=${arg(kubeConfig)} " : ""
|
|
workflowScript.sh("${env}${cmd}")
|
|
}
|
|
}
|