- package org.wikimedia.integration
-
- import com.cloudbees.groovy.cps.NonCPS
-
- import static org.wikimedia.integration.Utility.timestampLabel
-
- import org.wikimedia.integration.ExecutionContext
- import org.wikimedia.integration.PatchSet
- import org.wikimedia.integration.Pipeline
-
- class PipelineStage implements Serializable {
- static final String SETUP = 'setup'
- static final String TEARDOWN = 'teardown'
- static final List STEPS = ['build', 'run', 'publish', 'deploy', 'exports']
-
-
- Pipeline pipeline
- String name
- Map config
-
- private def context
-
- /**
- * Returns an config based on the given one but with default values
- * inserted.
- *
- * @example Shorthand stage config (providing only a stage name)
- * <pre><code>
- * def cfg = [name: "foo"]
- *
- * assert PipelineStage.defaultConfig(cfg) == [
- * name: "foo",
- * build: '${.stage}', // builds a variant by the same name
- * run: [
- * image: '${.imageID}', // runs the variant built by this stage
- * arguments: [],
- * ],
- * ]
- * </code></pre>
- *
- * @example Configuring `run: true` means run the variant built by this
- * stage
- * <pre><code>
- * def cfg = [name: "foo", build: "foo", run: true]
- *
- * assert PipelineStage.defaultConfig(cfg) == [
- * name: "foo",
- * build: "foo",
- * run: [
- * image: '${.imageID}', // runs the variant built by this stage
- * arguments: [],
- * ],
- * ]
- * </code></pre>
- *
- * @example Publish image default configuration
- * <pre><code>
- * def cfg = [image: true]
- * def defaults = PipelineStage.defaultConfig(cfg)
- *
- * // publish.image.id defaults to the previously built image
- * assert defaults.publish.image.id == '${.imageID}'
- *
- * // publish.image.name defaults to the project name
- * assert defaults.publish.image.name == '${setup.project}'
- *
- * // publish.image.tag defaults to {timestamp}-{stage name}
- * assert defaults.publish.image.tag == '${setup.timestamp}-${.stage}'
- * </code></pre>
- */
- @NonCPS
- static Map defaultConfig(Map cfg) {
- Map dcfg
-
- // shorthand with just name is: build and run a variant
- if (cfg.size() == 1 && cfg["name"]) {
- dcfg = cfg + [
- build: '${.stage}',
- run: [
- image: '${.imageID}',
- ]
- ]
- } else {
- dcfg = cfg.clone()
- }
-
- if (dcfg.run) {
- // run: true means run the built image
- if (dcfg.run == true) {
- dcfg.run = [
- image: '${.imageID}',
- ]
- } else {
- dcfg.run = dcfg.run.clone()
- }
-
- // run.image defaults to previously built image
- dcfg.run.image = dcfg.run.image ?: '${.imageID}'
-
- // run.arguments defaults to []
- dcfg.run.arguments = dcfg.run.arguments ?: []
- }
-
- if (dcfg.publish) {
- def pcfg = dcfg.publish.clone()
-
- if (pcfg.image) {
- if (pcfg.image == true) {
- pcfg.image = [:]
- } else {
- pcfg.image = pcfg.image.clone()
- }
-
- // publish.image.id defaults to the previously built image
- pcfg.image.id = pcfg.image.id ?: '${.imageID}'
-
- // publish.image.name defaults to the project name
- pcfg.image.name = pcfg.image.name ?: "\${${SETUP}.project}"
-
- // publish.image.tag defaults to {timestamp}-{stage name}
- pcfg.image.tag = pcfg.image.tag ?: "\${${SETUP}.timestamp}-\${.stage}"
-
- pcfg.image.tags = (pcfg.image.tags ?: []).clone()
- }
-
- if (pcfg.files) {
- pcfg.files.paths = pcfg.files.paths.clone()
- }
-
- dcfg.publish = pcfg
- }
-
- if (dcfg.deploy) {
- dcfg.deploy = dcfg.deploy.clone()
-
- dcfg.deploy.image = dcfg.deploy.image ?: '${.publishedImage}'
- dcfg.deploy.cluster = dcfg.deploy.cluster ?: "ci"
- dcfg.deploy.test = dcfg.deploy.test == null ? true : dcfg.deploy.test
- }
-
- dcfg
- }
-
- PipelineStage(Pipeline pline, String stageName, Map stageConfig, nodeContext) {
- pipeline = pline
- name = stageName
- config = stageConfig
- context = nodeContext
- }
-
- /**
- * Constructs and retruns a closure for this pipeline stage using the given
- * Jenkins workflow script object.
- */
- Closure closure(ws) {
- ({
- def runner = pipeline.runner(ws)
-
- context["stage"] = name
-
- switch (name) {
- case SETUP:
- setup(ws, runner)
- break
- case TEARDOWN:
- teardown(ws, runner)
- break
- default:
- ws.echo("running steps in ${pipeline.directory} with config: ${config.inspect()}")
-
- ws.dir(pipeline.directory) {
- for (def stageStep in STEPS) {
- if (config[stageStep]) {
- ws.echo("step: ${stageStep}, config: ${config.inspect()}")
- this."${stageStep}"(ws, runner)
- }
- }
- }
- }
- })
- }
-
- /**
- * Returns a set of node labels that will be required for this stage to
- * function correctly.
- */
- Set getRequiredNodeLabels() {
- def labels = [] as Set
-
- if (config.build || config.run) {
- labels.add("blubber")
- }
-
- if (config.publish) {
- if (config.publish.files) {
- labels.add("blubber")
- }
-
- if (config.publish.image) {
- labels.add("dockerPublish")
- }
- }
-
- labels
- }
-
- /**
- * Performs setup steps, checkout out the repo and binding useful values to
- * be used by all other stages (default image labels, project identifier,
- * timestamp, etc).
- *
- * <h3>Exports</h3>
- * <dl>
- * <dt><code>${setup.project}</code></dt>
- * <dd>ZUUL_PROJECT parameter value if getting a patchset from Zuul.</dd>
- * <dd>Jenkins JOB_NAME value otherwise.</dd>
- *
- * <dt><code>${setup.timestamp}</code></dt>
- * <dd>Timestamp at the start of pipeline execution. Used in image tags, etc.</dd>
- *
- * <dt><code>${setup.imageLabels}</code></dt>
- * <dd>Default set of image labels:
- * <code>jenkins.job</code>,
- * <code>jenkins.build</code>,
- * <code>ci.project</code>,
- * <code>ci.pipeline</code>
- * </dd>
- * </dl>
- */
- void setup(ws, runner) {
- def imageLabels = [
- "jenkins.job": ws.env.JOB_NAME,
- "jenkins.build": ws.env.BUILD_ID,
- ]
-
- if (ws.params.ZUUL_REF) {
- def patchset = PatchSet.fromZuul(ws.params)
- ws.checkout(patchset.getSCM())
- context["project"] = patchset.project.replaceAll('/', '-')
- imageLabels["zuul.commit"] = patchset.commit
- } else {
- ws.checkout(ws.scm)
- context["project"] = ws.env.JOB_NAME
- }
-
- imageLabels["ci.project"] = context['project']
- imageLabels["ci.pipeline"] = pipeline.name
-
- context["timestamp"] = timestampLabel()
- context["imageLabels"] = imageLabels
- }
-
- /**
- * Performs teardown steps, removing images and helm releases, and reporting
- * back to Gerrit.
- */
- void teardown(ws, runner) {
- try {
- runner.removeImages(context.getAll("imageID").values())
- } catch (all) {}
-
- try {
- runner.purgeReleases(context.getAll("releaseName").values())
- } catch (all) {}
-
- def imageTags = context.getAll("imageTags")
- context.getAll("publishedImage").collect { stageName, image ->
- try {
- runner.reportToGerrit(image, imageTags[stageName] ?: [])
- } catch (all) {}
- }
- }
-
- /**
- * Builds the configured Blubber variant.
- *
- * <h3>Configuration</h3>
- * <dl>
- * <dt><code>build</code></dt>
- * <dd>Blubber variant name</dd>
- * </dl>
- *
- * <h3>Example</h3>
- * <pre><code>
- * stages:
- * - name: candidate
- * build: production
- * </code></pre>
- *
- * <h3>Exports</h3>
- * <dl>
- * <dt><code>${[stage].imageID}</code></dt>
- * <dd>Image ID of built image.</dd>
- * </dl>
- */
- void build(ws, runner) {
- def imageID = runner.build(context % config.build, context["setup.imageLabels"])
-
- context["imageID"] = imageID
- }
-
- /**
- * Runs the entry point of a built image variant.
- *
- * <h3>Configuration</h3>
- * <dl>
- * <dt><code>run</code></dt>
- * <dd>Image to run and entry-point arguments</dd>
- * <dd>Specifying <code>run: true</code> expands to
- * <code>run: { image: '${.imageID}' }</code>
- * (i.e. the image built in this stage)</dd>
- * <dd>
- * <dl>
- * <dt><code>image</code></dt>
- * <dd>An image to run</dd>
- * <dd>Default: <code>{$.imageID}</code></dd>
- *
- * <dt><code>arguments</code></dt>
- * <dd>Entry-point arguments</dd>
- * <dd>Default: <code>[]</code></dd>
- * </dl>
- * </dd>
- * </dl>
- *
- * <h3>Example</h3>
- * <pre><code>
- * stages:
- * - name: test
- * build: test
- * run: true
- * </code></pre>
- *
- * <h3>Example</h3>
- * <pre><code>
- * stages:
- * - name: built
- * - name: lint
- * run:
- * image: '${built.imageID}'
- * arguments: [lint]
- * - name: test
- * run:
- * image: '${built.imageID}'
- * arguments: [test]
- * </code></pre>
- */
- void run(ws, runner) {
- runner.run(
- context % config.run.image,
- config.run.arguments.collect { context % it },
- )
- }
-
- /**
- * Publish artifacts, either files or a built image variant (pushed to the
- * WMF Docker registry).
- *
- * <h3>Configuration</h3>
- * <dl>
- * <dt><code>publish</code></dt>
- * <dd>
- * <dl>
- * <dt><code>image</code></dt>
- * <dd>Publish an to the WMF Docker registry</dd>
- * <dd>
- * <dl>
- * <dt>id</dt>
- * <dd>ID of a previously built image variant</dd>
- * <dd>Default: <code>${.imageID}</code> (image built in this stage)</dd>
- *
- * <dt>name</dt>
- * <dd>Published name of the image. Note that this base name will be
- * prefixed with the globally configured registry/repository name
- * before being pushed.</dd>
- * <dd>Default: <code>${setup.project}</code> (project identifier;
- * see {@link setup()})</dd>
- *
- * <dt>tag</dt>
- * <dd>Primary tag under which the image is published</dd>
- * <dd>Default: <code>${setup.timestamp}-${.stage}</code></dd>
- *
- * <dt>tags</dt>
- * <dd>Additional tags under which to publish the image</dd>
- * </dl>
- * </dd>
- * </dl>
- * </dd>
- * <dd>
- * <dl>
- * <dt><code>files</code></dt>
- * <dd>Extract and save files from a previously built image variant</dd>
- * <dd>
- * <dl>
- * <dt>paths</dt>
- * <dd>Globbed file paths resolving any number of files under the
- * image's root filesystem</dd>
- * </dl>
- * </dd>
- * </dl>
- * </dd>
- * </dl>
- *
- * <h3>Exports</h3>
- * <dl>
- * <dt><code>${[stage].imageName}</code></dt>
- * <dd>Short name under which the image was published</dd>
- *
- * <dt><code>${[stage].imageFullName}</code></dt>
- * <dd>Fully qualified name (registry/repository/imageName) under which the
- * image was published</dd>
- *
- * <dt><code>${[stage].imageTag}</code></dt>
- * <dd>Primary tag under which the image was published</dd>
- *
- * <dt><code>${[stage].publishedImage}</code></dt>
- * <dd>Full qualified name and tag (<code>${.imageFullName}:${.imageTag}</code>)</dd>
- * </dl>
- */
- void publish(ws, runner) {
- if (config.publish.image) {
- def publishImage = config.publish.image
-
- def imageID = context % publishImage.id
- def imageName = context % publishImage.name
- def imageTags = ([publishImage.tag] + publishImage.tags).collect { context % it }
-
- for (def tag in imageTags) {
- runner.registerAs(
- imageID,
- imageName,
- tag,
- )
- }
-
- context["imageName"] = imageName
- context["imageFullName"] = runner.qualifyRegistryPath(imageName)
- context["imageTag"] = context % publishImage.tag
- context["imageTags"] = imageTags
- context["publishedImage"] = context % '${.imageFullName}:${.imageTag}'
- }
-
- if (config.publish.files) {
- // TODO
- }
- }
-
- /**
- * Deploy a published image to a WMF k8s cluster. (Currently only the "ci"
- * cluster is supported for testing.)
- *
- * <h3>Configuration</h3>
- * <dl>
- * <dt><code>deploy</code></dt>
- * <dd>
- * <dl>
- * <dt>image</dt>
- * <dd>Reference to a previously published image</dd>
- * <dd>Default: <code>${.publishedImage}</code> (image published in the
- * {@link publish() publish step} of this stage)</dd>
- *
- * <dt>cluster</dt>
- * <dd>Cluster to target</dd>
- * <dd>Default: <code>"ci"</code></dd>
- * <dd>Currently only "ci" is supported and this configuration is
- * effectively ignored</dd>
- *
- * <dt>chart</dt>
- * <dd>URL of Helm chart to use for deployment</dd>
- * <dd>Required</dd>
- *
- * <dt>test</dt>
- * <dd>Whether to run <code>helm test</code> against this deployment</dd>
- * <dd>Default: <code>true</code></dd>
- * </dl>
- * </dd>
- * </dl>
- *
- * <h3>Exports</h3>
- * <dl>
- * <dt><code>${[stage].releaseName}</code></dt>
- * <dd>Release name of new deployment</dd>
- * </dl>
- */
- void deploy(ws, runner) {
- def release = runner.deployWithChart(
- context % config.deploy.chart,
- context % config.deploy.image,
- context % config.deploy.tag,
- )
-
- context["releaseName"] = release
-
- if (config.deploy.test) {
- runner.testRelease(release)
- }
- }
-
- /**
- * Binds a number of new values for reference in subsequent stages.
- *
- * <h3>Configuration</h3>
- * <dl>
- * <dt><code>exports</code></dt>
- * <dd>Name/value pairs for additional exports.</dd>
- * </dl>
- *
- * <h3>Example</h3>
- * <pre><code>
- * stages:
- * - name: candidate
- * build: production
- * exports:
- * image: '${.imageID}'
- * tag: '${.imageTag}-my-tag'
- * - name: published
- * publish:
- * image:
- * id: '${candidate.image}'
- * tags: ['${candidate.tag}']
- * </code></pre>
- *
- * <h3>Exports</h3>
- * <dl>
- * <dt><code>${[name].[value]}</code></dt>
- * <dd>Each configured name/value pair.</dd>
- * </dl>
- */
- void exports(ws, runner) {
- config.exports.each { export, value ->
- context[export] = context % value
- ws.echo "exported ${name}.${export}=${context[export].inspect()}"
- }
- }
- }
|