From 4d0070040ad200a04c92764a27465b3534d82d1b Mon Sep 17 00:00:00 2001 From: William Petit Date: Thu, 17 Aug 2023 07:43:26 -0600 Subject: [PATCH] feat(standard-make): create standard make-based pipeline --- README.md | 24 +- doc/README.md | 29 +++ doc/tutorials/standard-make-pipeline.md | 123 +++++++++ .../com/cadoles/standard-make/Dockerfile | 26 ++ vars/standardMakePipeline.groovy | 246 ++++++++++++++++++ vars/utils.groovy | 35 +++ 6 files changed, 461 insertions(+), 22 deletions(-) create mode 100644 doc/README.md create mode 100644 doc/tutorials/standard-make-pipeline.md create mode 100644 resources/com/cadoles/standard-make/Dockerfile create mode 100644 vars/standardMakePipeline.groovy diff --git a/README.md b/README.md index a60127f..890b055 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,9 @@ Utilitaires pour la création de pipeline Jenkins dans l'environnement Cadoles. -## Pipelines +## Documentation -- [Pipeline d'empaquetage Debian](./pipelines/debian-packaging.jenkinsfile) - -## Librairie - -### Méthodes exposées - -#### Création de paquets - -- [`tamarin.buildPackage()`](./vars/tamarin.groovy#L48) -- [`tamarin.buildPackageWithCPKG()`](./vars/tamarin.groovy#L1) - -#### Publication de paquets - -- [`vulcain.publish()`](./vars/vulcain.groovy#L1) - -#### Pilotage d'OpenNebula - -- [`nebula.initWithCredentials()`](./vars/nebula.groovy#L125) -- [`nebula.runInNewVM() { client -> ... }`](./vars/nebula.groovy#L135) -- [`client.findVMTemplate()`](./vars/nebula.groovy#L65) -- [`client.withNewVM()`](./vars/nebula.groovy#L79) +Voir le répertoire [`./doc`](./doc) ## Licence diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..3ba588e --- /dev/null +++ b/doc/README.md @@ -0,0 +1,29 @@ +# Documentation + +## Tutoriels + +- [Utilisation du pipeline `standardMakePipeline()`](./tutorials/standard-make-pipeline.md) + +## Pipelines + +- [Pipeline d'empaquetage Debian](../pipelines/debian-packaging.jenkinsfile) + +## Librairie + +### Méthodes exposées + +#### Création de paquets + +- [`tamarin.buildPackage()`](../vars/tamarin.groovy#L48) +- [`tamarin.buildPackageWithCPKG()`](../vars/tamarin.groovy#L1) + +#### Publication de paquets + +- [`vulcain.publish()`](../vars/vulcain.groovy#L1) + +#### Pilotage d'OpenNebula + +- [`nebula.initWithCredentials()`](../vars/nebula.groovy#L125) +- [`nebula.runInNewVM() { client -> ... }`](../vars/nebula.groovy#L135) +- [`client.findVMTemplate()`](../vars/nebula.groovy#L65) +- [`client.withNewVM()`](../vars/nebula.groovy#L79) \ No newline at end of file diff --git a/doc/tutorials/standard-make-pipeline.md b/doc/tutorials/standard-make-pipeline.md new file mode 100644 index 0000000..8941efb --- /dev/null +++ b/doc/tutorials/standard-make-pipeline.md @@ -0,0 +1,123 @@ +# Utilisation du pipeline `standardMakePipeline()` + +> **Note** Vous travaillez sur un projet Symfony ? Dans ce cas référez vous au tutoriel ["Utiliser le pipeline Symfony](https://forge.cadoles.com/Cadoles/Jenkins/wiki/Utiliser-le-pipeline-%22Symfony%22). + +Le pipeline [`standardMakePipeline()`](../../vars/standardMakePipeline.groovy) a pour objectif de permettre d'obtenir simplement et rapidement un pipeline générique pour un projet de développement ou d'intégration en utilisant et respectant quelques conventions de nommage dans ses tâches `Make`. + +Globalement, le pipeline exécute les opérations suivantes: + +- Il exécute la commande `make build` sur votre projet; +- Il exécute la commande `make test` sur votre projet et si votre branche est une PR, il créait un commentaire sur celle ci avec la sortie de ces tests; +- Si votre branche est une branche de "release" (par défaut les branches `develop`, `testing` et `stable`) il exécute la commande `make release` puis diffuse une notification sur le canal `#cadoles-jenkins`. + +Le pipeline ne présume pas des opérations réalisées par ces 3 tâches. Il ne fait que les exécuter en partant du principe que votre projet suit un cycle conventionnel de développement. Mais globalement ces tâches devraient: + +- `make build`: Construire votre projet (installer les dépendances, générer les assets, compiler le code source le cas échéant, etc); +- `make test`: Exécuter les tests automatisés associés à votre projet (unitaire, intégration, etc); +- `make release`: Diffuser une nouvelle version de votre projet (construire et déployer des artefacts comme des paquets ou des images de conteneur, exécuter un déploiement Ansible, etc). + +> **Note:** La gestion des dépendances des tâches est à la charge du développeur (voir "Comment installer les dépendances NPM avant une tâche ?" dans la FAQ pour un exemple). + +## Utilisation + +Afin d'utiliser le pipeline, vous devez effectuer les opérations suivantes à l'initialisation de votre projet: + +1. Créer votre fichier `Jenkinsfile` à la racine de votre projet + + ```groovy + @Library("cadoles") _ + + standardMakePipeline() + ``` + +2. Créer votre fichier `Makefile` à la racine de votre projet + + ```makefile + test: + echo "Testing my project..." + + build: + echo "Building my project..." + + release: + echo "Releasing my project..." + ``` + +3. Ajouter les deux fichiers à votre historique Git (`commit`) et pousser sur la branche de développement. + +4. Accéder à [Jenkins](https://jenkins.cadol.es/) puis à l'organisation contenant votre projet. Dans la barre de gauche cliquer sur le bouton "Scan Gitea Organization Now" + + > **Note:** Globalement un projet doit être partagé avec l'équipe "Bots" sur la forge afin que Jenkins puisse accéder aux sources de votre projet. Dans la majorité des organisations pré-existentes ce partage est déjà configuré. + +5. Votre pipeline devrait s'exécuter sur Jenkins ! + +## Variables d'environnement pré-disponible + +Le pipeline injecte directement dans l'environnement d'exécution une série de variables d'environnement. + +|Variable|Description|Valeurs possibles| +|--------|-----------|-----------------| +|`PROJECT_VERSION_TAG`|Tag conventionnel de la version du projet|Voir ["R14. Respecter le schéma d'identification des images publiées"](https://forge.cadoles.com/CadolesKube/KubeRules/wiki/Bonnes-pratiques-de-d%C3%A9veloppement-applicatif-en-vue-d%27un-d%C3%A9ploiement-sur-Kubernetes#r14-respecter-le-sch%C3%A9ma-d-identification-des-images-publi%C3%A9es)| +|`PROJECT_VERSION_SHORT_TAG`|Tag court conventionnel de la version du projet|Voir ["R14. Respecter le schéma d'identification des images publiées"](https://forge.cadoles.com/CadolesKube/KubeRules/wiki/Bonnes-pratiques-de-d%C3%A9veloppement-applicatif-en-vue-d%27un-d%C3%A9ploiement-sur-Kubernetes#r14-respecter-le-sch%C3%A9ma-d-identification-des-images-publi%C3%A9es)| +|`BRANCH_NAME`|Nom de la branche courante|Nom de la branche courante (préfixé par `PR-` le cas échéant)| +|`IS_PR`|Est ce que l'exécution courante s'effectue pour une PR ?|`true` ou `false`| +|`CI`|Est ce que l'exécution courante s'exécute sur le serveur d'intégration continue ?|`true`| +## FAQ + +### Comment installer des dépendances supplémentaires dans l'environnement d'exécution ? + +Par défaut l'environnement d'exécution du pipeline est un conteneur basé sur une image Ubuntu LTS (22.04 à ce jour). Dans cette image sont installées [des dépendances de base](../../resources/com/cadoles/standard-make/Dockerfile) généralement utilisées par les projets de développement. + +Cependant si vous avez besoin d'autres dépendances systèmes il est possible d'étendre le fichier `Dockerfile` par défaut. Pour ce faire, éditer votre fichier `Jenkinsfile`: + +```groovy +@Library("cadoles") _ + +// Exemple: installation du paquet ansible-lint +// dans l'environnement d'exécution +standardMakePipeline([ + 'dockerfileExtension': ''' + RUN apt-get update -y \ + && apt-get install -y ansible-lint + ''' +]) +``` + +### Comment injecter des secrets dans l'environnement d'exécution ? + +Parfois vous aurez besoin d'utiliser des secrets afin d'accéder soit à des projets privés sur la forge, soit pour publier des paquets ou des images de conteneur. Jenkins intègre [une gestion des secrets](https://jenkins.cadol.es/manage/credentials/) et ceux ci peuvent être récupérés dans votre environnement d'exécution sous diverses formes (variable d'environnement, fichiers, etc). + +Pour ce faire, éditer votre fichier `Jenkinsfile`: + +```groovy +@Library("cadoles") _ + +// Exemple: récupération des identifiants du compte +// "jenkins" sur la forge sous la forme des variables +// d'environnement FORGE_USERNAME et FORGE_PASSWORD +standardMakePipeline([ + 'credentials': [ + usernamePassword([ + credentialsId: 'forge-jenkins', + usernameVariable: 'FORGE_USERNAME', + passwordVariable: 'FORGE_PASSWORD', + ]), + ] +]) +``` + +Les différents types d'entrées possible pour le tableau `credentials` sont décris [sur cette page](https://www.jenkins.io/doc/pipeline/steps/credentials-binding/). + +### Comment installer les dépendances NPM avant une tâche ? + +Pour cela vous pouvez utiliser les mécanismes de gestion des dépendances intégrées à Make. Par exemple: + +```makefile +test: node_modules + npm run test + +node_modules: + npm ci +``` + +De cette manière Make exécutera la commande `npm ci` si et seulement si le répertoire `node_modules` n'existe pas déjà. diff --git a/resources/com/cadoles/standard-make/Dockerfile b/resources/com/cadoles/standard-make/Dockerfile new file mode 100644 index 0000000..9b61535 --- /dev/null +++ b/resources/com/cadoles/standard-make/Dockerfile @@ -0,0 +1,26 @@ +ARG JQ_VERSION=1.6 + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + wget tar curl ca-certificates \ + openssl bash git unzip build-essential gnupg + +COPY add-letsencrypt-ca.sh /root/add-letsencrypt-ca.sh + +RUN bash /root/add-letsencrypt-ca.sh \ + && rm -f /root/add-letsencrypt-ca.sh + +# Install JQ +RUN wget -O /usr/local/bin/jq https://github.com/stedolan/jq/releases/download/jq-${JQ_VERSION}/jq-linux64 \ + && chmod +x /usr/local/bin/jq + +# Install Docker client +RUN install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \ + && chmod a+r /etc/apt/keyrings/docker.gpg \ + && echo \ + "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null \ + && apt-get update \ + && apt-get install -y docker-ce-cli \ No newline at end of file diff --git a/vars/standardMakePipeline.groovy b/vars/standardMakePipeline.groovy new file mode 100644 index 0000000..3a277dc --- /dev/null +++ b/vars/standardMakePipeline.groovy @@ -0,0 +1,246 @@ +import org.jenkinsci.plugins.pipeline.modeldefinition.Utils + +void call(Map options = [:]) { + Map hooks = options.get('hooks', [ + 'pre': null, + + 'pre-test': null, + 'post-test': null, + + 'pre-build': null, + 'post-build': null, + + 'pre-release': null, + 'post-release': null, + + 'post-success': null, + 'post-always': null, + 'post-failure': null, + ]) + String testTask = options.get('testTask', 'test') + String buildTask = options.get('buildTask', 'build') + String releaseTask = options.get('releaseTask', 'release') + String jobHistory = options.get('jobHistory', '5') + + String baseDockerfile = options.get('baseDockerfile', '') + String baseImage = options.get('baseImage', 'reg.cadoles.com/proxy_cache/library/ubuntu:22.04') + String dockerfileExtension = options.get('dockerfileExtension', '') + List credentials = options.get('credentials', []) + + List releaseBranches = options.get('releaseBranches', ['develop', 'testing', 'stable']) + + node { + properties([ + buildDiscarder(logRotator(daysToKeepStr: jobHistory, numToKeepStr: jobHistory)), + ]) + + stage('Cancel older jobs') { + int buildNumber = env.BUILD_NUMBER as int + if (buildNumber > 1) { + milestone(buildNumber - 1) + } + + milestone(buildNumber) + } + + stage('Checkout project') { + checkout(scm) + } + + try { + def containerImage = buildContainerImage(baseImage, baseDockerfile, dockerfileExtension) + containerImage.inside('-v /var/run/docker.sock:/var/run/docker.sock') { + String repo = env.JOB_NAME + if (env.BRANCH_NAME ==~ /^PR-.*$/) { + repo = env.JOB_NAME - "/${env.JOB_BASE_NAME}" + } + + List environment = prepareEnvironment() + + withEnv(environment) { + withCredentials(credentials) { + runHook(hooks, 'pre') + + stage('Build project') { + runHook(hooks, 'pre-build') + runTask('buildTask', buildTask) + runHook(hooks, 'post-build') + } + + stage('Run tests') { + runHook(hooks, 'pre-test') + + catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') { + def ( status, output ) = runAndCaptureTask('testTask', testTask) + + if (!!output.trim() && env.CHANGE_ID) { + String gitCommit = sh(script: 'git rev-parse --short HEAD', returnStdout: true) + String report = """ + |# Test report for ${gitCommit} + | + |``` + |${output} + |``` + |""".trim().stripMargin() + + gitea.commentPullRequest(repo, env.CHANGE_ID, report) + } + + if (status != 0) { + throw new Exception("Task `${testTask}` failed !") + } + } + + runHook(hooks, 'post-test') + } + + stage('Release project') { + if (releaseBranches.contains(env.BRANCH_NAME)) { + try { + runHook(hooks, 'pre-release') + runTask('releaseTask', releaseTask) + runHook(hooks, 'post-release') + } catch (Exception ex) { + rocketSend( + message: """ + |:warning: Une erreur est survenue lors de la publication de [${repo}](https://forge.cadoles.com/${repo - env.JOB_BASE_NAME}): + | + | - **Commit:** [${env.GIT_COMMIT}](https://forge.cadoles.com/${repo - env.JOB_BASE_NAME}commit/${env.GIT_COMMIT}) + | - **Tags:** `${env.PROJECT_VERSION_TAG}` / `${env.PROJECT_VERSION_SHORT_TAG}` + | + | **Erreur** + |``` + |${ex} + |``` + | + |[Visualiser le job](${env.RUN_DISPLAY_URL}) + | + |@${utils.getBuildUser()} + """.stripMargin(), + rawMessage: true + ) + + throw ex + } + + rocketSend( + message: """ + |:white_check_mark: Nouvelle publication terminée pour [${repo}](https://forge.cadoles.com/${repo - env.JOB_BASE_NAME}): + | + | - **Commit:** [${env.GIT_COMMIT}](https://forge.cadoles.com/${repo - env.JOB_BASE_NAME}commit/${env.GIT_COMMIT}) + | - **Tags:** `${env.PROJECT_VERSION_TAG}` / `${env.PROJECT_VERSION_SHORT_TAG}` + | + |[Visualiser le job](${env.RUN_DISPLAY_URL}) + | + |@${utils.getBuildUser()} + """.stripMargin(), + rawMessage: true + ) + } else { + println("Current branch '${env.BRANCH_NAME}' not in releases branches (${releaseBranches}). Skipping.") + Utils.markStageSkippedForConditional('Release project') + } + } + } + } + } + } catch (Exception ex) { + runHook(hooks, 'post-failure', [ex]) + throw ex + } finally { + runHook(hooks, 'post-always') + cleanWs() + } + + runHook(hooks, 'post-success') + } +} + +void buildContainerImage(String baseImage, String baseDockerfile, String dockerfileExtension) { + String imageName = 'cadoles-standard-make-ci' + dir(".${imageName}") { + String dockerfile = '' + + if (baseDockerfile) { + dockerfile = baseDockerfile + } else { + dockerfile = libraryResource 'com/cadoles/standard-make/Dockerfile' + dockerfile = "FROM ${baseImage}\n" + dockerfile + } + + dockerfile = """ + ${dockerfile} + ${dockerfileExtension} + """ + + writeFile file:'Dockerfile', text: dockerfile + + String addLetsEncryptCA = libraryResource 'com/cadoles/common/add-letsencrypt-ca.sh' + writeFile file:'add-letsencrypt-ca.sh', text:addLetsEncryptCA + + String safeJobName = URLDecoder.decode(env.JOB_NAME).toLowerCase().replace('/', '-').replace(' ', '-') + String imageTag = "${safeJobName}-${env.BUILD_ID}" + + return docker.build("${imageName}:${imageTag}", '.') + } +} + +void runHook(Map hooks, String name, List args = []) { + if (!hooks[name]) { + println("No hook '${name}' defined. Skipping.") + return + } + + if (hooks[name] instanceof Closure) { + hooks[name](*args) + } else { + error("Hook '${name}' seems to be defined but is not a closure !") + } +} + +void runTask(String name, task) { + if (!task) { + println("No task '${name}' defined. Skipping.") + return [ -1, '' ] + } + + sh(script: """#!/bin/bash + make ${task} + """) +} + +List runAndCaptureTask(String name, task) { + if (!task) { + println("No task '${name}' defined. Skipping.") + return [ -1, '' ] + } + + String outputFile = ".${name}-output" + int status = sh(script: """#!/bin/bash + set -eo pipefail + make ${task} 2>&1 | tee '${outputFile}' + """, returnStatus: true) + + String output = readFile(outputFile) + + sh(script: "rm -f '${outputFile}'") + + return [status, output] +} + +List prepareEnvironment() { + List env = [] + + def ( longTag, shortTag ) = utils.getProjectVersionTags() + + env += ["PROJECT_VERSION_TAG=${longTag}"] + env += ["PROJECT_VERSION_SHORT_TAG=${shortTag}"] + + String gitCommit = sh(script:'git rev-parse --short HEAD', returnStdout: true).trim() + env += ["GIT_COMMIT=${gitCommit}"] + + Boolean isPR = utils.isPR() + env += ["IS_PR=${isPR ? 'true' : 'false'}"] + + return env +} \ No newline at end of file diff --git a/vars/utils.groovy b/vars/utils.groovy index 1f5de22..940050d 100644 --- a/vars/utils.groovy +++ b/vars/utils.groovy @@ -42,3 +42,38 @@ String getBuildUser() { return buildUser } + +String getProjectVersionDefaultChannel() { + return env.BRANCH_NAME.toLowerCase().replaceAll('(_|-| )+', '') +} + +String getProjectVersionShortChannel(String channel) { + switch (channel) { + case 'develop': + return 'dev' + case 'testing': + return 'tst' + case 'stable': + return 'stb' + default: + return channel.toLowerCase().replaceAll('(a|e|i|o|u|y_|-| )+', '').take(3) + } +} + +List getProjectVersionTags(String overrideChannel = '') { + String channel = overrideChannel ? overrideChannel : getProjectVersionDefaultChannel() + String shortChannel = getProjectVersionShortChannel(channel) + + String dateVersion = sh(script: 'date +%Y.%-m.%-d', returnStdout: true).trim() + String timestamp = sh(script: 'date +%-H%M', returnStdout: true).trim() + String shortCommit = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim() + + String longTag = "${dateVersion}-${channel}.${timestamp}.${shortCommit}" + String shortTag = "${dateVersion}-${shortChannel}.${timestamp}" + + return [ longTag, shortTag ] +} + +Boolean isPR() { + return env.BRANCH_NAME ==~ /^PR-.*$/ +}