Initial commit
Add infrastructure jobs for sandbox

Related-PROD: RE-336

Change-Id: I2140d47e3fc360ab05f92175b29b31e69b2ec10b
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..57b794a
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,28 @@
+apply plugin: 'groovy'
+apply plugin: 'codenarc'
+
+String jcenterRepo = System.getenv('ARTIFACTORY_URL') ?: 'https://artifactory.mcp.mirantis.net/jcenter'
+String rulesFile = 'codenarcRules.groovy'
+
+repositories {
+  maven {
+    url jcenterRepo
+  }
+}
+
+
+sourceSets {
+  main {
+    groovy {
+      exclude rulesFile
+      srcDir '.'
+    }
+  }
+}
+
+compileGroovy.enabled = false
+
+codenarc {
+  configFile = new File(rulesFile)
+  reportFormat = 'console'
+}
diff --git a/codenarcRules.groovy b/codenarcRules.groovy
new file mode 100644
index 0000000..5ce9ca1
--- /dev/null
+++ b/codenarcRules.groovy
@@ -0,0 +1,133 @@
+ruleset {
+  ruleset('rulesets/basic.xml') {
+    EmptyCatchBlock(enabled:false)
+  }
+  ruleset('rulesets/braces.xml')
+  ruleset('rulesets/comments.xml') {
+    // Not necessarily an issue
+    JavadocEmptyFirstLine(enabled:false)
+    JavadocEmptyLastLine(enabled:false)
+    JavadocMissingParamDescription(enabled:false)
+    JavadocConsecutiveEmptyLines(enabled:false)
+  }
+  ruleset('rulesets/concurrency.xml') {
+    // Not necessarily an issue
+    BusyWait(enabled:false)
+  }
+  ruleset('rulesets/convention.xml') {
+    // Don't need due to code readablilty
+    NoDef(enabled:false)
+    // Not necessarily an issue
+    CompileStatic(enabled:false)
+    // TBD
+    CouldBeElvis(enabled:false)
+    // Not necessarily an issue
+    TrailingComma(enabled:false)
+    // Not necessarily an issue
+    VariableTypeRequired(enabled:false)
+    // Not necessarily an issue
+    FieldTypeRequired(enabled:false)
+    // Not necessarily an issue
+    MethodParameterTypeRequired(enabled:false)
+    // Not necessarily an issue
+    MethodReturnTypeRequired(enabled:false)
+    // Not necessarily an issue
+    NoJavaUtilDate(enabled:false)
+  }
+  ruleset('rulesets/design.xml') {
+    // Don't need due to code readablilty
+    BuilderMethodWithSideEffects(enabled:false)
+    // Sometimes nested loop is cleaner than extracting a new method
+    NestedForLoop(enabled:false)
+    // TBD
+    ImplementationAsType(enabled:false)
+    // Not necessarily an issue
+    Instanceof(enabled:false)
+  }
+  ruleset('rulesets/dry.xml') {
+    DuplicateNumberLiteral(enabled: false)
+    DuplicateStringLiteral(enabled: false)
+    DuplicateMapLiteral(enabled: false)
+    DuplicateListLiteral(enabled: false)
+  }
+//  Raises a lot of "Compilation failed" warnings
+//  ruleset('rulesets/enhanced.xml')
+  ruleset('rulesets/exceptions.xml') {
+    // Not necessarily an issue
+    CatchException(enabled:false)
+    // Not necessarily an issue
+    ThrowRuntimeException(enabled:false)
+    // Not necessarily an issue
+    ReturnNullFromCatchBlock(enabled:false)
+
+  }
+  ruleset('rulesets/formatting.xml') {
+    // Don't need due to code readablilty
+    ConsecutiveBlankLines(enabled:false)
+    // TBD
+    SpaceAfterClosingBrace(enabled:false)
+    SpaceBeforeOpeningBrace(enabled:false)
+    // Enforce at least one space after map entry colon
+    //SpaceAroundMapEntryColon {
+    //        characterAfterColonRegex = /\s/
+    //        characterBeforeColonRegex = /./
+    //}
+    SpaceAroundMapEntryColon(enabled:false)
+    //TBD
+    Indentation(enabled:false)
+    // TBD
+    LineLength(enabled: false)
+    BlockStartsWithBlankLine(enabled: false)
+    BlockEndsWithBlankLine(enabled: false)
+  }
+  ruleset('rulesets/generic.xml')
+  ruleset('rulesets/grails.xml')
+  ruleset('rulesets/groovyism.xml') {
+    // Not necessarily an issue
+    GStringExpressionWithinString(enabled:false)
+  }
+  ruleset('rulesets/imports.xml')
+  ruleset('rulesets/jdbc.xml')
+  ruleset('rulesets/junit.xml')
+  ruleset('rulesets/logging.xml') {
+    // Can't be used in jenklins pipelines
+    Println(enabled:false)
+  }
+  ruleset('rulesets/naming.xml') {
+    // Don't need due to code readablilty
+    FactoryMethodName(enabled:false)
+    // Don't need due to code readablilty
+    VariableName(enabled:false)
+  }
+  ruleset('rulesets/security.xml') {
+    // Don't need to satisfy the Java Beans specification
+    JavaIoPackageAccess(enabled:false)
+  }
+  ruleset('rulesets/serialization.xml')
+  ruleset('rulesets/size.xml') {
+    // TBD
+    AbcMetric(enabled:false)
+    // TBD
+    MethodSize(enabled:false)
+    // TBD
+    NestedBlockDepth(enabled:false)
+    // Not necessarily an issue
+    ParameterCount(enabled:false)
+    CyclomaticComplexity(enabled:false)
+  }
+  ruleset('rulesets/unnecessary.xml') {
+    // Don't need due to code readablilty
+    UnnecessaryDefInVariableDeclaration(enabled:false)
+    // Not necessarily an issue
+    UnnecessaryGetter(enabled:false)
+    // Not necessarily an issue
+    UnnecessarySetter(enabled:false)
+    // Not necessarily an issue
+    UnnecessaryReturnKeyword(enabled:false)
+    // Not necessarily an issue
+    UnnecessaryObjectReferences(enabled:false)
+    // Not necessarily an issue
+    UnnecessaryCollectCall(enabled:false)
+  }
+  ruleset('rulesets/unused.xml')
+}
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
diff --git a/conf/jenkins_job.ini.example b/conf/jenkins_job.ini.example
new file mode 100644
index 0000000..426a39e
--- /dev/null
+++ b/conf/jenkins_job.ini.example
@@ -0,0 +1,14 @@
+[jenkins]
+user=my_username
+password=my_secret_password
+url=https://my.jenkins.com/
+query_plugins_info=False
+
+[job_builder]
+ignore_cache=False
+keep_descriptions=False
+recursive=True
+include_path=.:scripts
+
+[__future__]
+param_order_from_yaml=true
diff --git a/servers/sandbox-ci/global.yaml b/servers/sandbox-ci/global.yaml
new file mode 100644
index 0000000..34b29d3
--- /dev/null
+++ b/servers/sandbox-ci/global.yaml
@@ -0,0 +1,34 @@
+- defaults:
+    name: global
+    project-type: pipeline
+    description: '{job_description}'
+    sandbox: true
+
+    ###
+    #
+    # Global variables
+    #
+    ###
+
+    job_description: Do not edit this job through the web UI!
+
+    ci_name: 'sandbox-ci'
+    jcasc_branch: '{ci_name}'
+    k8s_cluster: unset
+    jjb_update_label: unset
+    jjb_credentials_id: jjb-update
+
+    gerrit-server: 'mcp-gerrit'
+    gerrit-host: 'gerrit.mcp.mirantis.net'
+    gerrit-port: '29418'
+    gerrit-url:  'https://gerrit.mcp.mirantis.net'
+    gerrit-git-url: 'ssh://{gerrit-host}:{gerrit-port}'
+    gerrit-custom-url: '* $JOB_NAME $BUILD_URL/consoleText'
+
+    artifactory-url:    'https://artifactory.mcp.mirantis.net'
+    artifactory_credentials_id: artifactory
+
+    git-credentials-id: '{ci_name}'
+    docker-prod-virtual: 'docker-prod-virtual.docker.mirantis.net'
+    docker-dev-local: 'docker-dev-local.docker.mirantis.net'
+    docker-dev-virtual:  'docker-dev-virtual.docker.mirantis.net'
diff --git a/servers/sandbox-ci/infra/codenarc.yaml b/servers/sandbox-ci/infra/codenarc.yaml
new file mode 100644
index 0000000..cd1b985
--- /dev/null
+++ b/servers/sandbox-ci/infra/codenarc.yaml
@@ -0,0 +1,16 @@
+---
+
+- project:
+    name: codenarc
+    jobs:
+    - common/codenarc:
+        projects:
+        - project-pattern: mcp-ci/jenkins-jobs
+          branches:
+          - branch-pattern: 'master'
+          file-paths:
+          - compare-type: ANT
+            pattern: 'common/**/*.groovy'
+          - compare-type: ANT
+            pattern: 'servers/{ci_name}/**/*.groovy'
+
diff --git a/servers/sandbox-ci/infra/gerrit-projects.yaml b/servers/sandbox-ci/infra/gerrit-projects.yaml
new file mode 100644
index 0000000..d7560a9
--- /dev/null
+++ b/servers/sandbox-ci/infra/gerrit-projects.yaml
@@ -0,0 +1,56 @@
+- project:
+    name: gerrit-projects
+    tox_env:
+    - update:
+        #gerrit_event: ref-updated-event
+        #refspec: '$GERRIT_REFNAME'
+        gerrit_event: change-merged-event
+        refspec: '$GERRIT_BRANCH'
+    - check:
+        gerrit_event: patchset-created-event
+        refspec: '$GERRIT_REFSPEC'
+    jobs:
+    - infra/gerrit-projects
+
+- job-template:
+    name: infra.gerrit-projects.{tox_env}
+    id: infra/gerrit-projects
+    project-type: pipeline
+    concurrent: False
+
+    properties:
+    - build-discarder:
+        days-to-keep: 15
+    - inject:
+        properties-content: |
+          CI_NAME={ci_name}
+          TOX_ENV={tox_env}
+          DOCKER_IMAGE={docker-infra-agent}
+          K8S_CLUSTER={k8s_cluster}
+          GIT_CREDENTIALS_ID={git-credentials-id}
+          REFSPEC={refspec}
+          JEEPYB_GERRIT_HOST={gerrit-host}
+          JEEPYB_USER=mcp-gerrit
+          JEEPYB_COMMITTER=Gerrit MCP <mcp-gerrit@mirantis.com>
+          JEEPYB_CREDENTIALS_ID=gerrit
+          ARTIFACTORY_URL={artifactory-url}
+          ART_CREDENTIALS_ID={artifactory_credentials_id}
+
+    parameters:
+    - bool:
+        name: MAINTAIN_MODE
+        default: false
+        description: Enable maintaining mode
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        trigger-on:
+        - '{gerrit_event}'
+        projects:
+        - project-pattern: '{gerrit-projects-project}'
+          branches:
+          - branch-pattern: 'master'
+        custom-url: '* $JOB_NAME $BUILD_URL'
+
+    dsl: !include-raw-escape: pipelines/gerrit-projects.groovy
diff --git a/servers/sandbox-ci/infra/pipelines/gerrit-projects.groovy b/servers/sandbox-ci/infra/pipelines/gerrit-projects.groovy
new file mode 100644
index 0000000..3835ceb
--- /dev/null
+++ b/servers/sandbox-ci/infra/pipelines/gerrit-projects.groovy
@@ -0,0 +1,102 @@
+#!groovy
+
+def main() {
+    String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+    String gitRef = env.REFSPEC
+    String artInfraNamespace = 'binary-dev-local/infra'
+    String artCacheFile = "${env.CI_NAME}.jeepyb.zip"
+    String artCacheUrl = "${env.ARTIFACTORY_URL}/artifactory/${artInfraNamespace}/${artCacheFile}"
+    String artCredential = env.ART_CREDENTIALS_ID
+    String cacheHome = '.workspace'
+    def response
+
+    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 jeepyb cache') {
+        dir(cacheHome) {
+            response = httpRequest \
+                url: artCacheUrl,
+                authentication: artCredential,
+                httpMode: 'GET',
+                outputFile: artCacheFile,
+                validResponseCodes: '100:399,404'
+            if (response.status != 404) {
+               unzip \
+                   zipFile: artCacheFile
+            }
+        }
+    }
+
+    stage("Jeepyb-${TOX_ENV}") {
+        if (env.MAINTAIN_MODE.toLowerCase() == 'false') {
+            sshagent([env.JEEPYB_CREDENTIALS_ID]) {
+                withEnv([
+                    "HOME=${cacheHome}",
+                    'GIT_SSH_VARIANT=ssh'
+                ]) {
+                    sh 'tox -v -e ${TOX_ENV}'
+                }
+            }
+        } else {
+            input \
+                message: 'Sleeping for maintainance'
+        }
+    }
+    stage('Save jeepyb cache') {
+        dir(cacheHome) {
+            sh "rm -f ${artCacheFile}"
+            zip \
+                zipFile: artCacheFile,
+                glob: 'cache/project.cache'
+            response = httpRequest \
+                url: artCacheUrl,
+                authentication: artCredential,
+                httpMode: 'PUT',
+                multipartName: 'file',
+                uploadFile: artCacheFile
+        }
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+        runAsUser: 1000
+      containers:
+      - name: "main"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+podTemplate(
+        cloud: env.K8S_CLUSTER,
+        yaml: podTpl,
+        showRawYaml: false
+    ) {
+        node(POD_LABEL) {
+        container('main') {
+            main()
+        }
+    }
+}
diff --git a/servers/sandbox-ci/infra/pipelines/terraform-validate.groovy b/servers/sandbox-ci/infra/pipelines/terraform-validate.groovy
new file mode 100644
index 0000000..13e0a6f
--- /dev/null
+++ b/servers/sandbox-ci/infra/pipelines/terraform-validate.groovy
@@ -0,0 +1,92 @@
+#!groovy
+
+def main() {
+    // Get & prepare source code
+    stage('SCM checkout') {
+        String gitUrl = "${env.GERRIT_SCHEME}://${env.GERRIT_HOST}:${env.GERRIT_PORT}/${env.GERRIT_PROJECT}"
+        String gitRef = env.GERRIT_REFSPEC
+
+        checkout \
+            $class: 'GitSCM',
+            branches: [[
+                name: 'FETCH_HEAD'
+            ]],
+            userRemoteConfigs: [[
+                url: gitUrl,
+                refspec: gitRef,
+                credentialsId: env.GIT_CREDENTIALS_ID
+            ]],
+            extensions: [[
+                $class: 'WipeWorkspace'
+            ]]
+    }
+
+    stage('Terraform-lint') {
+        String changedFolders = sh \
+            script: '''
+                git show \
+                    --name-only \
+                    --diff-filter=AM \
+                    | grep '.tf\$' \
+                    | awk -F'/' '{print $1"/"$2}' \
+                    | uniq
+            ''',
+            returnStdout: true
+
+        Integer result = 0
+        changedFolders.tokenize('\n').each {
+            dir(it) {
+                withCredentials([
+                    usernamePassword(
+                        credentialsId: env.AWS_CREDENTIALS_ID,
+                        usernameVariable: 'AWS_ACCESS_KEY_ID',
+                        passwordVariable: 'AWS_SECRET_ACCESS_KEY'
+                    )
+                ]) {
+                    result += sh \
+                        script: 'terraform init',
+                        returnStatus: true
+                    result += sh \
+                        script: 'terraform validate',
+                        returnStatus: true
+                    result += sh \
+                        script: 'terraform fmt -diff -recursive -check',
+                        returnStatus: true
+                }
+            }
+        }
+        if (result > 0) {
+            currentBuild.result = 'FAILURE'
+        }
+    }
+}
+
+String podTpl = """
+    apiVersion: "v1"
+    kind: "Pod"
+    spec:
+      securityContext:
+          runAsUser: 1000
+      containers:
+      - name: "terraform-test"
+        image: "${env.DOCKER_IMAGE}"
+        command:
+        - "cat"
+        securityContext:
+          privileged: false
+        tty: true
+"""
+
+podTemplate(
+        cloud: env.K8S_CLUSTER,
+        yaml: podTpl,
+        showRawYaml: false
+    ) {
+        node(POD_LABEL) {
+        container('terraform-test') {
+            ansiColor {
+                main()
+            }
+        }
+    }
+}
diff --git a/servers/sandbox-ci/infra/shellcheck.yaml b/servers/sandbox-ci/infra/shellcheck.yaml
new file mode 100644
index 0000000..336ff85
--- /dev/null
+++ b/servers/sandbox-ci/infra/shellcheck.yaml
@@ -0,0 +1,14 @@
+---
+- project:
+    name: shellcheck
+    jobs:
+    - common/shellcheck:
+        projects:
+        - project-pattern: mcp-ci/jenkins-jobs
+          branches:
+          - branch-pattern: 'master'
+          file-paths:
+          - compare-type: ANT
+            pattern: 'common/**/*.sh'
+          - compare-type: ANT
+            pattern: 'servers/{ci_name}/**/*.sh'
diff --git a/servers/sandbox-ci/infra/terraform-validate.yaml b/servers/sandbox-ci/infra/terraform-validate.yaml
new file mode 100644
index 0000000..d79b45e
--- /dev/null
+++ b/servers/sandbox-ci/infra/terraform-validate.yaml
@@ -0,0 +1,42 @@
+---
+- project:
+    name: terraform-validate
+    jobs:
+    - infra/terraform-validate
+
+- job-template:
+    name: infra.terraform-validate
+    id: infra/terraform-validate
+    project-type: pipeline
+    description: Check terraform files by terraform validate and terraform fmt
+    concurrent: true
+
+    properties:
+    - build-discarder:
+        days-to-keep: 14
+    - inject:
+        properties-content: |
+          DOCKER_IMAGE={docker-dev-virtual}/mirantis/openstack-ci/jenkins-job-tests:latest
+          GIT_CREDENTIALS_ID={git-credentials-id}
+          AWS_CREDENTIALS_ID={aws-credentials-id}
+
+    triggers:
+    - gerrit:
+        server-name: '{gerrit-server}'
+        projects:
+        - project-compare-type: PLAIN
+          project-pattern: mcp-ci/infra
+          branches:
+          - branch-pattern: 'master'
+          file-paths:
+          - compare-type: ANT
+            pattern: 'terraform/**'
+        trigger-on:
+        - patchset-created-event:
+            exclude-drafts: True
+        custom-url: '- ${{JOB_NAME}} ${{BUILD_URL}}console'
+        skip-vote:
+          failed: false
+          notbuilt: false
+
+    dsl: !include-raw-escape: pipelines/terraform-validate.groovy
diff --git a/servers/sandbox-ci/infra/tox.yaml b/servers/sandbox-ci/infra/tox.yaml
new file mode 100644
index 0000000..78e3270
--- /dev/null
+++ b/servers/sandbox-ci/infra/tox.yaml
@@ -0,0 +1,16 @@
+---
+- project:
+    name: tox
+
+    jobs:
+    - common/tox:
+        projects:
+        - project-compare-type: PLAIN
+          project-pattern: mcp-ci/infra
+          branches:
+          - branch-compare-type: PLAIN
+            branch-pattern: master
+        skip_vote:
+          failed: false
+          notbuilt: true
+
diff --git a/servers/sandbox-ci/infra/yamllint.yaml b/servers/sandbox-ci/infra/yamllint.yaml
new file mode 100644
index 0000000..fdf0ac1
--- /dev/null
+++ b/servers/sandbox-ci/infra/yamllint.yaml
@@ -0,0 +1,20 @@
+---
+- project:
+    name: yamllint
+    yaml_paths:
+    - compare-type: REG_EXP
+      pattern: '.*\.ya?ml$'
+
+    jobs:
+    - common/yamllint:
+        projects:
+        - project-compare-type: PLAIN
+          project-pattern: mcp-ci/jenkins-config
+          branches:
+          - branch-compare-type: PLAIN
+            branch-pattern: '{ci_name}'
+          file-paths: '{obj:yaml_paths}'
+        skip_vote:
+          failed: false
+          notbuilt: true
+
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..f89ded6
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,62 @@
+[tox]
+minversion = 1.6
+skipsdist = True
+envlist = jobs
+
+[testenv]
+usedevelop = False
+envdir = {toxworkdir}/shared
+deps =
+    git+https://opendev.org/jjb/jenkins-job-builder@3.0.2#egg=jenkins-job-builder
+passenv = JENKINS_URL JJB_* CI_NAME HOME XDG_CACHE_HOME
+whitelist_externals =
+    cp
+    sed
+
+[testenv:jobs]
+commands =
+    jenkins-jobs --version
+    jenkins-jobs \
+        --conf conf/jenkins_job.ini.example \
+        test servers/sandbox-ci:common \
+        -o {toxinidir}/output/sandbox-ci
+
+[testenv:update]
+commands =
+    cp {toxinidir}/conf/jenkins_job.ini.example {toxinidir}/conf/jenkins_job.ini~
+    sed -i "s|^url=.*|url={env:JENKINS_URL:unknown}|" {toxinidir}/conf/jenkins_job.ini~
+    jenkins-jobs --version
+    jenkins-jobs \
+        --conf {toxinidir}/conf/jenkins_job.ini~ \
+        update \
+        --jobs-only \
+        servers/sandbox-ci:common {posargs}
+
+[testenv:compare-xml-old]
+commands =
+    jenkins-jobs --version
+    jenkins-jobs \
+        --conf conf/jenkins_job.ini.example \
+        test \
+        servers/sandbox-ci:common \
+        -o {toxinidir}/output/sandbox-ci/old
+
+[testenv:compare-xml-new]
+commands =
+    jenkins-jobs --version
+    jenkins-jobs \
+        --conf conf/jenkins_job.ini.example \
+        test \
+        servers/sandbox-ci:common \
+        -o {toxinidir}/output/sandbox-ci/new
+
+[testenv:delete]
+commands =
+    cp {toxinidir}/conf/jenkins_job.ini.example {toxinidir}/conf/jenkins_job.ini~
+    sed -i "s|^url=.*|url={env:JENKINS_URL:unknown}|" {toxinidir}/conf/jenkins_job.ini~
+    jenkins-jobs --version
+    jenkins-jobs \
+        --conf {toxinidir}/conf/jenkins_job.ini~ \
+        delete \
+        --jobs-only \
+        {posargs}