feat(container): add image build validation steps

This commit is contained in:
wpetit 2022-10-10 11:56:55 +02:00
parent e670fb8bf6
commit 8e1b257144
4 changed files with 220 additions and 56 deletions

View File

@ -1,12 +1,20 @@
String buildAndPublishImage(Map options = [:]) { String buildAndPublishImage(Map options = [:]) {
def dockerfile = options.get("dockerfile", "./Dockerfile") String dockerfile = options.get('dockerfile', './Dockerfile')
def contextDir = options.get("contextDir", ".") String contextDir = options.get('contextDir', '.')
def imageName = options.get("imageName", "") String imageName = options.get('imageName', '')
def gitRef = sh(returnStdout: true, script: 'git describe --always').trim() String gitRef = sh(returnStdout: true, script: 'git describe --always').trim()
def imageTag = options.get("imageTag", gitRef) String imageTag = options.get('imageTag', gitRef)
def gitCredentialsId = options.get("gitCredentialsId", "forge-jenkins") String gitCredentialsId = options.get('gitCredentialsId', 'forge-jenkins')
def dockerRepository = options.get("dockerRepository", "docker.io") String dockerRepository = options.get('dockerRepository', 'docker.io')
def dockerRepositoryCredentialsId = options.get("dockerRepositoryCredentialsId", "cadoles-docker-hub") 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([ withCredentials([
usernamePassword([ usernamePassword([
@ -17,51 +25,183 @@ String buildAndPublishImage(Map options = [:]) {
]) { ]) {
String fullImageName = "${dockerRepository}/${env.HUB_USERNAME}/${imageName}" String fullImageName = "${dockerRepository}/${env.HUB_USERNAME}/${imageName}"
stage('Validate Dockerfile with Hadolint') {
utils.when(!skipVerifications) {
runHadolintCheck(dockerfile, projectRepository)
}
}
stage("Build image '${imageName}:${imageTag}'") { stage("Build image '${imageName}:${imageTag}'") {
git.withHTTPCredentials(gitCredentialsId) { git.withHTTPCredentials(gitCredentialsId) {
sh """ sh """
docker build \ docker build \
--build-arg="GIT_USERNAME=${env.GIT_USERNAME}" \ --build-arg="GIT_USERNAME=${env.GIT_USERNAME}" \
--build-arg="GIT_PASSWORD=${env.GIT_PASSWORD}" \ --build-arg="GIT_PASSWORD=${env.GIT_PASSWORD}" \
-t ${fullImageName}:${imageTag} \ -t '${fullImageName}:${imageTag}' \
-f '${dockerfile}' \ -f '${dockerfile}' \
'${contextDir}' '${contextDir}'
""" """
} }
} }
stage('Validate image with Trivy') {
utils.when(!skipVerifications) {
runTrivyCheck("${fullImageName}:${imageTag}", projectRepository)
}
}
stage("Publish image '${fullImageName}:${imageTag}'") { stage("Publish image '${fullImageName}:${imageTag}'") {
retry(2) { utils.when(!dryRun) {
sh """ retry(2) {
echo ${env.HUB_PASSWORD} | docker login -u '${env.HUB_USERNAME}' --password-stdin sh """
docker login '${dockerRepository}' echo ${env.HUB_PASSWORD} | docker login -u '${env.HUB_USERNAME}' --password-stdin
docker push '${fullImageName}:${imageTag}' docker login '${dockerRepository}'
""" docker push '${fullImageName}:${imageTag}'
"""
}
} }
} }
} }
} }
def createPodmanPackage(Map options = [:]) { void runHadolintCheck(String dockerfile, String projectRepository) {
String gomplateBin = getOrInstallGomplate() 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 validateDockerfileWithHadolint(String dockerfile, Map options = [:]) {
String installDir = options.get("installDir", "/usr/local/bin") String hadolintBin = getOrInstallHadolint(options)
String version = options.get("version", "3.10.0") String hadolintArgs = options.get('hadolintArgs', '--no-color')
String forceDownload = options.get("forceDownload", false) String reportFile = options.get('reportFile', ".hadolint-report-${currentBuild.startTimeInMillis}.txt")
String downloadUrl = options.get("downloadUrl", "https://github.com/hairyhenderson/gomplate/releases/download/v${version}/gomplate_linux-amd64")
String gomplateBin = sh(returnStdout: true, script: 'which gomplate').trim("") sh("""#!/bin/bash
if (gomplateBin == "" || forceDownload) { set -eo pipefail
sh(""" '${hadolintBin}' '${dockerfile}' ${hadolintArgs} | tee '${reportFile}'
mkdir -p '${installDir}' """)
curl -o tools/gomplate/bin/gomplate -sSL '${forceDownload}'
chmod +x '${installDir}/gomplate'
""")
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
} }

View File

@ -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('"', '\\"') comment = comment.replaceAll('"', '\\"')
withCredentials([ withCredentials([
string(credentialsId: 'GITEA_JENKINS_PERSONAL_TOKEN', variable: 'GITEA_TOKEN'), 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 sh """#!/bin/bash
set -xeo pipefail set -xeo pipefail
# Récupération si il existe du commentaire existant previous_comment_id=null
previous_comment_id=\$(curl -v --fail \
-H "Authorization: token ${GITEA_TOKEN}" \ if [ "${commentIndex}" != "-1" ]; then
-H "Content-Type: application/json" \ # Récupération si il existe du commentaire existant
https://forge.cadoles.com/api/v1/repos/${repo}/issues/${issueId}/comments \ previous_comment_id=\$(curl -v --fail \
| jq -c '[ .[] | select(.user.login=="jenkins") ] | .[${commentIndex}] | .id' \ -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 # Génération du payload pour l'API Gitea
echo '{}' | jq -c --rawfile body .prComment '.body = \$body' > payload.json echo '{}' | jq -c --rawfile body .prComment '.body = \$body' > payload.json

View File

@ -1,7 +1,13 @@
import org.jenkinsci.plugins.pipeline.modeldefinition.Utils 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 { node {
properties([
buildDiscarder(logRotator(daysToKeepStr: jobHistory, numToKeepStr: jobHistory)),
])
stage('Cancel older jobs') { stage('Cancel older jobs') {
def buildNumber = env.BUILD_NUMBER as int def buildNumber = env.BUILD_NUMBER as int
if (buildNumber > 1) milestone(buildNumber - 1) if (buildNumber > 1) milestone(buildNumber - 1)
@ -11,10 +17,10 @@ def call(String baseImage = 'ubuntu:22.04') {
checkout(scm) checkout(scm)
} }
stage('Run pre hooks') { stage('Run pre hooks') {
hook('pre-symfony-app') runHook(hooks, 'preSymfonyAppPipeline')
} }
stage('Run in Symfony image') { stage('Run in Symfony image') {
def symfonyImage = buildDockerImage(baseImage) def symfonyImage = buildDockerImage(baseImage, hooks)
symfonyImage.inside() { symfonyImage.inside() {
def repo = env.JOB_NAME def repo = env.JOB_NAME
if (env.BRANCH_NAME ==~ /^PR-.*$/) { 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) def auditReport = sh(script: 'local-php-security-checker --format=markdown || true', returnStdout: true)
if (auditReport.trim() != '') { if (auditReport.trim() != '') {
if (env.CHANGE_ID) { if (env.CHANGE_ID) {
gitea.commentPullRequest(repo, env.CHANGE_ID, auditReport, 0) gitea.commentPullRequest(repo, env.CHANGE_ID, auditReport)
} else { } else {
print auditReport print auditReport
} }
@ -60,7 +66,7 @@ def call(String baseImage = 'ubuntu:22.04') {
''' '''
def report = sh(script: 'junit2md php-cs-fixer.xml', returnStdout: true) def report = sh(script: 'junit2md php-cs-fixer.xml', returnStdout: true)
if (env.CHANGE_ID) { if (env.CHANGE_ID) {
gitea.commentPullRequest(repo, env.CHANGE_ID, report, 1) gitea.commentPullRequest(repo, env.CHANGE_ID, report)
} else { } else {
print report print report
} }
@ -81,7 +87,7 @@ def call(String baseImage = 'ubuntu:22.04') {
report = '## Rapport PHPStan\n\n```\n' + report report = '## Rapport PHPStan\n\n```\n' + report
report = report + '\n```\n' report = report + '\n```\n'
if (env.CHANGE_ID) { if (env.CHANGE_ID) {
gitea.commentPullRequest(repo, env.CHANGE_ID, report, 2) gitea.commentPullRequest(repo, env.CHANGE_ID, report)
} else { } else {
print report print report
} }
@ -92,12 +98,12 @@ def call(String baseImage = 'ubuntu:22.04') {
} }
} }
stage('Run post hooks') { 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' def imageName = 'cadoles-symfony-ci'
dir(".${imageName}") { dir(".${imageName}") {
def dockerfile = libraryResource 'com/cadoles/symfony/Dockerfile' 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' def addLetsEncryptCA = libraryResource 'com/cadoles/common/add-letsencrypt-ca.sh'
writeFile file:'add-letsencrypt-ca.sh', text:addLetsEncryptCA 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 safeJobName = URLDecoder.decode(env.JOB_NAME).toLowerCase().replace('/', '-').replace(' ', '-')
def imageTag = "${safeJobName}-${env.BUILD_ID}" def imageTag = "${safeJobName}-${env.BUILD_ID}"
@ -114,14 +120,15 @@ def buildDockerImage(String baseImage) {
} }
} }
def when(boolean condition, body) { void runHook(Map hooks, String name) {
def config = [:] if (!hooks[name]) {
body.resolveStrategy = Closure.OWNER_FIRST println("No hook '${name}' defined. Skipping.")
body.delegate = config return
}
if (condition) { if (hooks[name] instanceof Closure) {
body() hooks[name]()
} else { } else {
Utils.markStageSkippedForConditional(STAGE_NAME) error("Hook '${name}' seems to be defined but is not a closure !")
} }
} }

13
vars/utils.groovy Normal file
View File

@ -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)
}
}