diff --git a/vars/container.groovy b/vars/container.groovy index 26f9fe2..aea4a12 100644 --- a/vars/container.groovy +++ b/vars/container.groovy @@ -1,12 +1,20 @@ String buildAndPublishImage(Map options = [:]) { - def dockerfile = options.get("dockerfile", "./Dockerfile") - def contextDir = options.get("contextDir", ".") - def imageName = options.get("imageName", "") - def gitRef = sh(returnStdout: true, script: 'git describe --always').trim() - def imageTag = options.get("imageTag", gitRef) - def gitCredentialsId = options.get("gitCredentialsId", "forge-jenkins") - def dockerRepository = options.get("dockerRepository", "docker.io") - def dockerRepositoryCredentialsId = options.get("dockerRepositoryCredentialsId", "cadoles-docker-hub") + String dockerfile = options.get('dockerfile', './Dockerfile') + String contextDir = options.get('contextDir', '.') + String imageName = options.get('imageName', '') + String gitRef = sh(returnStdout: true, script: 'git describe --always').trim() + String imageTag = options.get('imageTag', gitRef) + String gitCredentialsId = options.get('gitCredentialsId', 'forge-jenkins') + String dockerRepository = options.get('dockerRepository', 'docker.io') + String dockerRepositoryCredentialsId = options.get('dockerRepositoryCredentialsId', 'cadoles-docker-hub') + Boolean dryRun = options.get('dryRun', true) + Boolean skipVerifications = options.get('skipVerification', false) + + String projectRepository = env.JOB_NAME + if (env.BRANCH_NAME ==~ /^PR-.*$/) { + projectRepository = env.JOB_NAME - "/${env.JOB_BASE_NAME}" + } + projectRepository = options.get('projectRepository', projectRepository) withCredentials([ usernamePassword([ @@ -17,51 +25,183 @@ String buildAndPublishImage(Map options = [:]) { ]) { String fullImageName = "${dockerRepository}/${env.HUB_USERNAME}/${imageName}" + stage('Validate Dockerfile with Hadolint') { + utils.when(!skipVerifications) { + runHadolintCheck(dockerfile, projectRepository) + } + } + stage("Build image '${imageName}:${imageTag}'") { git.withHTTPCredentials(gitCredentialsId) { sh """ docker build \ --build-arg="GIT_USERNAME=${env.GIT_USERNAME}" \ --build-arg="GIT_PASSWORD=${env.GIT_PASSWORD}" \ - -t ${fullImageName}:${imageTag} \ + -t '${fullImageName}:${imageTag}' \ -f '${dockerfile}' \ '${contextDir}' """ } } + stage('Validate image with Trivy') { + utils.when(!skipVerifications) { + runTrivyCheck("${fullImageName}:${imageTag}", projectRepository) + } + } + stage("Publish image '${fullImageName}:${imageTag}'") { - retry(2) { - sh """ - echo ${env.HUB_PASSWORD} | docker login -u '${env.HUB_USERNAME}' --password-stdin - docker login '${dockerRepository}' - docker push '${fullImageName}:${imageTag}' - """ + utils.when(!dryRun) { + retry(2) { + sh """ + echo ${env.HUB_PASSWORD} | docker login -u '${env.HUB_USERNAME}' --password-stdin + docker login '${dockerRepository}' + docker push '${fullImageName}:${imageTag}' + """ + } } } } } -def createPodmanPackage(Map options = [:]) { - String gomplateBin = getOrInstallGomplate() +void runHadolintCheck(String dockerfile, String projectRepository) { + String reportFile = ".hadolint-report-${currentBuild.startTimeInMillis}.txt" + + try { + validateDockerfileWithHadolint(dockerfile, ['reportFile': reportFile]) + } catch (err) { + unstable("Dockerfile '${dockerfile}' failed linting !") + } finally { + String lintReport = '' + + if (fileExists(reportFile)) { + lintReport = """${lintReport} + | + |``` + |${readFile(reportFile)} + |```""" + } else { + lintReport = """${lintReport} + | + |_Vérification échouée mais aucun rapport trouvé !?_ :thinking:""" + } + + String defaultReport = '_Rien à signaler !_ :thumbsup:' + String report = """## Validation du Dockerfile `${dockerfile}` + | + |${lintReport ?: defaultReport} + """.stripMargin() + + print report + + if (env.CHANGE_ID) { + gitea.commentPullRequest(projectRepository, env.CHANGE_ID, report) + } + } } -String getOrInstallGomplate() { - String installDir = options.get("installDir", "/usr/local/bin") - String version = options.get("version", "3.10.0") - String forceDownload = options.get("forceDownload", false) - String downloadUrl = options.get("downloadUrl", "https://github.com/hairyhenderson/gomplate/releases/download/v${version}/gomplate_linux-amd64") +String validateDockerfileWithHadolint(String dockerfile, Map options = [:]) { + String hadolintBin = getOrInstallHadolint(options) + String hadolintArgs = options.get('hadolintArgs', '--no-color') + String reportFile = options.get('reportFile', ".hadolint-report-${currentBuild.startTimeInMillis}.txt") - String gomplateBin = sh(returnStdout: true, script: 'which gomplate').trim("") - if (gomplateBin == "" || forceDownload) { - sh(""" - mkdir -p '${installDir}' - curl -o tools/gomplate/bin/gomplate -sSL '${forceDownload}' - chmod +x '${installDir}/gomplate' - """) + sh("""#!/bin/bash + set -eo pipefail + '${hadolintBin}' '${dockerfile}' ${hadolintArgs} | tee '${reportFile}' + """) - gomplateBin = "${installDir}/gomplate" + return reportFile +} + +void runTrivyCheck(String imageName, String projectRepository, Map options = [:]) { + String reportFile = ".trivy-report-${currentBuild.startTimeInMillis}.txt" + + try { + validateImageWithTrivy(imageName, ['reportFile': reportFile]) + } catch (err) { + unstable("Image '${imageName}' failed validation !") + } finally { + String lintReport = '' + + if (fileExists(reportFile)) { + lintReport = """${lintReport} + | + |``` + |${readFile(reportFile)} + |```""" + } else { + lintReport = """${lintReport} + | + |_Vérification échouée mais aucun rapport trouvé !?_ :thinking:""" + } + + String defaultReport = '_Rien à signaler !_ :thumbsup:' + String report = """## Validation de l'image `${imageName}` + | + |${lintReport ?: defaultReport} + """.stripMargin() + + print report + + if (env.CHANGE_ID) { + gitea.commentPullRequest(projectRepository, env.CHANGE_ID, report) + } + } +} + +String validateImageWithTrivy(String imageName, Map options = [:]) { + String trivyBin = getOrInstallTrivy(options) + String trivyArgs = options.get('trivyArgs', '--exit-code 1') + String cacheDirectory = options.get('cacheDirectory', '.trivy/.cache') + String cacheDefaultBranch = options.get('cacheDefaultBranch', 'develop') + Integer cacheMaxSize = options.get('cacheMaxSize', 250) + String reportFile = options.get('reportFile', ".trivy-report-${currentBuild.startTimeInMillis}.txt") + + cache(maxCacheSize: cacheMaxSize, defaultBranch: cacheDefaultBranch, caches: [ + [$class: 'ArbitraryFileCache', path: cacheDirectory, compressionMethod: 'TARGZ'] + ]) { + sh("'${trivyBin}' --cache-dir '${cacheDirectory}' image -o '${reportFile}' ${trivyArgs} '${imageName}'") } - return gomplateBin + return reportFile +} + +String getOrInstallHadolint(Map options = [:]) { + String installDir = options.get('installDir', '/usr/local/bin') + String version = options.get('version', '2.10.0') + String forceDownload = options.get('forceDownload', false) + String downloadUrl = options.get('downloadUrl', "https://github.com/hadolint/hadolint/releases/download/v${version}/hadolint-Linux-x86_64") + + String hadolintBin = sh(returnStdout: true, script: 'which hadolint || exit 0').trim() + if (hadolintBin == '' || forceDownload) { + sh(""" + mkdir -p '${installDir}' + curl -o '${installDir}/hadolint' -sSL '${downloadUrl}' + chmod +x '${installDir}/hadolint' + """) + + hadolintBin = "${installDir}/hadolint" + } + + return hadolintBin +} + +String getOrInstallTrivy(Map options = [:]) { + String installDir = options.get('installDir', '/usr/local/bin') + String version = options.get('version', '0.27.1') + String forceDownload = options.get('forceDownload', false) + String installScriptDownloadUrl = options.get('downloadUrl', 'https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh') + + String trivyBin = sh(returnStdout: true, script: 'which trivy || exit 0').trim() + if (trivyBin == '' || forceDownload) { + sh(""" + mkdir -p '${installDir}' + curl -sfL '${installScriptDownloadUrl}' | sh -s -- -b '${installDir}' v${version} + chmod +x '${installDir}/trivy' + """) + + trivyBin = "${installDir}/trivy" + } + + return trivyBin } diff --git a/vars/gitea.groovy b/vars/gitea.groovy index 8a30e95..7699ea2 100644 --- a/vars/gitea.groovy +++ b/vars/gitea.groovy @@ -1,4 +1,4 @@ -def commentPullRequest(String repo, String issueId, String comment, Integer commentIndex = 0) { +def commentPullRequest(String repo, String issueId, String comment, Integer commentIndex = -1) { comment = comment.replaceAll('"', '\\"') withCredentials([ string(credentialsId: 'GITEA_JENKINS_PERSONAL_TOKEN', variable: 'GITEA_TOKEN'), @@ -7,13 +7,17 @@ def commentPullRequest(String repo, String issueId, String comment, Integer comm sh """#!/bin/bash set -xeo pipefail - # Récupération si il existe du commentaire existant - previous_comment_id=\$(curl -v --fail \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - https://forge.cadoles.com/api/v1/repos/${repo}/issues/${issueId}/comments \ - | jq -c '[ .[] | select(.user.login=="jenkins") ] | .[${commentIndex}] | .id' \ - ) + previous_comment_id=null + + if [ "${commentIndex}" != "-1" ]; then + # Récupération si il existe du commentaire existant + previous_comment_id=\$(curl -v --fail \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + https://forge.cadoles.com/api/v1/repos/${repo}/issues/${issueId}/comments \ + | jq -c '[ .[] | select(.user.login=="jenkins") ] | .[${commentIndex}] | .id' \ + ) + fi # Génération du payload pour l'API Gitea echo '{}' | jq -c --rawfile body .prComment '.body = \$body' > payload.json diff --git a/vars/symfonyAppPipeline.groovy b/vars/symfonyAppPipeline.groovy index d9c7c0e..74ce9b8 100644 --- a/vars/symfonyAppPipeline.groovy +++ b/vars/symfonyAppPipeline.groovy @@ -1,7 +1,13 @@ import org.jenkinsci.plugins.pipeline.modeldefinition.Utils -def call(String baseImage = 'ubuntu:22.04') { +def call(String baseImage = 'ubuntu:22.04', Map options = [:]) { + Map hooks = options.get('hooks', [:]) + String jobHistory = options.get('jobHistory', '10') + node { + properties([ + buildDiscarder(logRotator(daysToKeepStr: jobHistory, numToKeepStr: jobHistory)), + ]) stage('Cancel older jobs') { def buildNumber = env.BUILD_NUMBER as int if (buildNumber > 1) milestone(buildNumber - 1) @@ -11,10 +17,10 @@ def call(String baseImage = 'ubuntu:22.04') { checkout(scm) } stage('Run pre hooks') { - hook('pre-symfony-app') + runHook(hooks, 'preSymfonyAppPipeline') } stage('Run in Symfony image') { - def symfonyImage = buildDockerImage(baseImage) + def symfonyImage = buildDockerImage(baseImage, hooks) symfonyImage.inside() { def repo = env.JOB_NAME if (env.BRANCH_NAME ==~ /^PR-.*$/) { @@ -34,7 +40,7 @@ def call(String baseImage = 'ubuntu:22.04') { def auditReport = sh(script: 'local-php-security-checker --format=markdown || true', returnStdout: true) if (auditReport.trim() != '') { if (env.CHANGE_ID) { - gitea.commentPullRequest(repo, env.CHANGE_ID, auditReport, 0) + gitea.commentPullRequest(repo, env.CHANGE_ID, auditReport) } else { print auditReport } @@ -60,7 +66,7 @@ def call(String baseImage = 'ubuntu:22.04') { ''' def report = sh(script: 'junit2md php-cs-fixer.xml', returnStdout: true) if (env.CHANGE_ID) { - gitea.commentPullRequest(repo, env.CHANGE_ID, report, 1) + gitea.commentPullRequest(repo, env.CHANGE_ID, report) } else { print report } @@ -81,7 +87,7 @@ def call(String baseImage = 'ubuntu:22.04') { report = '## Rapport PHPStan\n\n```\n' + report report = report + '\n```\n' if (env.CHANGE_ID) { - gitea.commentPullRequest(repo, env.CHANGE_ID, report, 2) + gitea.commentPullRequest(repo, env.CHANGE_ID, report) } else { print report } @@ -92,12 +98,12 @@ def call(String baseImage = 'ubuntu:22.04') { } } stage('Run post hooks') { - hook('post-symfony-app') + runHook(hooks, 'postSymfonyAppPipeline') } } } -def buildDockerImage(String baseImage) { +void buildDockerImage(String baseImage, Map hooks) { def imageName = 'cadoles-symfony-ci' dir(".${imageName}") { def dockerfile = libraryResource 'com/cadoles/symfony/Dockerfile' @@ -106,7 +112,7 @@ def buildDockerImage(String baseImage) { def addLetsEncryptCA = libraryResource 'com/cadoles/common/add-letsencrypt-ca.sh' writeFile file:'add-letsencrypt-ca.sh', text:addLetsEncryptCA - hook('build-symfony-image') + runHook(hooks, 'buildSymfonyImage') def safeJobName = URLDecoder.decode(env.JOB_NAME).toLowerCase().replace('/', '-').replace(' ', '-') def imageTag = "${safeJobName}-${env.BUILD_ID}" @@ -114,14 +120,15 @@ def buildDockerImage(String baseImage) { } } -def when(boolean condition, body) { - def config = [:] - body.resolveStrategy = Closure.OWNER_FIRST - body.delegate = config +void runHook(Map hooks, String name) { + if (!hooks[name]) { + println("No hook '${name}' defined. Skipping.") + return + } - if (condition) { - body() - } else { - Utils.markStageSkippedForConditional(STAGE_NAME) + if (hooks[name] instanceof Closure) { + hooks[name]() + } else { + error("Hook '${name}' seems to be defined but is not a closure !") } } diff --git a/vars/utils.groovy b/vars/utils.groovy new file mode 100644 index 0000000..611625a --- /dev/null +++ b/vars/utils.groovy @@ -0,0 +1,13 @@ +import org.jenkinsci.plugins.pipeline.modeldefinition.Utils + +void when(Boolean condition, body) { + Map config = [:] + body.resolveStrategy = Closure.OWNER_FIRST + body.delegate = config + + if (condition) { + body() + } else { + Utils.markStageSkippedForConditional(STAGE_NAME) + } +} \ No newline at end of file