├── .gitignore ├── LICENSE ├── README.md ├── pom.xml └── vars ├── retryAndReturn.groovy ├── retryWithPrompt.groovy └── sshDeploy.groovy /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | 3 | # Mobile Tools for Java (J2ME) 4 | .mtj.tmp/ 5 | 6 | # Package Files # 7 | *.jar 8 | *.war 9 | *.ear 10 | .idea 11 | .idea/* 12 | *.iml 13 | 14 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 15 | hs_err_pid* 16 | 17 | *.DS_Store 18 | .project 19 | *.settings 20 | *.bak 21 | public/ 22 | 23 | .idea/vcs.xml 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Naresh Rayapati 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-deploy-library 2 | 3 | Jenkins Pipeline Library - A Yaml wrapper on top of ssh-steps-plugin. 4 | 5 | More about on this [blog](https://engineering.cerner.com/blog/ssh-steps-for-jenkins-pipeline/) 6 | 7 | This is just an example library. 8 | 9 | 10 | Sample YML file: 11 | 12 | 13 | ```yml 14 | config: 15 | credentials_id: sshUserAcct 16 | # retry_with_prompt: true 17 | # retry_and_return: true 18 | # retry_count: 3 19 | 20 | remote_groups: 21 | r_group_1: 22 | - name: node01 23 | host: node01.abc.net 24 | - name: node02 25 | host: node02.abc.net 26 | r_group_2: 27 | - name: node03 28 | host: node03.abc.net 29 | 30 | command_groups: 31 | c_group_1: 32 | - commands: 33 | - 'ls -lrt' 34 | - 'whoami' 35 | - scripts: 36 | - 'test.sh' 37 | c_group_2: 38 | - gets: 39 | - from: 'test.sh' 40 | into: 'test_new.sh' 41 | override: true 42 | - puts: 43 | - from: 'test.sh' 44 | into: '.' 45 | - removes: 46 | - 'test.sh' 47 | 48 | steps: 49 | deploy: 50 | - remote_groups: 51 | - r_group_1 52 | command_groups: 53 | - c_group_1 54 | - remote_groups: 55 | - r_group_2 56 | command_groups: 57 | - c_group_2 58 | ``` 59 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | pom 5 | org.jenkinsci.library 6 | ssh-deploy-library 7 | 1.0.0-SNAPSHOT 8 | 9 | Remote Jenkins Pipeline Library 10 | 11 | 12 | 13 | Jenkins 14 | 15 | 16 | 17 | nrayapati 18 | Naresh Rayapati 19 | 20 | 21 | 22 | 3.2.5 23 | 24 | 25 | 1.4 26 | 2.10.3 27 | scm:git:git@github.com:nrayapati/ssh-deploy-library.git 28 | scm:git:git@github.com:nrayapati/ssh-deploy-library.git 29 | http://github.com/nrayapati/ssh-deploy-library 30 | 31 | 32 | ${scm.connection} 33 | ${scm.url} 34 | HEAD 35 | 36 | 37 | 38 | org.apache.ivy 39 | ivy 40 | 2.4.0 41 | 42 | 43 | com.cloudbees 44 | groovy-cps 45 | 1.16 46 | 47 | 48 | joda-time 49 | joda-time 50 | 2.9.9 51 | 52 | 53 | org.codehaus.groovy 54 | groovy 55 | 2.4.11 56 | 57 | 58 | org.codehaus.groovy 59 | groovy-json 60 | 2.4.3 61 | 62 | 63 | org.jenkins-ci.main 64 | jenkins-core 65 | 2.68 66 | 67 | 68 | org.jenkins-ci.plugins.workflow 69 | workflow-job 70 | 2.10 71 | 72 | 73 | 74 | 75 | 76 | org.codehaus.gmavenplus 77 | gmavenplus-plugin 78 | 1.5 79 | 80 | 81 | 82 | ${project.basedir}/src 83 | 84 | **/*.groovy 85 | 86 | 87 | 88 | ${project.basedir}/vars 89 | 90 | **/*.groovy 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | addSources 99 | addTestSources 100 | compile 101 | testCompile 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | org.apache.maven.plugins 111 | maven-gpg-plugin 112 | 1.5 113 | 114 | 115 | org.apache.maven.plugins 116 | maven-javadoc-plugin 117 | ${maven.javadoc.plugin.version} 118 | 119 | ${javadoc.opts} 120 | 121 | 122 | 123 | com.google.code.sortpom 124 | maven-sortpom-plugin 125 | 2.3.0 126 | 127 | scope,groupId,artifactId 128 | true 129 | 2 130 | false 131 | src/etc/sortpom.xml 132 | warn 133 | 134 | 135 | 136 | 137 | verify 138 | 139 | 140 | 141 | 142 | 143 | 144 | src 145 | 146 | 147 | 148 | doclint-java8-disable 149 | 150 | [1.8,) 151 | 152 | 153 | -Xdoclint:none 154 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /vars/retryAndReturn.groovy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/groovy 2 | def call(Map config = [:], body) { 3 | def retryCount = config.get('retryCount') ?: 2 4 | boolean failedAtleastOnce = false 5 | boolean isSuccessful = false 6 | int retries = 0 7 | while(!isSuccessful && retries++ < retryCount) { 8 | try { 9 | body() 10 | isSuccessful = true 11 | } catch(e) { 12 | failedAtleastOnce = true 13 | println "Error: ${e}" 14 | } 15 | } 16 | [isSuccessful, failedAtleastOnce] 17 | } -------------------------------------------------------------------------------- /vars/retryWithPrompt.groovy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/groovy 2 | import static java.util.UUID.randomUUID 3 | 4 | def call(Map config = [:], body) { 5 | 6 | // default timeout to 900 minutes. 7 | def timeOut = config.get('timeOut') ?: 900 8 | def stepName = config.get('stepName') ?: '?' 9 | 10 | waitUntil { 11 | try { 12 | body() 13 | return true 14 | } 15 | catch(e) { 16 | println "Error: ${e}" 17 | println "StackTrace: ${e.getStackTrace()}" 18 | def actionMessage = "One of the steps (${stepName}) failed. Please Retry(the failed step), Ignore(and continue on with the next step), or Abort the job" 19 | def userInput 20 | timeout(timeOut) { 21 | userInput = input(id: "userInput-${randomUUID() as String}", message: actionMessage, parameters: [[$class: 'ChoiceParameterDefinition', choices: "Retry\nIgnore\n", description: 'Select...', name: 'Select...']]) 22 | } 23 | echo "User Selected: " + userInput.toString() 24 | return userInput == 'Ignore' 25 | } 26 | 27 | } 28 | } -------------------------------------------------------------------------------- /vars/sshDeploy.groovy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/groovy 2 | 3 | def call(String yamlName) { 4 | sshDeploy(yamlName, false) 5 | } 6 | 7 | def call(String yamlName, boolean dryRun) { 8 | def yaml = readYaml file: yamlName 9 | sshDeploy(yaml, dryRun) 10 | } 11 | 12 | def call(yaml, boolean dryRun) { 13 | if(!yaml.config) 14 | error "config missing in the given yml file." 15 | if(!yaml.config.credentials_id) 16 | error "config->credentials_id is missing." 17 | 18 | def failedRemotes = [] 19 | def retriedRemotes = [] 20 | 21 | withCredentials([usernamePassword(credentialsId: yaml.config.credentials_id, passwordVariable: 'password', usernameVariable: 'userName')]) { 22 | 23 | if(!userName && params.SSH_USER) { 24 | error "userName is null or empty, please check credentials_id." 25 | } 26 | 27 | if(!password && params.PASSWORD) { 28 | error "password is null or empty, please check credentials_id." 29 | } 30 | 31 | yaml.steps.each { stageName, step -> 32 | step.each { 33 | def remoteGroups = [:] 34 | def allRemotes = [] 35 | 36 | it.remote_groups.each { 37 | if(!yaml.remote_groups."$it") { 38 | error "remotes groups are empty/invalid for the given stage: ${stageName}, command group: ${it}. Please check yml." 39 | } 40 | remoteGroups[it] = yaml.remote_groups."$it" 41 | } 42 | 43 | // Merge all the commands for the given group 44 | def commandGroups = [:] 45 | it.command_groups.each { 46 | if(!yaml.command_groups."$it") { 47 | error "command groups are empty/invalid for the given stage: ${stageName}, command group: ${it}. Please check yml." 48 | } 49 | commandGroups[it] = yaml.command_groups."$it" 50 | } 51 | 52 | def isSudo = false 53 | // Append user and identity for all the remotes. 54 | remoteGroups.each { remoteGroupName, remotes -> 55 | allRemotes += remotes.collect { remote -> 56 | if(!remote.host) { 57 | throw IllegalArgumentException("host missing for one of the nodes in ${remoteGroupName}") 58 | } 59 | if(!remote.name) 60 | remote.name = remote.host 61 | 62 | if(params.SSH_USER) { 63 | remote.user = params.SSH_USER 64 | remote.password = params.PASSWORD 65 | isSudo = true 66 | } else { 67 | remote.user = userName 68 | remote.password = password 69 | } 70 | 71 | // For now we are settings host checking off. 72 | remote.allowAnyHosts = true 73 | 74 | remote.groupName = remoteGroupName 75 | if(yaml.gateway) { 76 | def gateway = [:] 77 | gateway.name = yaml.gateway.name 78 | gateway.host = yaml.gateway.host 79 | gateway.allowAnyHosts = true 80 | 81 | if(params.SSH_USER) { 82 | gateway.user = params.SSH_USER 83 | gateway.password = params.PASSWORD 84 | } else { 85 | gateway.user = userName 86 | gateway.password = password 87 | } 88 | 89 | remote.gateway = gateway 90 | } 91 | remote 92 | } 93 | } 94 | 95 | // Execute in parallel. 96 | if(allRemotes) { 97 | if(allRemotes.size() > 1) { 98 | def stepsForParallel = allRemotes.collectEntries { remote -> 99 | ["${remote.groupName}-${remote.name}" : transformIntoStep(dryRun, stageName, remote.groupName, remote, commandGroups, isSudo, yaml.config, failedRemotes, retriedRemotes)] 100 | } 101 | stage(stageName + " \u2609 Size: ${allRemotes.size()}") { 102 | parallel stepsForParallel 103 | } 104 | } else { 105 | def remote = allRemotes.first() 106 | stage(stageName + "\n" + remote.groupName + "-" + remote.name) { 107 | transformIntoStep(dryRun, stageName, remote.groupName, remote, commandGroups, isSudo, yaml.config, failedRemotes, retriedRemotes).call() 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | return [failedRemotes, retriedRemotes] 115 | } 116 | 117 | private transformIntoStep(dryRun, stageName, remoteGroupName, remote, commandGroups, isSudo, config, failedRemotes, retriedRemotes) { 118 | return { 119 | def finalRetryResult = true 120 | commandGroups.each { commandGroupName, commands -> 121 | echo "Running ${commandGroupName} group of commands." 122 | commands.each { command -> 123 | command.each { commandName, commandList -> 124 | commandList.each { 125 | validateCommands(stageName, remoteGroupName, commandGroupName, commandName, it) 126 | if(!dryRun) { 127 | def stepName = "${stageName} -> ${remoteGroupName.replace("_", " -> ")} -> ${commandGroupName} -> ${remote.host}" 128 | if (config.retry_with_prompt) { 129 | retryWithPrompt([stepName: stepName]) { 130 | executeCommands(remote, stageName, remoteGroupName, commandGroupName, commandName, it, isSudo) 131 | } 132 | } else if(config.retry_and_return) { 133 | def retryCount = config.retry_count ? config.retry_count.toInteger() : 2 134 | def (isSuccessful, failedAtleastOnce) = retryAndReturn([retryCount: retryCount]) { 135 | executeCommands(remote, stageName, remoteGroupName, commandGroupName, commandName, it, isSudo) 136 | } 137 | if(!isSuccessful) { 138 | finalRetryResult = false 139 | if(!(stepName in failedRemotes)) { 140 | failedRemotes.add(stepName) 141 | } 142 | } else if(failedAtleastOnce) { 143 | if(!(stepName in retriedRemotes)) { 144 | retriedRemotes.add(stepName) 145 | } 146 | } 147 | } else { 148 | executeCommands(remote, stageName, remoteGroupName, commandGroupName, commandName, it, isSudo) 149 | } 150 | } else { 151 | echo "DryRun Mode: Running ${commandName}." 152 | echo "Remote: ${remote}" 153 | echo "Command: ${it}" 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | private validateCommands(stageName, remoteGroupName, commandGroupName, commandName, command) { 163 | if(commandName in ["gets", "puts"]) { 164 | if(!command.from) 165 | error "${stageName} -> ${remoteGroupName} -> ${commandGroupName} -> ${commandName} -> from is empty or null." 166 | if(!command.into) 167 | error "${stageName} -> ${remoteGroupName} -> ${commandGroupName} -> ${commandName} -> into is empty or null." 168 | } 169 | } 170 | 171 | private executeCommands(remote, stageName, remoteGroupName, commandGroupName, commandName, command, isSudo) { 172 | switch (commandName) { 173 | case "commands": 174 | sshCommand remote: remote, command: command, sudo: isSudo 175 | break 176 | case "scripts": 177 | sshScript remote: remote, script: command 178 | break 179 | case "gets": 180 | sshGet remote: remote, from: command.from, into: command.into, override: command.override 181 | break 182 | case "puts": 183 | sshPut remote: remote, from: command.from, into: command.into 184 | break 185 | case "removes": 186 | sshRemove remote: remote, path: command 187 | break 188 | default: 189 | error "Invalid Command: ${stageName} -> ${remoteGroupName} -> ${commandGroupName} -> ${commandName}" 190 | break 191 | } 192 | } 193 | --------------------------------------------------------------------------------