Initial commit
Add infrastructure jobs for sandbox

Related-PROD: RE-336

Change-Id: I2140d47e3fc360ab05f92175b29b31e69b2ec10b
diff --git a/common/pipelines/codenarc.groovy b/common/pipelines/codenarc.groovy
new file mode 100644
index 0000000..c4ae040
--- /dev/null
+++ b/common/pipelines/codenarc.groovy
@@ -0,0 +1,94 @@
+#!groovy
+
+def main() {
+    String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+    String gitRef = env.GERRIT_REFSPEC
+
+    stage('SCM checkout') {
+        echo "Checking out git repository from ${gitUrl} @ ${gitRef}"
+
+        checkout \
+            $class: 'GitSCM',
+            branches: [[
+                name: 'FETCH_HEAD'
+            ]],
+            userRemoteConfigs: [[
+                url: gitUrl,
+                refspec: gitRef,
+                credentialsId: env.GIT_CREDENTIALS_ID
+            ]],
+            extensions: [[
+                $class: 'WipeWorkspace'
+            ]]
+    }
+
+    stage('Codenarc') {
+        String corenarcRulesFile = 'codenarcRules.groovy'
+        if (!fileExists(corenarcRulesFile)) {
+             writeFile \
+                 file: corenarcRulesFile,
+                 text: env.DEFAULT_RULES
+        }
+        sh '''#!/bin/bash -ex
+            codenarc \
+                -maxPriority1Violations=0 \
+                -maxPriority2Violations=0 \
+                -maxPriority3Violations=0 \
+                -excludes='**/codenarcRules.groovy' \
+                -rulesetfiles=file:codenarcRules.groovy \
+                -report=console \
+                -report=html:report.html \
+                | tee report.log
+            if [ "${PIPESTATUS[0]}" != '0' ]; then
+                exit 1
+            fi
+            if grep -q 'Compilation failed' report.log ; then
+                exit 1
+            fi
+            if grep -q 'Error processing' report.log ; then
+                exit 1
+            fi
+        '''
+    }
+
+    stage('Report') {
+        archiveArtifacts \
+            artifacts: 'report.html',
+            allowEmptyArchive: true
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+          runAsUser: 1000
+      containers:
+      - name: "codenarc"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+if (env.K8S_CLUSTER == 'unset') {
+    node(env.NODE_LABEL) {
+        docker.image(env.DOCKER_IMAGE).inside('--entrypoint=""') {
+            main()
+        }
+    }
+} else {
+    podTemplate(
+            cloud: env.K8S_CLUSTER,
+            yaml: podTpl,
+            showRawYaml: false
+        ) {
+            node(POD_LABEL) {
+            container('codenarc') {
+                main()
+            }
+        }
+    }
+}
diff --git a/common/pipelines/shellcheck.groovy b/common/pipelines/shellcheck.groovy
new file mode 100644
index 0000000..66a7d37
--- /dev/null
+++ b/common/pipelines/shellcheck.groovy
@@ -0,0 +1,65 @@
+#!groovy
+
+def main() {
+    String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+    String gitRef = env.GERRIT_REFSPEC
+
+    stage('SCM checkout') {
+        checkout \
+            $class: 'GitSCM',
+            branches: [[
+                name: 'FETCH_HEAD'
+            ]],
+            userRemoteConfigs: [[
+                url: gitUrl,
+                refspec: gitRef,
+                credentialsId: env.GIT_CREDENTIALS_ID
+            ]],
+            extensions: [[
+                $class: 'WipeWorkspace'
+            ]]
+    }
+
+    stage('Shellcheck') {
+        sh '''#!/bin/bash -ex
+        git show --name-only --diff-filter=AM \
+        | grep -E '.sh$' \
+        | xargs --no-run-if-empty shellcheck -e SC1090,SC2013,SC2154,SC2029
+        '''
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+          runAsUser: 1000
+      containers:
+      - name: "main"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+if (env.K8S_CLUSTER == 'unset') {
+    node(env.NODE_LABEL) {
+        docker.image(env.DOCKER_IMAGE).inside('--entrypoint=""') {
+            main()
+        }
+    }
+} else {
+    podTemplate(
+            cloud: env.K8S_CLUSTER,
+            yaml: podTpl,
+            showRawYaml: false
+        ) {
+            node(POD_LABEL) {
+            container('main') {
+                main()
+            }
+        }
+    }
+}
diff --git a/common/pipelines/test-jenkins-jobs.groovy b/common/pipelines/test-jenkins-jobs.groovy
new file mode 100644
index 0000000..e04f644
--- /dev/null
+++ b/common/pipelines/test-jenkins-jobs.groovy
@@ -0,0 +1,218 @@
+#!groovy
+//import groovy.transform.Field
+
+String getJobs(getJobsCmd) {
+    String result
+    dir("${env.WORKSPACE}/output/${env.CI_NAME}") {
+        result = sh \
+            script: """\
+                   ${getJobsCmd} \
+                   | grep -v '\\/\$' \
+                   | grep -E '^(deleting|Files)' \
+                   | sed -r 's%^(deleting|Files)\\s%%g' \
+                   | sed -r 's%^old/%%' \
+                   | cut -d' ' -f1
+                """,
+            returnStdout: true
+    }
+    return result
+}
+
+def main() {
+    // Use gerrit parameters if set with fallback to job param
+    String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+    String gitRef = env.GERRIT_REFSPEC
+
+    String pathSep = '/'
+
+    String getAddedJobsCmd = 'rsync --dry-run -av --delete old/ new/'
+    String getRemovedJobsCmd = 'rsync --dry-run -av --delete new/ old/'
+    String getDiffJobsCmd = 'diff -rq old/ new/'
+
+    // Set current build description
+    if (env.GERRIT_CHANGE_URL) {
+        currentBuild.description = """
+        <p>
+          Triggered by change: <a href="${env.GERRIT_CHANGE_URL}">${env.GERRIT_CHANGE_NUMBER},${env.GERRIT_PATCHSET_NUMBER}</a><br/>
+          Project: <b>${env.GERRIT_PROJECT}</b><br/>
+          Branch: <b>${env.GERRIT_BRANCH}</b><br/>
+          Subject: <b>${env.GERRIT_CHANGE_SUBJECT}</b><br/>
+        </p>
+        """
+    }
+
+    // Get & prepare source code
+    stage('SCM checkout') {
+        echo "Checking out git repository from ${gitUrl} @ ${gitRef}"
+
+        checkout \
+            $class: 'GitSCM',
+            branches: [
+                [name: 'FETCH_HEAD'],
+            ],
+            userRemoteConfigs: [
+                [url: gitUrl, refspec: gitRef, credentialsId: env.GIT_CREDENTIALS_ID],
+            ],
+            extensions: [
+                [$class: 'WipeWorkspace'],
+            ]
+    }
+
+    stage('Check for non-ascii characters') {
+        def asciiStatus = sh \
+            script: 'grep -q --perl-regexp -R "[^[:ascii:]]" --include \\*.sh --include \\*.yaml --include \\*.groovy *',
+            returnStatus: true
+
+        if (asciiStatus == 0) {
+            error 'Found non-ASCII symbols!!!'
+        }
+    }
+
+    stage('JJB verify') {
+        withEnv(['HOME=/tmp/']) {
+            // Generate current jobs from parent commit (will be used for diff)
+            sh 'tox -v -e compare-xml-new'
+        }
+    }
+
+    stage('JJB compare') {
+        withEnv(['HOME=/tmp/']) {
+            // Generate jobs from parent commit (will be used for diff)
+            sh '''
+                git reset --hard HEAD^
+                git checkout FETCH_HEAD -- tox.ini
+                tox -v -e compare-xml-old
+            '''
+        }
+
+        dir("output/${env.CI_NAME}") {
+            Integer diffStatus = sh \
+                script: 'diff -rq old/ new/',
+                returnStatus: true
+
+            if (diffStatus == 0) {
+                currentBuild.result = 'SUCCESS'
+                currentBuild.description += 'No job changes'
+                currentBuild.getRawBuild().getExecutor().interrupt(Result.SUCCESS)
+                sleep(1)   // Interrupt is not blocking and does not take effect immediately.
+            }
+        }
+
+        // Analyse output file and prepare array with results
+
+        String diffJobs = getJobs(getDiffJobsCmd)
+        String addedJobs = getJobs(getAddedJobsCmd)
+        String removedJobs = getJobs(getRemovedJobsCmd)
+
+        // Set job description
+
+        String description = ''
+        String _item, _itemPath
+
+        dir("output/${env.CI_NAME}") {
+            if (diffJobs.size() > 0) {
+                description += '<b>CHANGED</b><ul>'
+                diffJobs.split('\n').each { item ->
+                    _item = item.replace('/config.xml', '')
+                    try {
+                        _itemPath = item.tokenize(pathSep)[0..-2].join(pathSep)
+                    } catch (e) {
+                        _itemPath = ''
+                    }
+                    description += "<li><a href=\"${env.BUILD_URL}artifact/output/${env.CI_NAME}/diff/${item}/*view*/\">${_item}</a></li>"
+
+                    // Generate diff file
+                    sh """
+                        mkdir -p diff/${_itemPath}
+                        diff -U 50 \
+                            'old/${item}' \
+                            'new/${item}' \
+                            > 'diff/${item}' || :
+                    """
+                }
+                description += '</ul>'
+            }
+
+            if (addedJobs.size() > 0) {
+                description += '<b>ADDED</b><ul>'
+                addedJobs.split('\n').each { item ->
+                    _item = item.replace('/config.xml', '')
+                    try {
+                        _itemPath = item.tokenize(pathSep)[0..-2].join(pathSep)
+                    } catch (e) {
+                        _itemPath = ''
+                    }
+                    description += "<li><a href=\"${env.BUILD_URL}artifact/output/${env.CI_NAME}/diff/${item}/*view*/\">${_item}</a></li>"
+                    sh """
+                        mkdir -p diff/${_itemPath}
+                        cp new/${item} diff/${_itemPath}/
+                    """
+                }
+                description += '</ul>'
+            }
+
+            if (removedJobs.size() > 0) {
+                description += '<b>DELETED</b><ul>'
+                removedJobs.split('\n').each { item ->
+                    _item = item.replace('/config.xml', '')
+                    try {
+                        _itemPath = item.tokenize(pathSep)[0..-2].join(pathSep)
+                    } catch (e) {
+                        _itemPath = ''
+                    }
+                    description += "<li><a href=\"${env.BUILD_URL}artifact/output/${env.CI_NAME}/diff/${item}/*view*/\">${_item}</a></li>"
+                    sh """
+                        mkdir -p diff/${_itemPath}
+                        cp old/${item} diff/${_itemPath}/
+                    """
+                }
+                description += '</ul>'
+            }
+        }
+
+        currentBuild.description += description
+    }
+
+    // Save results
+    stage('Record test results') {
+        archiveArtifacts([
+            artifacts: "output/${env.CI_NAME}/diff/**",
+            allowEmptyArchive: true,
+        ])
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+          runAsUser: 1000
+      containers:
+      - name: "tox"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+if (env.K8S_CLUSTER == 'unset') {
+    node(env.SLAVE_LABEL ?: 'docker') {
+        docker.image(env.DOCKER_IMAGE).inside('--entrypoint=""') {
+            main()
+        }
+    }
+} else {
+    podTemplate(
+            cloud: env.K8S_CLUSTER,
+            yaml: podTpl,
+            showRawYaml: false
+        ) {
+            node(POD_LABEL) {
+            container('tox') {
+                main()
+            }
+        }
+    }
+}
diff --git a/common/pipelines/tox.groovy b/common/pipelines/tox.groovy
new file mode 100644
index 0000000..f0d8ccf
--- /dev/null
+++ b/common/pipelines/tox.groovy
@@ -0,0 +1,67 @@
+#!groovy
+
+def main() {
+    String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+    String gitRef = env.GERRIT_REFSPEC
+
+    stage('SCM checkout') {
+        echo "Checking out git repository from ${gitUrl} @ ${gitRef}"
+
+        checkout \
+            $class: 'GitSCM',
+            branches: [[
+                name: 'FETCH_HEAD'
+            ]],
+            userRemoteConfigs: [[
+                url: gitUrl,
+                refspec: gitRef,
+                credentialsId: env.GIT_CREDENTIALS_ID
+            ]],
+            extensions: [[
+                $class: 'WipeWorkspace'
+            ]]
+    }
+
+    stage('tox') {
+        sh '''#!/bin/bash -ex
+        tox -v
+        '''
+
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+          runAsUser: 1000
+      containers:
+      - name: "main"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+
+if (env.K8S_CLUSTER == 'unset') {
+    node(env.NODE_LABEL) {
+        docker.image(env.DOCKER_IMAGE).inside('--entrypoint=""') {
+            main()
+        }
+    }
+} else {
+    podTemplate(
+            cloud: env.K8S_CLUSTER,
+            yaml: podTpl,
+            showRawYaml: false
+        ) {
+            node(POD_LABEL) {
+            container('main') {
+                main()
+            }
+        }
+    }
+}
diff --git a/common/pipelines/update-jenkins-config.groovy b/common/pipelines/update-jenkins-config.groovy
new file mode 100644
index 0000000..0e76192
--- /dev/null
+++ b/common/pipelines/update-jenkins-config.groovy
@@ -0,0 +1,75 @@
+#!groovy
+import io.jenkins.plugins.casc.ConfigurationAsCode
+
+@NonCPS
+Boolean testConfig(String cascPath) {
+    def casc = ConfigurationAsCode.get()
+    def form
+    Boolean result = true
+    try {
+        form = casc.doCheckNewSource(cascPath)
+    } catch (Exception e) {
+        result = false
+        message = e.toString()
+    } finally {
+        if (form) {
+            println form.kind
+            println form.message.split('<')[0]
+            if (form.kind.toString() != 'OK') {
+                result = false
+            }
+        }
+    }
+    return result
+}
+
+@NonCPS
+void applyConfig(String cascPath) {
+    ConfigurationAsCode.get().configure(cascPath)
+}
+
+node('master') {
+    Boolean doApply = false
+    String cascPath = env.WORKSPACE
+
+    if (env.GERRIT_EVENT_TYPE == 'ref-updated') {
+        doApply = true
+        cascPath = ConfigurationAsCode.get().getStandardConfig()[0] ?: "${env.HOME}/casc"
+    }
+
+    stage('SCM checkout') {
+        String refSpec = env.GERRIT_REFSPEC ?: env.GERRIT_REFNAME
+        String gitUrl = "ssh://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+        echo "Checking out git repository from ${env.GERRIT_PROJECT} @ ${refSpec}"
+
+        dir(cascPath) {
+            checkout([
+                $class: 'GitSCM',
+                branches: [[
+                    name: 'FETCH_HEAD',
+                ]],
+                userRemoteConfigs: [[
+                   url: gitUrl,
+                   refspec: refSpec,
+                   credentialsId: env.GIT_CREDENTIALS_ID,
+                ]],
+                extensions: [[
+                    $class: 'WipeWorkspace',
+                ]],
+            ])
+        }
+    }
+
+    stage('Test configuration') {
+        if (! testConfig(cascPath) ) {
+            currentBuild.result = 'FAILURE'
+            throw new RuntimeException ('Fail')
+        }
+    }
+
+    if (doApply) {
+        stage('Apply configuration') {
+          applyConfig(cascPath)
+        }
+    }
+}
diff --git a/common/pipelines/update-jenkins-jobs.groovy b/common/pipelines/update-jenkins-jobs.groovy
new file mode 100644
index 0000000..4bb3702
--- /dev/null
+++ b/common/pipelines/update-jenkins-jobs.groovy
@@ -0,0 +1,202 @@
+#!groovy
+
+def main(String cacheHome = 'output/cache') {
+    String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+    String gitRef = env.GERRIT_BRANCH ?: env.GERRIT_REFNAME
+    String artInfraNamespace = 'binary-dev-local/infra'
+    String artCacheFile = "${env.CI_NAME}.jjb.zip"
+    String artCacheUrl = "${env.ARTIFACTORY_URL}/artifactory/${artInfraNamespace}/${artCacheFile}"
+    String artCredential = env.ART_CREDENTIALS_ID
+    String jenkinsCredential = env.JENKINS_CREDENTIALS_ID
+    def response
+
+    def jenkinsJobs
+    def jjbJobs
+    def jobsToRemove
+
+    currentBuild.description = ''
+
+    // Set current build description
+    if (env.GERRIT_CHANGE_URL) {
+        currentBuild.description = """
+        <p>
+          Triggered by change: <a href="${env.GERRIT_CHANGE_URL}">${env.GERRIT_CHANGE_NUMBER},${env.GERRIT_PATCHSET_NUMBER}</a><br/>
+          Project: <b>${env.GERRIT_PROJECT}</b><br/>
+          Branch: <b>${env.GERRIT_BRANCH}</b><br/>
+          Subject: <b>${env.GERRIT_CHANGE_SUBJECT}</b><br/>
+        </p>
+        """
+    }
+
+    stage('SCM checkout') {
+        if (env.MAINTAIN_MODE.toLowerCase() == 'false') {
+            checkout([
+                $class: 'GitSCM',
+                branches: [[
+                    name: 'FETCH_HEAD'
+                ]],
+                userRemoteConfigs: [[
+                    url: gitUrl,
+                    refspec: gitRef,
+                    credentialsId: env.GIT_CREDENTIALS_ID
+                ]],
+                extensions: [[
+                    $class: 'WipeWorkspace'
+                ]],
+            ])
+        }
+    }
+
+    stage('Get JJB cache') {
+        dir(cacheHome) {
+            response = httpRequest \
+                url: artCacheUrl,
+                authentication: artCredential,
+                httpMode: 'GET',
+                outputFile: artCacheFile,
+                validResponseCodes: '100:399,404'
+            if (response.status != 404) {
+               unzip \
+                   zipFile: artCacheFile,
+                   dir: '.cache'
+            }
+        }
+    }
+
+    stage('Update JJB jobs') {
+        if (env.MAINTAIN_MODE.toLowerCase() == 'false') {
+            withCredentials([
+                usernamePassword(
+                    credentialsId: env.JENKINS_CREDENTIALS_ID,
+                    usernameVariable: 'JJB_USER',
+                    passwordVariable: 'JJB_PASSWORD')
+                ]) {
+                    withEnv([
+                        "HOME=${cacheHome}"
+                    ]) {
+                        sh 'tox -v -e update $JOBS_LIST'
+                }
+            }
+        } else {
+            input \
+                message: 'Sleeping for maintainance'
+        }
+    }
+
+    stage('Get deployed jobs list') {
+        response = httpRequest \
+            url: "${JENKINS_URL}/crumbIssuer/api/json",
+            authentication: jenkinsCredential
+        def crumb = readJSON text: response.getContent()
+
+        response = httpRequest \
+            url: "${JENKINS_URL}/api/json?tree=jobs[name,description]",
+            authentication: jenkinsCredential,
+            customHeaders: [[
+                name: crumb.crumbRequestField,
+                value: crumb.crumb,
+                maskValue: true
+            ]]
+        def jobs = readJSON text: response.getContent()
+
+        jenkinsJobs = jobs.jobs.findAll {
+                // Filter jenkins jobs deployed by JJB
+                it.description.toString().contains('Managed by Jenkins Job Builder')
+            }.collect{ it.name }
+    }
+
+    stage('Get JJB jobs list') {
+        withEnv([
+            "HOME=${cacheHome}"
+        ]) {
+            sh 'tox -v -e jobs'
+        }
+        dir("output/${env.CI_NAME}") {
+            jjbJobs = sh(
+                script: "grep -rl actions | sed 's|/config.xml\$||g'",
+                returnStdout: true
+            ).trim().readLines()
+        }
+
+        if (jjbJobs.size() == 0) {
+            error 'ERROR: Unexpected JJB output. No generated jobs found'
+        }
+    }
+
+    stage('Remove undefined jobs') {
+        jobsToRemove = jenkinsJobs.findAll { ! jjbJobs.contains(it) }
+
+        if (jobsToRemove.size() > 0) {
+            withCredentials([
+                usernamePassword(
+                    credentialsId: jenkinsCredential,
+                    usernameVariable: 'JJB_USER',
+                    passwordVariable: 'JJB_PASSWORD')
+            ]) {
+                withEnv([
+                    "HOME=${cacheHome}"
+                ]) {
+                    sh "tox -v -e delete ${jobsToRemove.join(' ')}"
+                }
+            }
+
+            String description = '<b>DELETED</b><ul>'
+            jobsToRemove.each { description += "<li>${it}</li>" }
+            description += '</ul>'
+            currentBuild.description += description
+        } else {
+            currentBuild.description += 'No jobs to remove'
+        }
+    }
+
+    stage('Save JJB cache') {
+        dir(cacheHome) {
+            sh "rm -f ${artCacheFile}"
+            zip \
+                zipFile: artCacheFile,
+                dir: '.cache',
+                glob: 'jenkins_jobs/**'
+            response = httpRequest \
+                url: artCacheUrl,
+                authentication: artCredential,
+                httpMode: 'PUT',
+                multipartName: 'file',
+                uploadFile: artCacheFile
+        }
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+          runAsUser: 1000
+      containers:
+      - name: "tox"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+
+if (env.K8S_CLUSTER == 'unset') {
+    node(env.SLAVE_LABEL ?: 'docker') {
+        main(env.WORKSPACE)
+    }
+} else {
+    podTemplate(
+            cloud: env.K8S_CLUSTER,
+            yaml: podTpl,
+            showRawYaml: false
+    ) {
+        node(POD_LABEL) {
+            container('tox') {
+                main()
+            }
+        }
+    }
+}
+
diff --git a/common/pipelines/yamllint.groovy b/common/pipelines/yamllint.groovy
new file mode 100644
index 0000000..94b618a
--- /dev/null
+++ b/common/pipelines/yamllint.groovy
@@ -0,0 +1,70 @@
+#!groovy
+
+def main() {
+    String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+    String gitRef = env.GERRIT_REFSPEC
+
+    stage('SCM checkout') {
+        echo "Checking out git repository from ${gitUrl} @ ${gitRef}"
+
+        checkout \
+            $class: 'GitSCM',
+            branches: [[
+                name: 'FETCH_HEAD'
+            ]],
+            userRemoteConfigs: [[
+                url: gitUrl,
+                refspec: gitRef,
+                credentialsId: env.GIT_CREDENTIALS_ID
+            ]],
+            extensions: [[
+                $class: 'WipeWorkspace'
+            ]]
+    }
+
+    stage('Yamllint') {
+        sh '''#!/bin/bash -ex
+        YAMLLINT=$(which yamllint)
+        [ -f '.yamllint' ] && YAMLLINT="${YAMLLINT} -c .yamllint"
+        git diff HEAD^ --name-only --diff-filter=AM \
+            | grep -E '\\.ya?ml$' \
+            | xargs --no-run-if-empty ${YAMLLINT}
+        '''
+
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+          runAsUser: 1000
+      containers:
+      - name: "yamllint"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+if (env.K8S_CLUSTER == 'unset') {
+    node(env.NODE_LABEL) {
+        docker.image(env.DOCKER_IMAGE).inside('--entrypoint=""') {
+            main()
+        }
+    }
+} else {
+    podTemplate(
+            cloud: env.K8S_CLUSTER,
+            yaml: podTpl,
+            showRawYaml: false
+        ) {
+            node(POD_LABEL) {
+            container('yamllint') {
+                main()
+            }
+        }
+    }
+}
diff --git a/common/pvc-for-jjb-update.yaml.k8s b/common/pvc-for-jjb-update.yaml.k8s
new file mode 100644
index 0000000..aebe156
--- /dev/null
+++ b/common/pvc-for-jjb-update.yaml.k8s
@@ -0,0 +1,11 @@
+---
+apiVersion: "v1"
+kind: "PersistentVolumeClaim"
+metadata:
+  name: "jjb-cache"
+spec:
+  accessModes:
+    - ReadWriteOnce
+  resources:
+    requests:
+      storage: 2Gi
diff --git a/common/templates/codenarc.yaml b/common/templates/codenarc.yaml
new file mode 100644
index 0000000..4843314
--- /dev/null
+++ b/common/templates/codenarc.yaml
@@ -0,0 +1,37 @@
+---
+
+- job-template:
+    name: infra.codenarc
+    id: common/codenarc
+    project-type: pipeline
+    description: Check groovy scripts by CodeNarc
+    concurrent: True
+
+    properties:
+    - build-discarder:
+        days-to-keep: 14
+    - inject:
+        properties-content: |
+          DOCKER_IMAGE=docker-prod-local.docker.mirantis.net/infra/codenarc:latest
+          K8S_CLUSTER={k8s_cluster}
+          NODE_LABEL={codenarc_node|docker}
+          GIT_CREDENTIALS_ID={git-credentials-id}
+
+
+    parameters:
+    - hidden:
+        name: DEFAULT_RULES
+        default: !include-raw-escape: ../../codenarcRules.groovy
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        projects: '{obj:projects}'
+        trigger-on:
+        - patchset-created-event:
+            exclude-drafts: True
+        custom-url: '- ${{JOB_NAME}} ${{BUILD_URL}}artifact/report.html'
+        skip-vote:
+          notbuilt: true
+
+    dsl: !include-raw-escape: ../pipelines/codenarc.groovy
diff --git a/common/templates/shellcheck.yaml b/common/templates/shellcheck.yaml
new file mode 100644
index 0000000..d989d04
--- /dev/null
+++ b/common/templates/shellcheck.yaml
@@ -0,0 +1,31 @@
+---
+- job-template:
+    name: infra.shellcheck
+    id: common/shellcheck
+    project-type: pipeline
+    description: Check shell scripts by ShellCheck
+    concurrent: True
+
+    properties:
+    - build-discarder:
+        days-to-keep: 14
+    - inject:
+        properties-content: |
+          K8S_CLUSTER={k8s_cluster}
+          DOCKER_IMAGE={docker-dev-virtual}/mirantis/openstack-ci/jenkins-job-tests:latest
+          NODE_LABEL={shellcheck_node|docker}
+          GIT_CREDENTIALS_ID={git-credentials-id}
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        projects: '{obj:projects}'
+        trigger-on:
+        - patchset-created-event:
+            exclude-drafts: True
+        custom-url: '- ${{JOB_NAME}} ${{BUILD_URL}}console'
+        skip-vote:
+          notbuilt: true
+
+    dsl: !include-raw-escape: ../pipelines/shellcheck.groovy
+
diff --git a/common/templates/tox.yaml b/common/templates/tox.yaml
new file mode 100644
index 0000000..e837ec9
--- /dev/null
+++ b/common/templates/tox.yaml
@@ -0,0 +1,29 @@
+---
+- job-template:
+    name: infra.tox
+    id: common/tox
+    project-type: pipeline
+    description: Run tox tests
+    concurrent: True
+
+    properties:
+    - build-discarder:
+        days-to-keep: 14
+    - inject:
+        properties-content: |
+          K8S_CLUSTER={k8s_cluster}
+          DOCKER_IMAGE={docker-dev-virtual}/mirantis/openstack-ci/jenkins-job-tests:latest
+          NODE_LABEL={tox_node|docker}
+          GIT_CREDENTIALS_ID={git-credentials-id}
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        projects: '{obj:projects}'
+        trigger-on:
+        - patchset-created-event:
+            exclude-drafts: True
+        custom-url: '- ${{JOB_NAME}} ${{BUILD_URL}}console'
+        skip-vote: '{obj:skip_vote}'
+
+    dsl: !include-raw-escape: ../pipelines/tox.groovy
diff --git a/common/templates/yamllint.yaml b/common/templates/yamllint.yaml
new file mode 100644
index 0000000..eb5b341
--- /dev/null
+++ b/common/templates/yamllint.yaml
@@ -0,0 +1,29 @@
+---
+- job-template:
+    name: infra.yamllint
+    id: common/yamllint
+    project-type: pipeline
+    description: Check yaml files by yamllint.
+    concurrent: True
+
+    properties:
+    - build-discarder:
+        days-to-keep: 14
+    - inject:
+        properties-content: |
+          K8S_CLUSTER={k8s_cluster}
+          DOCKER_IMAGE={docker-dev-virtual}/mirantis/openstack-ci/jenkins-job-tests:latest
+          NODE_LABEL={yamllint_node|docker}
+          GIT_CREDENTIALS_ID={git-credentials-id}
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        projects: '{obj:projects}'
+        trigger-on:
+        - patchset-created-event:
+            exclude-drafts: True
+        custom-url: '- ${{JOB_NAME}} ${{BUILD_URL}}console'
+        skip-vote: '{obj:skip_vote}'
+
+    dsl: !include-raw-escape: ../pipelines/yamllint.groovy
diff --git a/common/test-jenkins-jobs.yaml b/common/test-jenkins-jobs.yaml
new file mode 100644
index 0000000..80f71e8
--- /dev/null
+++ b/common/test-jenkins-jobs.yaml
@@ -0,0 +1,46 @@
+---
+- project:
+    name: test-jenkins-jobs
+    jobs:
+    - infra/jenkins-jobs.check
+
+- job-template:
+    name: infra.jenkins-jobs.check
+    id: infra/jenkins-jobs.check
+    project-type: pipeline
+    description: Check job definitions by Jenkins Job Builder
+    concurrent: True
+
+    properties:
+    - build-discarder:
+        days-to-keep: 14
+    - inject:
+        properties-content: |
+          K8S_CLUSTER={k8s_cluster}
+          GIT_CREDENTIALS_ID={git-credentials-id}
+          DOCKER_IMAGE={docker-dev-virtual}/mirantis/openstack-ci/jenkins-job-tests:latest
+          CI_NAME={ci_name}
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        projects:
+        - project-compare-type: PLAIN
+          project-pattern: mcp-ci/jenkins-jobs
+          branches:
+          - branch-pattern: 'master'
+          file-paths:
+          - compare-type: ANT
+            pattern: 'common/*'
+          - compare-type: ANT
+            pattern: 'common/**/*'
+          - compare-type: ANT
+            pattern: 'servers/{ci_name}/*'
+          - compare-type: ANT
+            pattern: 'servers/{ci_name}/**/*'
+        trigger-on:
+        - patchset-created-event:
+            exclude-drafts: True
+        custom-url: '- ${{JOB_NAME}} ${{BUILD_URL}}'
+
+    dsl: !include-raw-escape: pipelines/test-jenkins-jobs.groovy
diff --git a/common/update-jenkins-config.yaml b/common/update-jenkins-config.yaml
new file mode 100644
index 0000000..9be1f85
--- /dev/null
+++ b/common/update-jenkins-config.yaml
@@ -0,0 +1,36 @@
+- project:
+    name: jenkins-config
+    jobs:
+    - infra/jenkins-config.checkupdate
+
+- job-template:
+    name: 'infra.jenkins-config.checkupdate'
+    id: infra/jenkins-config.checkupdate
+    description: |
+      <p>Verify and apply JCasC config</p>
+    project-type: pipeline
+    properties:
+    - build-discarder:
+        days-to-keep: 3
+    - inject:
+        properties-content: |
+          GIT_CREDENTIALS_ID={git-credentials-id}
+
+    concurrent: false
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        trigger-on:
+        - patchset-created-event
+        - ref-updated-event
+        #- comment-added-contains-event:
+        #    comment-contains-value: '(?i)^(Patch Set [0-9]+:)?( [\w\\+-]*)*(\n\n)?\s*(rebuild|recheck|retest|reverify)'
+        projects:
+        - project-compare-type: 'PLAIN'
+          project-pattern: 'mcp-ci/jenkins-config'
+          branches:
+          - branch-compare-type: 'PLAIN'
+            branch-pattern: '{jcasc_branch}'
+        custom-url: '* $JOB_NAME $BUILD_URL'
+    dsl: !include-raw-escape: pipelines/update-jenkins-config.groovy
+    sandbox: false
diff --git a/common/update-jenkins-jobs.yaml b/common/update-jenkins-jobs.yaml
new file mode 100644
index 0000000..fdb6a03
--- /dev/null
+++ b/common/update-jenkins-jobs.yaml
@@ -0,0 +1,58 @@
+- project:
+    name: jenkins-jobs
+    jobs:
+    - infra/update-jenkins-jobs
+
+- job-template:
+    name: 'infra.jenkins-jobs.update'
+    id: infra/update-jenkins-jobs
+    project-type: pipeline
+    description: |
+      <p>Update jenkins jobs configuration</p>
+      <p>Requires python-tox package and user credentials stored as JJB_USER and JJB_PASSWORD</p>
+    concurrent: false
+
+    jjb_project: 'mcp-ci/jenkins-jobs'
+
+    properties:
+    - build-discarder:
+        days-to-keep: 3
+    - inject:
+        properties-content: |
+          K8S_CLUSTER={k8s_cluster}
+          GIT_CREDENTIALS_ID={git-credentials-id}
+          JENKINS_CREDENTIALS_ID={jjb_credentials_id}
+          DOCKER_IMAGE={docker-dev-virtual}/mirantis/openstack-ci/jenkins-job-tests:latest
+          CI_NAME={ci_name}
+          SLAVE_LABEL={jjb_update_label}
+          ARTIFACTORY_URL={artifactory-url}
+          ART_CREDENTIALS_ID={artifactory_credentials_id}
+
+    parameters:
+    - bool:
+        name: MAINTAIN_MODE
+        default: false
+        description: Enable maintaining mode
+    - string:
+        name: JOBS_LIST
+        description: |
+          Space separated list of jobs to update. Will update all jobs if empty
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        projects:
+        - project-compare-type: PLAIN
+          project-pattern: '{jjb_project}'
+          branches:
+          - branch-pattern: 'master'
+          file-paths:
+          - compare-type: ANT
+            pattern: 'common/**'
+          - compare-type: ANT
+            pattern: 'servers/{ci_name}/**'
+        trigger-on:
+        - change-merged-event
+        custom-url: '- ${{JOB_NAME}} ${{BUILD_URL}}'
+
+    dsl: !include-raw-escape: pipelines/update-jenkins-jobs.groovy