├── images ├── ssh.png ├── trust.png └── demo2017.05.10.gif ├── go.mod ├── docker ├── Dockerfile.windows.amd64 ├── Dockerfile.linux.arm ├── Dockerfile.linux.amd64 ├── Dockerfile.linux.arm64 └── manifest.tmpl ├── tests ├── entrypoint.sh └── .ssh │ ├── id_rsa.pub │ └── id_rsa ├── .drone.jsonnet ├── .gitignore ├── .revive.toml ├── .editorconfig ├── LICENSE ├── go.sum ├── README.md ├── Makefile ├── DOCS.md ├── plugin.go ├── pipeline.libsonnet ├── main.go ├── .drone.yml └── plugin_test.go /images/ssh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/drone-ssh/master/images/ssh.png -------------------------------------------------------------------------------- /images/trust.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/drone-ssh/master/images/trust.png -------------------------------------------------------------------------------- /images/demo2017.05.10.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/drone-ssh/master/images/demo2017.05.10.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/appleboy/drone-ssh 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/appleboy/easyssh-proxy v1.2.0 7 | github.com/joho/godotenv v1.3.0 8 | github.com/stretchr/testify v1.4.0 9 | github.com/urfave/cli v1.22.1 10 | ) 11 | -------------------------------------------------------------------------------- /docker/Dockerfile.windows.amd64: -------------------------------------------------------------------------------- 1 | FROM microsoft/nanoserver:10.0.14393.1884 2 | 3 | LABEL maintainer="Bo-Yi Wu " \ 4 | org.label-schema.name="Drone SSH" \ 5 | org.label-schema.vendor="Bo-Yi Wu" \ 6 | org.label-schema.schema-version="1.0" 7 | 8 | ADD drone-ssh.exe /drone-ssh.exe 9 | ENTRYPOINT [ "\\drone-ssh.exe" ] 10 | -------------------------------------------------------------------------------- /tests/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ ! -f "/etc/ssh/ssh_host_rsa_key" ]; then 4 | # generate fresh rsa key 5 | ssh-keygen -f /etc/ssh/ssh_host_rsa_key -N '' -t rsa 6 | fi 7 | 8 | if [ ! -f "/etc/ssh/ssh_host_dsa_key" ]; then 9 | # generate fresh dsa key 10 | ssh-keygen -f /etc/ssh/ssh_host_dsa_key -N '' -t dsa 11 | fi 12 | 13 | exec "$@" 14 | -------------------------------------------------------------------------------- /docker/Dockerfile.linux.arm: -------------------------------------------------------------------------------- 1 | FROM plugins/base:linux-arm 2 | 3 | LABEL maintainer="Bo-Yi Wu " \ 4 | org.label-schema.name="Drone SSH" \ 5 | org.label-schema.vendor="Bo-Yi Wu" \ 6 | org.label-schema.schema-version="1.0" 7 | 8 | RUN apk add --no-cache ca-certificates && \ 9 | rm -rf /var/cache/apk/* 10 | 11 | ADD release/linux/arm/drone-ssh /bin/ 12 | ENTRYPOINT ["/bin/drone-ssh"] 13 | -------------------------------------------------------------------------------- /tests/.ssh/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDh7YP+o83TynNNpz5rxmaU/XOIk5eTjkLKcw+29rSu0r9EHbpVt8AXSEgmOLuW2+dieoJT2gV+8QzfdxOftP3r6h3yJv9XPblhTxluy2q0iyQ+7AJu/crSYAeCy+InJEPvIz5ApNsFASBsa5bqC1swqGJh+IgHgPKPsB1L9+Te/brAODPtIcjk4Gq71u/UqHFBh0USdTc8C0Cp5xyDM2lsfd5gvIbO5TEQgmWGln+5TYb2mmP9xKs41U+IjwCGLhGuVmOY/mXnv+yrUKUa6XIukVwzDryQ/kWKTKoekckdEE2BTnvXLQ+HfdKMFuzSFoIgByat5YSEZ7785ecl7pVR drone-scp@localhost 2 | -------------------------------------------------------------------------------- /docker/Dockerfile.linux.amd64: -------------------------------------------------------------------------------- 1 | FROM plugins/base:linux-amd64 2 | 3 | LABEL maintainer="Bo-Yi Wu " \ 4 | org.label-schema.name="Drone SSH" \ 5 | org.label-schema.vendor="Bo-Yi Wu" \ 6 | org.label-schema.schema-version="1.0" 7 | 8 | RUN apk add --no-cache ca-certificates && \ 9 | rm -rf /var/cache/apk/* 10 | 11 | ADD release/linux/amd64/drone-ssh /bin/ 12 | ENTRYPOINT ["/bin/drone-ssh"] 13 | -------------------------------------------------------------------------------- /docker/Dockerfile.linux.arm64: -------------------------------------------------------------------------------- 1 | FROM plugins/base:linux-arm64 2 | 3 | LABEL maintainer="Bo-Yi Wu " \ 4 | org.label-schema.name="Drone SSH" \ 5 | org.label-schema.vendor="Bo-Yi Wu" \ 6 | org.label-schema.schema-version="1.0" 7 | 8 | RUN apk add --no-cache ca-certificates && \ 9 | rm -rf /var/cache/apk/* 10 | 11 | ADD release/linux/arm64/drone-ssh /bin/ 12 | ENTRYPOINT ["/bin/drone-ssh"] 13 | -------------------------------------------------------------------------------- /.drone.jsonnet: -------------------------------------------------------------------------------- 1 | local pipeline = import 'pipeline.libsonnet'; 2 | local name = 'drone-ssh'; 3 | 4 | [ 5 | pipeline.test, 6 | pipeline.build(name, 'linux', 'amd64'), 7 | pipeline.build(name, 'linux', 'arm64'), 8 | pipeline.build(name, 'linux', 'arm'), 9 | pipeline.release, 10 | pipeline.notifications(depends_on=[ 11 | 'linux-amd64', 12 | 'linux-arm64', 13 | 'linux-arm', 14 | 'release-binary', 15 | ]), 16 | ] 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | .env 26 | 27 | coverage.txt 28 | release 29 | drone-ssh 30 | .cover 31 | -------------------------------------------------------------------------------- /.revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = false 2 | severity = "warning" 3 | confidence = 0.8 4 | errorCode = 1 5 | warningCode = 1 6 | 7 | [rule.blank-imports] 8 | [rule.context-as-argument] 9 | [rule.context-keys-type] 10 | [rule.dot-imports] 11 | [rule.error-return] 12 | [rule.error-strings] 13 | [rule.error-naming] 14 | [rule.exported] 15 | [rule.if-return] 16 | [rule.increment-decrement] 17 | [rule.var-naming] 18 | [rule.var-declaration] 19 | [rule.package-comments] 20 | [rule.range] 21 | [rule.receiver-naming] 22 | [rule.time-naming] 23 | [rule.unexported-return] 24 | [rule.indent-error-flow] 25 | [rule.errorf] 26 | -------------------------------------------------------------------------------- /docker/manifest.tmpl: -------------------------------------------------------------------------------- 1 | image: appleboy/drone-ssh:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} 2 | {{#if build.tags}} 3 | tags: 4 | {{#each build.tags}} 5 | - {{this}} 6 | {{/each}} 7 | {{/if}} 8 | manifests: 9 | - 10 | image: appleboy/drone-ssh:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 11 | platform: 12 | architecture: amd64 13 | os: linux 14 | - 15 | image: appleboy/drone-ssh:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 16 | platform: 17 | architecture: arm64 18 | os: linux 19 | variant: v8 20 | - 21 | image: appleboy/drone-ssh:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm 22 | platform: 23 | architecture: arm 24 | os: linux 25 | variant: v7 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # unifying the coding style for different editors and IDEs => editorconfig.org 2 | 3 | ; indicate this is the root of the project 4 | root = true 5 | 6 | ########################################################### 7 | ; common 8 | ########################################################### 9 | 10 | [*] 11 | charset = utf-8 12 | 13 | end_of_line = LF 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | 17 | indent_style = space 18 | indent_size = 2 19 | 20 | ########################################################### 21 | ; make 22 | ########################################################### 23 | 24 | [Makefile] 25 | indent_style = tab 26 | 27 | [makefile] 28 | indent_style = tab 29 | 30 | ########################################################### 31 | ; markdown 32 | ########################################################### 33 | 34 | [*.md] 35 | trim_trailing_whitespace = false 36 | 37 | ########################################################### 38 | ; golang 39 | ########################################################### 40 | 41 | [*.go] 42 | indent_style = tab -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Bo-Yi Wu 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 | -------------------------------------------------------------------------------- /tests/.ssh/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA4e2D/qPN08pzTac+a8ZmlP1ziJOXk45CynMPtva0rtK/RB26 3 | VbfAF0hIJji7ltvnYnqCU9oFfvEM33cTn7T96+od8ib/Vz25YU8ZbstqtIskPuwC 4 | bv3K0mAHgsviJyRD7yM+QKTbBQEgbGuW6gtbMKhiYfiIB4Dyj7AdS/fk3v26wDgz 5 | 7SHI5OBqu9bv1KhxQYdFEnU3PAtAqeccgzNpbH3eYLyGzuUxEIJlhpZ/uU2G9ppj 6 | /cSrONVPiI8Ahi4RrlZjmP5l57/sq1ClGulyLpFcMw68kP5FikyqHpHJHRBNgU57 7 | 1y0Ph33SjBbs0haCIAcmreWEhGe+/OXnJe6VUQIDAQABAoIBAH97emORIm9DaVSD 8 | 7mD6DqA7c5m5Tmpgd6eszU08YC/Vkz9oVuBPUwDQNIX8tT0m0KVs42VVPIyoj874 9 | bgZMJoucC1G8V5Bur9AMxhkShx9g9A7dNXJTmsKilRpk2TOk7wBdLp9jZoKoZBdJ 10 | jlp6FfaazQjjKD6zsCsMATwAoRCBpBNsmT6QDN0n0bIgY0tE6YGQaDdka0dAv68G 11 | R0VZrcJ9voT6+f+rgJLoojn2DAu6iXaM99Gv8FK91YCymbQlXXgrk6CyS0IHexN7 12 | V7a3k767KnRbrkqd3o6JyNun/CrUjQwHs1IQH34tvkWScbseRaFehcAm6mLT93RP 13 | muauvMECgYEA9AXGtfDMse0FhvDPZx4mx8x+vcfsLvDHcDLkf/lbyPpu97C27b/z 14 | ia07bu5TAXesUZrWZtKA5KeRE5doQSdTOv1N28BEr8ZwzDJwfn0DPUYUOxsN2iIy 15 | MheO5A45Ko7bjKJVkZ61Mb1UxtqCTF9mqu9R3PBdJGthWOd+HUvF460CgYEA7QRf 16 | Z8+vpGA+eSuu29e0xgRKnRzed5zXYpcI4aERc3JzBgO4Z0er9G8l66OWVGdMfpe6 17 | CBajC5ToIiT8zqoYxXwqJgN+glir4gJe3mm8J703QfArZiQrdk0NTi5bY7+vLLG/ 18 | knTrtpdsKih6r3kjhuPPaAsIwmMxIydFvATKjLUCgYEAh/y4EihRSk5WKC8GxeZt 19 | oiZ58vT4z+fqnMIfyJmD5up48JuQNcokw/LADj/ODiFM7GUnWkGxBrvDA3H67WQm 20 | 49bJjs8E+BfUQFdTjYnJRlpJZ+7Zt1gbNQMf5ENw5CCchTDqEq6pN0DVf8PBnSIF 21 | KvkXW9KvdV5J76uCAn15mDkCgYA1y8dHzbjlCz9Cy2pt1aDfTPwOew33gi7U3skS 22 | RTerx29aDyAcuQTLfyrROBkX4TZYiWGdEl5Bc7PYhCKpWawzrsH2TNa7CRtCOh2E 23 | R+V/84+GNNf04ALJYCXD9/ugQVKmR1XfDRCvKeFQFE38Y/dvV2etCswbKt5tRy2p 24 | xkCe/QKBgQCkLqafD4S20YHf6WTp3jp/4H/qEy2X2a8gdVVBi1uKkGDXr0n+AoVU 25 | ib4KbP5ovZlrjL++akMQ7V2fHzuQIFWnCkDA5c2ZAqzlM+ZN+HRG7gWur7Bt4XH1 26 | 7XC9wlRna4b3Ln8ew3q1ZcBjXwD4ppbTlmwAfQIaZTGJUgQbdsO9YA== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/appleboy/easyssh-proxy v1.2.0 h1:KvaUGC18WkBFet+N1oofQy03jkC5HaKFn2XGxFxCTtg= 3 | github.com/appleboy/easyssh-proxy v1.2.0/go.mod h1:vHskChUNhxwW4dXMe2MNE/k+UBCkBagrQDm70UWZrS0= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 6 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 9 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 13 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 14 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 15 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 18 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 19 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 20 | github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= 21 | github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 22 | golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25 h1:jsG6UpNLt9iAsb0S2AGW28DveNzzgmbXR+ENoPjUeIU= 23 | golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 24 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 25 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 29 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # drone-ssh 2 | 3 | ![sshlog](images/ssh.png) 4 | 5 | [![GitHub tag](https://img.shields.io/github/tag/appleboy/drone-ssh.svg)](https://github.com/appleboy/drone-ssh/releases) 6 | [![GoDoc](https://godoc.org/github.com/appleboy/drone-ssh?status.svg)](https://godoc.org/github.com/appleboy/drone-ssh) 7 | [![Build Status](https://cloud.drone.io/api/badges/appleboy/drone-ssh/status.svg)](https://cloud.drone.io/appleboy/drone-ssh) 8 | [![codecov](https://codecov.io/gh/appleboy/drone-ssh/branch/master/graph/badge.svg)](https://codecov.io/gh/appleboy/drone-ssh) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/appleboy/drone-ssh)](https://goreportcard.com/report/github.com/appleboy/drone-ssh) 10 | [![Docker Pulls](https://img.shields.io/docker/pulls/appleboy/drone-ssh.svg)](https://hub.docker.com/r/appleboy/drone-ssh/) 11 | [![micro badger](https://images.microbadger.com/badges/image/appleboy/drone-ssh.svg)](https://microbadger.com/images/appleboy/drone-ssh "Get your own image badge on microbadger.com") 12 | 13 | Drone plugin to execute commands on a remote host through SSH. For the usage 14 | information and a listing of the available options please take a look at [the docs](http://plugins.drone.io/appleboy/drone-ssh/). 15 | 16 | **Note: Please update your image config path to `appleboy/drone-ssh` for drone. `plugins/ssh` is no longer maintained.** 17 | 18 | ![demo](./images/demo2017.05.10.gif) 19 | 20 | ## Breaking changes 21 | 22 | `v1.5.0`: change command timeout flag to `Duration`. See the following setting: 23 | 24 | ```diff 25 | pipeline: 26 | scp: 27 | image: appleboy/drone-scp 28 | settings: 29 | host: 30 | - example1.com 31 | - example2.com 32 | username: ubuntu 33 | password: 34 | from_secret: ssh_password 35 | port: 22 36 | - command_timeout: 120 37 | + command_timeout: 2m 38 | script: 39 | - echo "Hello World" 40 | ``` 41 | 42 | ## Build or Download a binary 43 | 44 | The pre-compiled binaries can be downloaded from [release page](https://github.com/appleboy/drone-ssh/releases). Support the following OS type. 45 | 46 | * Windows amd64/386 47 | * Linux arm/amd64/386 48 | * Darwin amd64/386 49 | 50 | With `Go` installed 51 | 52 | ```sh 53 | go get -u -v github.com/appleboy/drone-ssh 54 | ``` 55 | 56 | or build the binary with the following command: 57 | 58 | ```sh 59 | export GOOS=linux 60 | export GOARCH=amd64 61 | export CGO_ENABLED=0 62 | export GO111MODULE=on 63 | 64 | go test -cover ./... 65 | 66 | go build -v -a -tags netgo -o release/linux/amd64/drone-ssh . 67 | ``` 68 | 69 | ## Docker 70 | 71 | Build the docker image with the following commands: 72 | 73 | ```sh 74 | make docker 75 | ``` 76 | 77 | ## Usage 78 | 79 | Execute from the working directory: 80 | 81 | ```sh 82 | docker run --rm \ 83 | -e PLUGIN_HOST=foo.com \ 84 | -e PLUGIN_USERNAME=root \ 85 | -e PLUGIN_KEY="$(cat ${HOME}/.ssh/id_rsa)" \ 86 | -e PLUGIN_SCRIPT=whoami \ 87 | -v $(pwd):$(pwd) \ 88 | -w $(pwd) \ 89 | appleboy/drone-ssh 90 | ``` 91 | 92 | ## Mount key from file path 93 | 94 | Please make sure that enable the `trusted` mode in project setting for [drone 0.8 version](https://0-8-0.docs.drone.io/). 95 | 96 | ![trusted mode](./images/trust.png) 97 | 98 | Mount private key in `volumes` setting of `.drone.yml` config 99 | 100 | ```diff 101 | pipeline: 102 | ssh: 103 | image: appleboy/drone-ssh 104 | host: xxxxx.com 105 | username: deploy 106 | + volumes: 107 | + - /root/drone_rsa:/root/ssh/drone_rsa 108 | key_path: /root/ssh/drone_rsa 109 | script: 110 | - echo "test ssh" 111 | ``` 112 | 113 | See the detail of [issue comment](https://github.com/appleboy/drone-ssh/issues/51#issuecomment-336732928). 114 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DIST := dist 2 | EXECUTABLE := drone-ssh 3 | GOFMT ?= gofmt "-s" 4 | GO ?= go 5 | 6 | # for dockerhub 7 | DEPLOY_ACCOUNT := appleboy 8 | DEPLOY_IMAGE := $(EXECUTABLE) 9 | 10 | TARGETS ?= linux darwin windows 11 | ARCHS ?= amd64 386 12 | PACKAGES ?= $(shell $(GO) list ./...) 13 | SOURCES ?= $(shell find . -name "*.go" -type f) 14 | TAGS ?= 15 | LDFLAGS ?= -X 'main.Version=$(VERSION)' 16 | 17 | ifneq ($(shell uname), Darwin) 18 | EXTLDFLAGS = -extldflags "-static" $(null) 19 | else 20 | EXTLDFLAGS = 21 | endif 22 | 23 | ifneq ($(DRONE_TAG),) 24 | VERSION ?= $(DRONE_TAG) 25 | else 26 | VERSION ?= $(shell git describe --tags --always || git rev-parse --short HEAD) 27 | endif 28 | 29 | all: build 30 | 31 | fmt: 32 | $(GOFMT) -w $(SOURCES) 33 | 34 | vet: 35 | $(GO) vet $(PACKAGES) 36 | 37 | lint: 38 | @hash revive > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ 39 | $(GO) get -u github.com/mgechev/revive; \ 40 | fi 41 | revive -config .revive.toml ./... || exit 1 42 | 43 | .PHONY: misspell-check 44 | misspell-check: 45 | @hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ 46 | $(GO) get -u github.com/client9/misspell/cmd/misspell; \ 47 | fi 48 | misspell -error $(SOURCES) 49 | 50 | .PHONY: misspell 51 | misspell: 52 | @hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ 53 | $(GO) get -u github.com/client9/misspell/cmd/misspell; \ 54 | fi 55 | misspell -w $(SOURCES) 56 | 57 | .PHONY: fmt-check 58 | fmt-check: 59 | @diff=$$($(GOFMT) -d $(SOURCES)); \ 60 | if [ -n "$$diff" ]; then \ 61 | echo "Please run 'make fmt' and commit the result:"; \ 62 | echo "$${diff}"; \ 63 | exit 1; \ 64 | fi; 65 | 66 | test: fmt-check 67 | @$(GO) test -v -cover -coverprofile coverage.txt $(PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 68 | 69 | install: $(SOURCES) 70 | $(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' 71 | 72 | build: $(EXECUTABLE) 73 | 74 | $(EXECUTABLE): $(SOURCES) 75 | $(GO) build -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o $@ 76 | 77 | release: release-dirs release-build release-copy release-check 78 | 79 | release-dirs: 80 | mkdir -p $(DIST)/binaries $(DIST)/release 81 | 82 | release-build: 83 | @which gox > /dev/null; if [ $$? -ne 0 ]; then \ 84 | $(GO) get -u github.com/mitchellh/gox; \ 85 | fi 86 | gox -os="$(TARGETS)" -arch="$(ARCHS)" -tags="$(TAGS)" -ldflags="-s -w $(LDFLAGS)" -output="$(DIST)/binaries/$(EXECUTABLE)-$(VERSION)-{{.OS}}-{{.Arch}}" 87 | 88 | release-copy: 89 | $(foreach file,$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*),cp $(file) $(DIST)/release/$(notdir $(file));) 90 | 91 | release-check: 92 | cd $(DIST)/release; $(foreach file,$(wildcard $(DIST)/release/$(EXECUTABLE)-*),sha256sum $(notdir $(file)) > $(notdir $(file)).sha256;) 93 | 94 | build_linux_amd64: 95 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/amd64/$(DEPLOY_IMAGE) 96 | 97 | build_linux_i386: 98 | CGO_ENABLED=0 GOOS=linux GOARCH=386 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/i386/$(DEPLOY_IMAGE) 99 | 100 | build_linux_arm64: 101 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/arm64/$(DEPLOY_IMAGE) 102 | 103 | build_linux_arm: 104 | CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/arm/$(DEPLOY_IMAGE) 105 | 106 | docker_image: 107 | docker build -t $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE) . 108 | 109 | docker: docker_image 110 | 111 | docker_deploy: 112 | ifeq ($(tag),) 113 | @echo "Usage: make $@ tag=" 114 | @exit 1 115 | endif 116 | # deploy image 117 | docker tag $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE):latest $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE):$(tag) 118 | docker push $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE):$(tag) 119 | 120 | ssh-server: 121 | adduser -h /home/drone-scp -s /bin/bash -D -S drone-scp 122 | echo drone-scp:1234 | chpasswd 123 | mkdir -p /home/drone-scp/.ssh 124 | chmod 700 /home/drone-scp/.ssh 125 | cp tests/.ssh/id_rsa.pub /home/drone-scp/.ssh/authorized_keys 126 | chown -R drone-scp /home/drone-scp/.ssh 127 | # install ssh and start server 128 | apk add --update openssh openrc 129 | rm -rf /etc/ssh/ssh_host_rsa_key /etc/ssh/ssh_host_dsa_key 130 | sed -i 's/AllowTcpForwarding no/AllowTcpForwarding yes/g' /etc/ssh/sshd_config 131 | ./tests/entrypoint.sh /usr/sbin/sshd -D & 132 | 133 | coverage: 134 | sed -i '/main.go/d' coverage.txt 135 | 136 | clean: 137 | $(GO) clean -x -i ./... 138 | rm -rf coverage.txt $(EXECUTABLE) $(DIST) 139 | 140 | version: 141 | @echo $(VERSION) 142 | -------------------------------------------------------------------------------- /DOCS.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2019-08-04T00:00:00+00:00 3 | title: SSH 4 | author: appleboy 5 | tags: [ deploy, publish, ssh ] 6 | repo: appleboy/drone-ssh 7 | logo: term.svg 8 | image: appleboy/drone-ssh 9 | --- 10 | 11 | Use the SSH plugin to execute commands on a remote server. The below pipeline configuration demonstrates simple usage: 12 | 13 | ```yaml 14 | - name: ssh commands 15 | image: appleboy/drone-ssh 16 | settings: 17 | host: foo.com 18 | username: root 19 | password: 1234 20 | port: 22 21 | script: 22 | - echo hello 23 | - echo world 24 | ``` 25 | 26 | Example configuration in your `.drone.yml` file for multiple hosts: 27 | 28 | ```diff 29 | - name: ssh commands 30 | image: appleboy/drone-ssh 31 | settings: 32 | host: 33 | + - foo.com 34 | + - bar.com 35 | username: root 36 | password: 1234 37 | port: 22 38 | script: 39 | - echo hello 40 | - echo world 41 | ``` 42 | 43 | Example configuration for command timeout, default value is 60 seconds: 44 | 45 | ```diff 46 | - name: ssh commands 47 | image: appleboy/drone-ssh 48 | settings: 49 | host: foo.com 50 | username: root 51 | password: 1234 52 | port: 22 53 | + command_timeout: 2m 54 | script: 55 | - echo hello 56 | - echo world 57 | ``` 58 | 59 | Example configuration for execute commands on a remote server using `SSHProxyCommand`: 60 | 61 | ```diff 62 | - name: ssh commands 63 | image: appleboy/drone-ssh 64 | settings: 65 | host: foo.com 66 | username: root 67 | password: 1234 68 | port: 22 69 | script: 70 | - echo hello 71 | - echo world 72 | + proxy_host: 10.130.33.145 73 | + proxy_user: ubuntu 74 | + proxy_port: 22 75 | + proxy_password: 1234 76 | ``` 77 | 78 | Example configuration using password from secrets: 79 | 80 | ```diff 81 | - name: ssh commands 82 | image: appleboy/drone-ssh 83 | settings: 84 | host: foo.com 85 | username: root 86 | + password: 87 | + from_secret: ssh_password 88 | port: 22 89 | script: 90 | - echo hello 91 | - echo world 92 | ``` 93 | 94 | Example configuration using ssh key from secrets: 95 | 96 | ```diff 97 | - name: ssh commands 98 | image: appleboy/drone-ssh 99 | settings: 100 | host: foo.com 101 | username: root 102 | port: 22 103 | + key: 104 | + from_secret: ssh_key 105 | script: 106 | - echo hello 107 | - echo world 108 | ``` 109 | 110 | Example configuration for exporting custom secrets: 111 | 112 | ```diff 113 | - name: ssh commands 114 | image: appleboy/drone-ssh 115 | settings: 116 | host: foo.com 117 | username: root 118 | password: 1234 119 | port: 22 120 | + envs: 121 | - aws_access_key_id 122 | script: 123 | - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID 124 | ``` 125 | 126 | Example configuration for stoping script after first failure: 127 | 128 | ```diff 129 | - name: ssh commands 130 | image: appleboy/drone-ssh 131 | settings: 132 | host: foo.com 133 | username: root 134 | password: 1234 135 | port: 22 136 | + script_stop: true 137 | script: 138 | - mkdir abc/def/efg 139 | - echo "you can't see the steps." 140 | ``` 141 | 142 | ## Secret Reference 143 | 144 | ssh_username 145 | : account for target host user 146 | 147 | ssh_password 148 | : password for target host user 149 | 150 | ssh_key 151 | : plain text of user private key 152 | 153 | proxy_ssh_username 154 | : account for user of proxy server 155 | 156 | proxy_ssh_password 157 | : password for user of proxy server 158 | 159 | proxy_ssh_key 160 | : plain text of user private key for proxy server 161 | 162 | ## Parameter Reference 163 | 164 | host 165 | : target hostname or IP 166 | 167 | port 168 | : ssh port of target host 169 | 170 | username 171 | : account for target host user 172 | 173 | password 174 | : password for target host user 175 | 176 | key 177 | : plain text of user private key 178 | 179 | key_path 180 | : key path of user private key 181 | 182 | envs 183 | : custom secrets which are made available in the script section 184 | 185 | script 186 | : execute commands on a remote server 187 | 188 | script_stop 189 | : stop script after first failure 190 | 191 | timeout 192 | : Timeout is the maximum amount of time for the ssh connection to establish, default is 30 seconds. 193 | 194 | command_timeout 195 | : Command timeout is the maximum amount of time for the execute commands, default is 10 minutes. 196 | 197 | proxy_host 198 | : proxy hostname or IP 199 | 200 | proxy_port 201 | : ssh port of proxy host 202 | 203 | proxy_username 204 | : account for proxy host user 205 | 206 | proxy_password 207 | : password for proxy host user 208 | 209 | proxy_key 210 | : plain text of proxy private key 211 | 212 | proxy_key_path 213 | : key path of proxy private key 214 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/appleboy/easyssh-proxy" 14 | ) 15 | 16 | var ( 17 | errMissingHost = errors.New("Error: missing server host") 18 | errMissingPasswordOrKey = errors.New("Error: can't connect without a private SSH key or password") 19 | errCommandTimeOut = errors.New("Error: command timeout") 20 | errSetPasswordandKey = errors.New("can't set password and key at the same time") 21 | ) 22 | 23 | type ( 24 | // Config for the plugin. 25 | Config struct { 26 | Key string 27 | KeyPath string 28 | Username string 29 | Password string 30 | Host []string 31 | Port int 32 | Timeout time.Duration 33 | CommandTimeout time.Duration 34 | Script []string 35 | ScriptStop bool 36 | Envs []string 37 | Proxy easyssh.DefaultConfig 38 | Debug bool 39 | Sync bool 40 | } 41 | 42 | // Plugin structure 43 | Plugin struct { 44 | Config Config 45 | Writer io.Writer 46 | } 47 | ) 48 | 49 | func escapeArg(arg string) string { 50 | return "'" + strings.Replace(arg, "'", `'\''`, -1) + "'" 51 | } 52 | 53 | func (p Plugin) exec(host string, wg *sync.WaitGroup, errChannel chan error) { 54 | // Create MakeConfig instance with remote username, server address and path to private key. 55 | ssh := &easyssh.MakeConfig{ 56 | Server: host, 57 | User: p.Config.Username, 58 | Password: p.Config.Password, 59 | Port: strconv.Itoa(p.Config.Port), 60 | Key: p.Config.Key, 61 | KeyPath: p.Config.KeyPath, 62 | Timeout: p.Config.Timeout, 63 | Proxy: easyssh.DefaultConfig{ 64 | Server: p.Config.Proxy.Server, 65 | User: p.Config.Proxy.User, 66 | Password: p.Config.Proxy.Password, 67 | Port: p.Config.Proxy.Port, 68 | Key: p.Config.Proxy.Key, 69 | KeyPath: p.Config.Proxy.KeyPath, 70 | Timeout: p.Config.Proxy.Timeout, 71 | }, 72 | } 73 | 74 | p.log(host, "======CMD======") 75 | p.log(host, strings.Join(p.Config.Script, "\n")) 76 | p.log(host, "======END======") 77 | 78 | env := []string{} 79 | for _, key := range p.Config.Envs { 80 | key = strings.ToUpper(key) 81 | if val, found := os.LookupEnv(key); found { 82 | env = append(env, key+"="+escapeArg(val)) 83 | } 84 | } 85 | 86 | p.Config.Script = append(env, p.scriptCommands()...) 87 | 88 | if p.Config.Debug { 89 | p.log(host, "======ENV======") 90 | p.log(host, strings.Join(env, "\n")) 91 | p.log(host, "======END======") 92 | } 93 | 94 | stdoutChan, stderrChan, doneChan, errChan, err := ssh.Stream(strings.Join(p.Config.Script, "\n"), p.Config.CommandTimeout) 95 | if err != nil { 96 | errChannel <- err 97 | } else { 98 | // read from the output channel until the done signal is passed 99 | isTimeout := true 100 | loop: 101 | for { 102 | select { 103 | case isTimeout = <-doneChan: 104 | break loop 105 | case outline := <-stdoutChan: 106 | p.log(host, "out:", outline) 107 | case errline := <-stderrChan: 108 | p.log(host, "err:", errline) 109 | case err = <-errChan: 110 | } 111 | } 112 | 113 | // get exit code or command error. 114 | if err != nil { 115 | errChannel <- err 116 | } 117 | 118 | // command time out 119 | if !isTimeout { 120 | errChannel <- errCommandTimeOut 121 | } 122 | } 123 | 124 | wg.Done() 125 | } 126 | 127 | func (p Plugin) log(host string, message ...interface{}) { 128 | if p.Writer == nil { 129 | p.Writer = os.Stdout 130 | } 131 | if count := len(p.Config.Host); count == 1 { 132 | fmt.Fprintf(p.Writer, "%s", fmt.Sprintln(message...)) 133 | } else { 134 | fmt.Fprintf(p.Writer, "%s: %s", host, fmt.Sprintln(message...)) 135 | } 136 | } 137 | 138 | // Exec executes the plugin. 139 | func (p Plugin) Exec() error { 140 | if len(p.Config.Host) == 0 { 141 | return errMissingHost 142 | } 143 | 144 | if len(p.Config.Key) == 0 && len(p.Config.Password) == 0 && len(p.Config.KeyPath) == 0 { 145 | return errMissingPasswordOrKey 146 | } 147 | 148 | if len(p.Config.Key) != 0 && len(p.Config.Password) != 0 { 149 | return errSetPasswordandKey 150 | } 151 | 152 | wg := sync.WaitGroup{} 153 | wg.Add(len(p.Config.Host)) 154 | errChannel := make(chan error) 155 | finished := make(chan struct{}) 156 | for _, host := range p.Config.Host { 157 | if p.Config.Sync { 158 | p.exec(host, &wg, errChannel) 159 | } else { 160 | go p.exec(host, &wg, errChannel) 161 | } 162 | } 163 | 164 | go func() { 165 | wg.Wait() 166 | close(finished) 167 | }() 168 | 169 | select { 170 | case <-finished: 171 | case err := <-errChannel: 172 | if err != nil { 173 | return err 174 | } 175 | } 176 | 177 | fmt.Println("==============================================") 178 | fmt.Println("✅ Successfully executed commands to all host.") 179 | fmt.Println("==============================================") 180 | 181 | return nil 182 | } 183 | 184 | func (p Plugin) scriptCommands() []string { 185 | scripts := []string{} 186 | 187 | for _, cmd := range p.Config.Script { 188 | if p.Config.ScriptStop { 189 | scripts = append(scripts, strings.Split(cmd, "\n")...) 190 | } else { 191 | scripts = append(scripts, cmd) 192 | } 193 | } 194 | 195 | commands := make([]string, 0) 196 | 197 | for _, cmd := range scripts { 198 | if strings.TrimSpace(cmd) == "" { 199 | continue 200 | } 201 | commands = append(commands, cmd) 202 | if p.Config.ScriptStop { 203 | commands = append(commands, "DRONE_SSH_PREV_COMMAND_EXIT_CODE=$? ; if [ $DRONE_SSH_PREV_COMMAND_EXIT_CODE -ne 0 ]; then exit $DRONE_SSH_PREV_COMMAND_EXIT_CODE; fi;") 204 | } 205 | } 206 | 207 | return commands 208 | } 209 | -------------------------------------------------------------------------------- /pipeline.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | test:: { 3 | kind: 'pipeline', 4 | name: 'testing', 5 | platform: { 6 | os: 'linux', 7 | arch: 'amd64', 8 | }, 9 | steps: [ 10 | { 11 | name: 'vet', 12 | image: 'golang:1.13', 13 | pull: 'always', 14 | commands: [ 15 | 'make vet', 16 | ], 17 | volumes: [ 18 | { 19 | name: 'gopath', 20 | path: '/go', 21 | }, 22 | ], 23 | }, 24 | { 25 | name: 'lint', 26 | image: 'golang:1.13', 27 | pull: 'always', 28 | commands: [ 29 | 'make lint', 30 | ], 31 | volumes: [ 32 | { 33 | name: 'gopath', 34 | path: '/go', 35 | }, 36 | ], 37 | }, 38 | { 39 | name: 'misspell', 40 | image: 'golang:1.13', 41 | pull: 'always', 42 | commands: [ 43 | 'make misspell-check', 44 | ], 45 | volumes: [ 46 | { 47 | name: 'gopath', 48 | path: '/go', 49 | }, 50 | ], 51 | }, 52 | { 53 | name: 'test', 54 | image: 'golang:1.13-alpine', 55 | pull: 'always', 56 | commands: [ 57 | 'apk add git make curl perl bash build-base zlib-dev ucl-dev', 58 | 'make ssh-server', 59 | 'make test', 60 | 'make coverage', 61 | ], 62 | volumes: [ 63 | { 64 | name: 'gopath', 65 | path: '/go', 66 | }, 67 | ], 68 | }, 69 | { 70 | name: 'codecov', 71 | image: 'robertstettner/drone-codecov', 72 | pull: 'always', 73 | settings: { 74 | token: { 'from_secret': 'codecov_token' }, 75 | }, 76 | }, 77 | ], 78 | volumes: [ 79 | { 80 | name: 'gopath', 81 | temp: {}, 82 | }, 83 | ], 84 | }, 85 | 86 | build(name, os='linux', arch='amd64'):: { 87 | kind: 'pipeline', 88 | name: os + '-' + arch, 89 | platform: { 90 | os: os, 91 | arch: arch, 92 | }, 93 | steps: [ 94 | { 95 | name: 'build-push', 96 | image: 'golang:1.13', 97 | pull: 'always', 98 | environment: { 99 | CGO_ENABLED: '0', 100 | }, 101 | commands: [ 102 | 'go build -v -ldflags \'-X main.build=${DRONE_BUILD_NUMBER}\' -a -o release/' + os + '/' + arch + '/' + name, 103 | ], 104 | when: { 105 | event: { 106 | exclude: [ 'tag' ], 107 | }, 108 | }, 109 | }, 110 | { 111 | name: 'build-tag', 112 | image: 'golang:1.13', 113 | pull: 'always', 114 | environment: { 115 | CGO_ENABLED: '0', 116 | }, 117 | commands: [ 118 | 'go build -v -ldflags \'-X main.version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}\' -a -o release/' + os + '/' + arch + '/' + name, 119 | ], 120 | when: { 121 | event: [ 'tag' ], 122 | }, 123 | }, 124 | { 125 | name: 'executable', 126 | image: 'golang:1.13', 127 | pull: 'always', 128 | commands: [ 129 | './release/' + os + '/' + arch + '/' + name + ' --help', 130 | ], 131 | }, 132 | { 133 | name: 'dryrun', 134 | image: 'plugins/docker:' + os + '-' + arch, 135 | pull: 'always', 136 | settings: { 137 | daemon_off: false, 138 | dry_run: true, 139 | tags: os + '-' + arch, 140 | dockerfile: 'docker/Dockerfile.' + os + '.' + arch, 141 | repo: 'appleboy/' + name, 142 | cache_from: 'appleboy/' + name, 143 | }, 144 | when: { 145 | event: [ 'pull_request' ], 146 | }, 147 | }, 148 | { 149 | name: 'publish', 150 | image: 'plugins/docker:' + os + '-' + arch, 151 | pull: 'always', 152 | settings: { 153 | daemon_off: 'false', 154 | auto_tag: true, 155 | auto_tag_suffix: os + '-' + arch, 156 | dockerfile: 'docker/Dockerfile.' + os + '.' + arch, 157 | repo: 'appleboy/' + name, 158 | cache_from: 'appleboy/' + name, 159 | username: { 'from_secret': 'docker_username' }, 160 | password: { 'from_secret': 'docker_password' }, 161 | }, 162 | when: { 163 | event: { 164 | exclude: [ 'pull_request' ], 165 | }, 166 | }, 167 | }, 168 | ], 169 | depends_on: [ 170 | 'testing', 171 | ], 172 | trigger: { 173 | ref: [ 174 | 'refs/heads/master', 175 | 'refs/pull/**', 176 | 'refs/tags/**', 177 | ], 178 | }, 179 | }, 180 | 181 | release:: { 182 | kind: 'pipeline', 183 | name: 'release-binary', 184 | platform: { 185 | os: 'linux', 186 | arch: 'amd64', 187 | }, 188 | steps: [ 189 | { 190 | name: 'build-all-binary', 191 | image: 'golang:1.13', 192 | pull: 'always', 193 | commands: [ 194 | 'make release' 195 | ], 196 | when: { 197 | event: [ 'tag' ], 198 | }, 199 | }, 200 | { 201 | name: 'deploy-all-binary', 202 | image: 'plugins/github-release', 203 | pull: 'always', 204 | settings: { 205 | files: [ 'dist/release/*' ], 206 | api_key: { 'from_secret': 'github_release_api_key' }, 207 | }, 208 | when: { 209 | event: [ 'tag' ], 210 | }, 211 | }, 212 | ], 213 | depends_on: [ 214 | 'testing', 215 | ], 216 | trigger: { 217 | ref: [ 218 | 'refs/tags/**', 219 | ], 220 | }, 221 | }, 222 | 223 | notifications(os='linux', arch='amd64', depends_on=[]):: { 224 | kind: 'pipeline', 225 | name: 'notifications', 226 | platform: { 227 | os: os, 228 | arch: arch, 229 | }, 230 | steps: [ 231 | { 232 | name: 'manifest', 233 | image: 'plugins/manifest', 234 | pull: 'always', 235 | settings: { 236 | username: { from_secret: 'docker_username' }, 237 | password: { from_secret: 'docker_password' }, 238 | spec: 'docker/manifest.tmpl', 239 | ignore_missing: true, 240 | }, 241 | }, 242 | ], 243 | depends_on: depends_on, 244 | trigger: { 245 | ref: [ 246 | 'refs/heads/master', 247 | 'refs/tags/**', 248 | ], 249 | }, 250 | }, 251 | 252 | signature(key):: { 253 | kind: 'signature', 254 | hmac: key, 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/appleboy/easyssh-proxy" 9 | "github.com/joho/godotenv" 10 | _ "github.com/joho/godotenv/autoload" 11 | "github.com/urfave/cli" 12 | ) 13 | 14 | // Version set at compile-time 15 | var Version string 16 | 17 | func main() { 18 | // Load env-file if it exists first 19 | if filename, found := os.LookupEnv("PLUGIN_ENV_FILE"); found { 20 | _ = godotenv.Load(filename) 21 | } 22 | 23 | app := cli.NewApp() 24 | app.Name = "Drone SSH" 25 | app.Usage = "Executing remote ssh commands" 26 | app.Copyright = "Copyright (c) 2019 Bo-Yi Wu" 27 | app.Authors = []cli.Author{ 28 | { 29 | Name: "Bo-Yi Wu", 30 | Email: "appleboy.tw@gmail.com", 31 | }, 32 | } 33 | app.Action = run 34 | app.Version = Version 35 | app.Flags = []cli.Flag{ 36 | cli.StringFlag{ 37 | Name: "ssh-key", 38 | Usage: "private ssh key", 39 | EnvVar: "PLUGIN_SSH_KEY,PLUGIN_KEY,SSH_KEY,KEY,INPUT_KEY", 40 | }, 41 | cli.StringFlag{ 42 | Name: "key-path,i", 43 | Usage: "ssh private key path", 44 | EnvVar: "PLUGIN_KEY_PATH,SSH_KEY_PATH,INPUT_KEY_PATH", 45 | }, 46 | cli.StringFlag{ 47 | Name: "username,user,u", 48 | Usage: "connect as user", 49 | EnvVar: "PLUGIN_USERNAME,PLUGIN_USER,SSH_USERNAME,USERNAME,INPUT_USERNAME", 50 | Value: "root", 51 | }, 52 | cli.StringFlag{ 53 | Name: "password,P", 54 | Usage: "user password", 55 | EnvVar: "PLUGIN_PASSWORD,SSH_PASSWORD,PASSWORD,INPUT_PASSWORD", 56 | }, 57 | cli.StringSliceFlag{ 58 | Name: "host,H", 59 | Usage: "connect to host", 60 | EnvVar: "PLUGIN_HOST,SSH_HOST,HOST,INPUT_HOST", 61 | }, 62 | cli.IntFlag{ 63 | Name: "port,p", 64 | Usage: "connect to port", 65 | EnvVar: "PLUGIN_PORT,SSH_PORT,PORT,INPUT_PORT", 66 | Value: 22, 67 | }, 68 | cli.BoolFlag{ 69 | Name: "sync", 70 | Usage: "sync mode", 71 | EnvVar: "PLUGIN_SYNC,SYNC,INPUT_SYNC", 72 | }, 73 | cli.DurationFlag{ 74 | Name: "timeout,t", 75 | Usage: "connection timeout", 76 | EnvVar: "PLUGIN_TIMEOUT,SSH_TIMEOUT,TIMEOUT,INPUT_TIMEOUT", 77 | Value: 30 * time.Second, 78 | }, 79 | cli.DurationFlag{ 80 | Name: "command.timeout,T", 81 | Usage: "command timeout", 82 | EnvVar: "PLUGIN_COMMAND_TIMEOUT,SSH_COMMAND_TIMEOUT,COMMAND_TIMEOUT,INPUT_COMMAND_TIMEOUT", 83 | Value: 10 * time.Minute, 84 | }, 85 | cli.StringSliceFlag{ 86 | Name: "script,s", 87 | Usage: "execute commands", 88 | EnvVar: "PLUGIN_SCRIPT,SSH_SCRIPT,SCRIPT", 89 | }, 90 | cli.StringFlag{ 91 | Name: "script.string", 92 | Usage: "execute single commands for github action", 93 | EnvVar: "INPUT_SCRIPT", 94 | }, 95 | cli.BoolFlag{ 96 | Name: "script.stop", 97 | Usage: "stop script after first failure", 98 | EnvVar: "PLUGIN_SCRIPT_STOP,STOP,INPUT_SCRIPT_STOP", 99 | }, 100 | cli.StringFlag{ 101 | Name: "proxy.ssh-key", 102 | Usage: "private ssh key of proxy", 103 | EnvVar: "PLUGIN_PROXY_SSH_KEY,PLUGIN_PROXY_KEY,PROXY_SSH_KEY,INPUT_PROXY_KEY", 104 | }, 105 | cli.StringFlag{ 106 | Name: "proxy.key-path", 107 | Usage: "ssh private key path of proxy", 108 | EnvVar: "PLUGIN_PROXY_KEY_PATH,PROXY_SSH_KEY_PATH,INPUT_PROXY_KEY_PATH", 109 | }, 110 | cli.StringFlag{ 111 | Name: "proxy.username", 112 | Usage: "connect as user of proxy", 113 | EnvVar: "PLUGIN_PROXY_USERNAME,PLUGIN_PROXY_USER,PROXY_SSH_USERNAME,INPUT_PROXY_USERNAME", 114 | Value: "root", 115 | }, 116 | cli.StringFlag{ 117 | Name: "proxy.password", 118 | Usage: "user password of proxy", 119 | EnvVar: "PLUGIN_PROXY_PASSWORD,PROXY_SSH_PASSWORD,INPUT_PROXY_PASSWORD", 120 | }, 121 | cli.StringFlag{ 122 | Name: "proxy.host", 123 | Usage: "connect to host of proxy", 124 | EnvVar: "PLUGIN_PROXY_HOST,PROXY_SSH_HOST,INPUT_PROXY_HOST", 125 | }, 126 | cli.StringFlag{ 127 | Name: "proxy.port", 128 | Usage: "connect to port of proxy", 129 | EnvVar: "PLUGIN_PROXY_PORT,PROXY_SSH_PORT,INPUT_PROXY_PORT", 130 | Value: "22", 131 | }, 132 | cli.DurationFlag{ 133 | Name: "proxy.timeout", 134 | Usage: "proxy connection timeout", 135 | EnvVar: "PLUGIN_PROXY_TIMEOUT,PROXY_SSH_TIMEOUT,INPUT_PROXY_TIMEOUT", 136 | }, 137 | cli.StringSliceFlag{ 138 | Name: "envs", 139 | Usage: "pass environment variable to shell script", 140 | EnvVar: "PLUGIN_ENVS,INPUT_ENVS", 141 | }, 142 | cli.BoolFlag{ 143 | Name: "debug", 144 | Usage: "debug mode", 145 | EnvVar: "PLUGIN_DEBUG,DEBUG,INPUT_DEBUG", 146 | }, 147 | } 148 | 149 | // Override a template 150 | cli.AppHelpTemplate = ` 151 | ________ _________ _________ ___ ___ 152 | \______ \_______ ____ ____ ____ / _____// _____// | \ 153 | | | \_ __ \/ _ \ / \_/ __ \ ______ \_____ \ \_____ \/ ~ \ 154 | | | \ | \( <_> ) | \ ___/ /_____/ / \/ \ Y / 155 | /_______ /__| \____/|___| /\___ > /_______ /_______ /\___|_ / 156 | \/ \/ \/ \/ \/ \/ 157 | version: {{.Version}} 158 | NAME: 159 | {{.Name}} - {{.Usage}} 160 | 161 | USAGE: 162 | {{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}} 163 | {{if len .Authors}} 164 | AUTHOR: 165 | {{range .Authors}}{{ . }}{{end}} 166 | {{end}}{{if .Commands}} 167 | COMMANDS: 168 | {{range .Commands}}{{if not .HideHelp}} {{join .Names ", "}}{{ "\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}}{{end}}{{if .VisibleFlags}} 169 | GLOBAL OPTIONS: 170 | {{range .VisibleFlags}}{{.}} 171 | {{end}}{{end}}{{if .Copyright }} 172 | COPYRIGHT: 173 | {{.Copyright}} 174 | {{end}}{{if .Version}} 175 | VERSION: 176 | {{.Version}} 177 | {{end}} 178 | REPOSITORY: 179 | Github: https://github.com/appleboy/drone-ssh 180 | ` 181 | 182 | if err := app.Run(os.Args); err != nil { 183 | log.Fatal(err) 184 | } 185 | } 186 | 187 | func run(c *cli.Context) error { 188 | scripts := c.StringSlice("script") 189 | if s := c.String("script.string"); s != "" { 190 | scripts = append(scripts, s) 191 | } 192 | plugin := Plugin{ 193 | Config: Config{ 194 | Key: c.String("ssh-key"), 195 | KeyPath: c.String("key-path"), 196 | Username: c.String("user"), 197 | Password: c.String("password"), 198 | Host: c.StringSlice("host"), 199 | Port: c.Int("port"), 200 | Timeout: c.Duration("timeout"), 201 | CommandTimeout: c.Duration("command.timeout"), 202 | Script: scripts, 203 | ScriptStop: c.Bool("script.stop"), 204 | Envs: c.StringSlice("envs"), 205 | Debug: c.Bool("debug"), 206 | Sync: c.Bool("sync"), 207 | Proxy: easyssh.DefaultConfig{ 208 | Key: c.String("proxy.ssh-key"), 209 | KeyPath: c.String("proxy.key-path"), 210 | User: c.String("proxy.username"), 211 | Password: c.String("proxy.password"), 212 | Server: c.String("proxy.host"), 213 | Port: c.String("proxy.port"), 214 | Timeout: c.Duration("proxy.timeout"), 215 | }, 216 | }, 217 | Writer: os.Stdout, 218 | } 219 | 220 | return plugin.Exec() 221 | } 222 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | name: testing 4 | 5 | platform: 6 | os: linux 7 | arch: amd64 8 | 9 | steps: 10 | - name: vet 11 | pull: always 12 | image: golang:1.13 13 | commands: 14 | - make vet 15 | volumes: 16 | - name: gopath 17 | path: /go 18 | 19 | - name: lint 20 | pull: always 21 | image: golang:1.13 22 | commands: 23 | - make lint 24 | volumes: 25 | - name: gopath 26 | path: /go 27 | 28 | - name: misspell 29 | pull: always 30 | image: golang:1.13 31 | commands: 32 | - make misspell-check 33 | volumes: 34 | - name: gopath 35 | path: /go 36 | 37 | - name: test 38 | pull: always 39 | image: golang:1.13-alpine 40 | commands: 41 | - apk add git make curl perl bash build-base zlib-dev ucl-dev 42 | - make ssh-server 43 | - make test 44 | - make coverage 45 | volumes: 46 | - name: gopath 47 | path: /go 48 | 49 | - name: codecov 50 | pull: always 51 | image: robertstettner/drone-codecov 52 | settings: 53 | token: 54 | from_secret: codecov_token 55 | 56 | volumes: 57 | - name: gopath 58 | temp: {} 59 | 60 | --- 61 | kind: pipeline 62 | name: linux-amd64 63 | 64 | platform: 65 | os: linux 66 | arch: amd64 67 | 68 | steps: 69 | - name: build-push 70 | pull: always 71 | image: golang:1.13 72 | commands: 73 | - go build -v -ldflags '-X main.build=${DRONE_BUILD_NUMBER}' -a -o release/linux/amd64/drone-ssh 74 | environment: 75 | CGO_ENABLED: 0 76 | when: 77 | event: 78 | exclude: 79 | - tag 80 | 81 | - name: build-tag 82 | pull: always 83 | image: golang:1.13 84 | commands: 85 | - go build -v -ldflags '-X main.version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -a -o release/linux/amd64/drone-ssh 86 | environment: 87 | CGO_ENABLED: 0 88 | when: 89 | event: 90 | - tag 91 | 92 | - name: executable 93 | pull: always 94 | image: golang:1.13 95 | commands: 96 | - ./release/linux/amd64/drone-ssh --help 97 | 98 | - name: dryrun 99 | pull: always 100 | image: plugins/docker:linux-amd64 101 | settings: 102 | cache_from: appleboy/drone-ssh 103 | dockerfile: docker/Dockerfile.linux.amd64 104 | dry_run: true 105 | repo: appleboy/drone-ssh 106 | tags: linux-amd64 107 | when: 108 | event: 109 | - pull_request 110 | 111 | - name: publish 112 | pull: always 113 | image: plugins/docker:linux-amd64 114 | settings: 115 | auto_tag: true 116 | auto_tag_suffix: linux-amd64 117 | cache_from: appleboy/drone-ssh 118 | daemon_off: false 119 | dockerfile: docker/Dockerfile.linux.amd64 120 | password: 121 | from_secret: docker_password 122 | repo: appleboy/drone-ssh 123 | username: 124 | from_secret: docker_username 125 | when: 126 | event: 127 | exclude: 128 | - pull_request 129 | 130 | trigger: 131 | ref: 132 | - refs/heads/master 133 | - refs/pull/** 134 | - refs/tags/** 135 | 136 | depends_on: 137 | - testing 138 | 139 | --- 140 | kind: pipeline 141 | name: linux-arm64 142 | 143 | platform: 144 | os: linux 145 | arch: arm64 146 | 147 | steps: 148 | - name: build-push 149 | pull: always 150 | image: golang:1.13 151 | commands: 152 | - go build -v -ldflags '-X main.build=${DRONE_BUILD_NUMBER}' -a -o release/linux/arm64/drone-ssh 153 | environment: 154 | CGO_ENABLED: 0 155 | when: 156 | event: 157 | exclude: 158 | - tag 159 | 160 | - name: build-tag 161 | pull: always 162 | image: golang:1.13 163 | commands: 164 | - go build -v -ldflags '-X main.version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -a -o release/linux/arm64/drone-ssh 165 | environment: 166 | CGO_ENABLED: 0 167 | when: 168 | event: 169 | - tag 170 | 171 | - name: executable 172 | pull: always 173 | image: golang:1.13 174 | commands: 175 | - ./release/linux/arm64/drone-ssh --help 176 | 177 | - name: dryrun 178 | pull: always 179 | image: plugins/docker:linux-arm64 180 | settings: 181 | cache_from: appleboy/drone-ssh 182 | dockerfile: docker/Dockerfile.linux.arm64 183 | dry_run: true 184 | repo: appleboy/drone-ssh 185 | tags: linux-arm64 186 | when: 187 | event: 188 | - pull_request 189 | 190 | - name: publish 191 | pull: always 192 | image: plugins/docker:linux-arm64 193 | settings: 194 | auto_tag: true 195 | auto_tag_suffix: linux-arm64 196 | cache_from: appleboy/drone-ssh 197 | daemon_off: false 198 | dockerfile: docker/Dockerfile.linux.arm64 199 | password: 200 | from_secret: docker_password 201 | repo: appleboy/drone-ssh 202 | username: 203 | from_secret: docker_username 204 | when: 205 | event: 206 | exclude: 207 | - pull_request 208 | 209 | trigger: 210 | ref: 211 | - refs/heads/master 212 | - refs/pull/** 213 | - refs/tags/** 214 | 215 | depends_on: 216 | - testing 217 | 218 | --- 219 | kind: pipeline 220 | name: linux-arm 221 | 222 | platform: 223 | os: linux 224 | arch: arm 225 | 226 | steps: 227 | - name: build-push 228 | pull: always 229 | image: golang:1.13 230 | commands: 231 | - go build -v -ldflags '-X main.build=${DRONE_BUILD_NUMBER}' -a -o release/linux/arm/drone-ssh 232 | environment: 233 | CGO_ENABLED: 0 234 | when: 235 | event: 236 | exclude: 237 | - tag 238 | 239 | - name: build-tag 240 | pull: always 241 | image: golang:1.13 242 | commands: 243 | - go build -v -ldflags '-X main.version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -a -o release/linux/arm/drone-ssh 244 | environment: 245 | CGO_ENABLED: 0 246 | when: 247 | event: 248 | - tag 249 | 250 | - name: executable 251 | pull: always 252 | image: golang:1.13 253 | commands: 254 | - ./release/linux/arm/drone-ssh --help 255 | 256 | - name: dryrun 257 | pull: always 258 | image: plugins/docker:linux-arm 259 | settings: 260 | cache_from: appleboy/drone-ssh 261 | dockerfile: docker/Dockerfile.linux.arm 262 | dry_run: true 263 | repo: appleboy/drone-ssh 264 | tags: linux-arm 265 | when: 266 | event: 267 | - pull_request 268 | 269 | - name: publish 270 | pull: always 271 | image: plugins/docker:linux-arm 272 | settings: 273 | auto_tag: true 274 | auto_tag_suffix: linux-arm 275 | cache_from: appleboy/drone-ssh 276 | daemon_off: false 277 | dockerfile: docker/Dockerfile.linux.arm 278 | password: 279 | from_secret: docker_password 280 | repo: appleboy/drone-ssh 281 | username: 282 | from_secret: docker_username 283 | when: 284 | event: 285 | exclude: 286 | - pull_request 287 | 288 | trigger: 289 | ref: 290 | - refs/heads/master 291 | - refs/pull/** 292 | - refs/tags/** 293 | 294 | depends_on: 295 | - testing 296 | 297 | --- 298 | kind: pipeline 299 | name: release-binary 300 | 301 | platform: 302 | os: linux 303 | arch: amd64 304 | 305 | steps: 306 | - name: build-all-binary 307 | pull: always 308 | image: golang:1.13 309 | commands: 310 | - make release 311 | when: 312 | event: 313 | - tag 314 | 315 | - name: deploy-all-binary 316 | pull: always 317 | image: plugins/github-release 318 | settings: 319 | api_key: 320 | from_secret: github_release_api_key 321 | files: 322 | - dist/release/* 323 | when: 324 | event: 325 | - tag 326 | 327 | trigger: 328 | ref: 329 | - refs/tags/** 330 | 331 | depends_on: 332 | - testing 333 | 334 | --- 335 | kind: pipeline 336 | name: notifications 337 | 338 | platform: 339 | os: linux 340 | arch: amd64 341 | 342 | steps: 343 | - name: manifest 344 | pull: always 345 | image: plugins/manifest 346 | settings: 347 | ignore_missing: true 348 | password: 349 | from_secret: docker_password 350 | spec: docker/manifest.tmpl 351 | username: 352 | from_secret: docker_username 353 | 354 | trigger: 355 | ref: 356 | - refs/heads/master 357 | - refs/tags/** 358 | 359 | depends_on: 360 | - linux-amd64 361 | - linux-arm64 362 | - linux-arm 363 | - release-binary 364 | 365 | ... 366 | -------------------------------------------------------------------------------- /plugin_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/appleboy/easyssh-proxy" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestMissingHostOrUser(t *testing.T) { 17 | plugin := Plugin{} 18 | 19 | err := plugin.Exec() 20 | 21 | assert.NotNil(t, err) 22 | assert.Equal(t, errMissingHost, err) 23 | } 24 | 25 | func TestMissingKeyOrPassword(t *testing.T) { 26 | plugin := Plugin{ 27 | Config{ 28 | Host: []string{"localhost"}, 29 | Username: "ubuntu", 30 | }, 31 | os.Stdout, 32 | } 33 | 34 | err := plugin.Exec() 35 | 36 | assert.NotNil(t, err) 37 | assert.Equal(t, errMissingPasswordOrKey, err) 38 | } 39 | 40 | func TestSetPasswordAndKey(t *testing.T) { 41 | plugin := Plugin{ 42 | Config{ 43 | Host: []string{"localhost"}, 44 | Username: "ubuntu", 45 | Password: "1234", 46 | Key: "1234", 47 | }, 48 | os.Stdout, 49 | } 50 | 51 | err := plugin.Exec() 52 | 53 | assert.NotNil(t, err) 54 | assert.Equal(t, errSetPasswordandKey, err) 55 | } 56 | 57 | func TestIncorrectPassword(t *testing.T) { 58 | plugin := Plugin{ 59 | Config: Config{ 60 | Host: []string{"localhost"}, 61 | Username: "drone-scp", 62 | Port: 22, 63 | Password: "123456", 64 | Script: []string{"whoami"}, 65 | CommandTimeout: 60 * time.Second, 66 | }, 67 | } 68 | 69 | err := plugin.Exec() 70 | assert.NotNil(t, err) 71 | } 72 | 73 | func TestSSHScriptFromRawKey(t *testing.T) { 74 | plugin := Plugin{ 75 | Config: Config{ 76 | Host: []string{"localhost"}, 77 | Username: "drone-scp", 78 | Port: 22, 79 | CommandTimeout: 60 * time.Second, 80 | Key: `-----BEGIN RSA PRIVATE KEY----- 81 | MIIEpAIBAAKCAQEA4e2D/qPN08pzTac+a8ZmlP1ziJOXk45CynMPtva0rtK/RB26 82 | VbfAF0hIJji7ltvnYnqCU9oFfvEM33cTn7T96+od8ib/Vz25YU8ZbstqtIskPuwC 83 | bv3K0mAHgsviJyRD7yM+QKTbBQEgbGuW6gtbMKhiYfiIB4Dyj7AdS/fk3v26wDgz 84 | 7SHI5OBqu9bv1KhxQYdFEnU3PAtAqeccgzNpbH3eYLyGzuUxEIJlhpZ/uU2G9ppj 85 | /cSrONVPiI8Ahi4RrlZjmP5l57/sq1ClGulyLpFcMw68kP5FikyqHpHJHRBNgU57 86 | 1y0Ph33SjBbs0haCIAcmreWEhGe+/OXnJe6VUQIDAQABAoIBAH97emORIm9DaVSD 87 | 7mD6DqA7c5m5Tmpgd6eszU08YC/Vkz9oVuBPUwDQNIX8tT0m0KVs42VVPIyoj874 88 | bgZMJoucC1G8V5Bur9AMxhkShx9g9A7dNXJTmsKilRpk2TOk7wBdLp9jZoKoZBdJ 89 | jlp6FfaazQjjKD6zsCsMATwAoRCBpBNsmT6QDN0n0bIgY0tE6YGQaDdka0dAv68G 90 | R0VZrcJ9voT6+f+rgJLoojn2DAu6iXaM99Gv8FK91YCymbQlXXgrk6CyS0IHexN7 91 | V7a3k767KnRbrkqd3o6JyNun/CrUjQwHs1IQH34tvkWScbseRaFehcAm6mLT93RP 92 | muauvMECgYEA9AXGtfDMse0FhvDPZx4mx8x+vcfsLvDHcDLkf/lbyPpu97C27b/z 93 | ia07bu5TAXesUZrWZtKA5KeRE5doQSdTOv1N28BEr8ZwzDJwfn0DPUYUOxsN2iIy 94 | MheO5A45Ko7bjKJVkZ61Mb1UxtqCTF9mqu9R3PBdJGthWOd+HUvF460CgYEA7QRf 95 | Z8+vpGA+eSuu29e0xgRKnRzed5zXYpcI4aERc3JzBgO4Z0er9G8l66OWVGdMfpe6 96 | CBajC5ToIiT8zqoYxXwqJgN+glir4gJe3mm8J703QfArZiQrdk0NTi5bY7+vLLG/ 97 | knTrtpdsKih6r3kjhuPPaAsIwmMxIydFvATKjLUCgYEAh/y4EihRSk5WKC8GxeZt 98 | oiZ58vT4z+fqnMIfyJmD5up48JuQNcokw/LADj/ODiFM7GUnWkGxBrvDA3H67WQm 99 | 49bJjs8E+BfUQFdTjYnJRlpJZ+7Zt1gbNQMf5ENw5CCchTDqEq6pN0DVf8PBnSIF 100 | KvkXW9KvdV5J76uCAn15mDkCgYA1y8dHzbjlCz9Cy2pt1aDfTPwOew33gi7U3skS 101 | RTerx29aDyAcuQTLfyrROBkX4TZYiWGdEl5Bc7PYhCKpWawzrsH2TNa7CRtCOh2E 102 | R+V/84+GNNf04ALJYCXD9/ugQVKmR1XfDRCvKeFQFE38Y/dvV2etCswbKt5tRy2p 103 | xkCe/QKBgQCkLqafD4S20YHf6WTp3jp/4H/qEy2X2a8gdVVBi1uKkGDXr0n+AoVU 104 | ib4KbP5ovZlrjL++akMQ7V2fHzuQIFWnCkDA5c2ZAqzlM+ZN+HRG7gWur7Bt4XH1 105 | 7XC9wlRna4b3Ln8ew3q1ZcBjXwD4ppbTlmwAfQIaZTGJUgQbdsO9YA== 106 | -----END RSA PRIVATE KEY----- 107 | `, 108 | Script: []string{"whoami"}, 109 | }, 110 | } 111 | 112 | err := plugin.Exec() 113 | assert.Nil(t, err) 114 | } 115 | 116 | func TestSSHScriptFromKeyFile(t *testing.T) { 117 | plugin := Plugin{ 118 | Config: Config{ 119 | Host: []string{"localhost", "127.0.0.1"}, 120 | Username: "drone-scp", 121 | Port: 22, 122 | KeyPath: "./tests/.ssh/id_rsa", 123 | Script: []string{"whoami", "ls -al"}, 124 | CommandTimeout: 60 * time.Second, 125 | }, 126 | } 127 | 128 | err := plugin.Exec() 129 | assert.Nil(t, err) 130 | } 131 | 132 | func TestStreamFromSSHCommand(t *testing.T) { 133 | plugin := Plugin{ 134 | Config: Config{ 135 | Host: []string{"localhost", "127.0.0.1"}, 136 | Username: "drone-scp", 137 | Port: 22, 138 | KeyPath: "./tests/.ssh/id_rsa", 139 | Script: []string{"whoami", "for i in {1..5}; do echo ${i}; sleep 1; done", "echo 'done'"}, 140 | CommandTimeout: 60 * time.Second, 141 | }, 142 | } 143 | 144 | err := plugin.Exec() 145 | assert.Nil(t, err) 146 | } 147 | 148 | func TestSSHScriptWithError(t *testing.T) { 149 | plugin := Plugin{ 150 | Config: Config{ 151 | Host: []string{"localhost", "127.0.0.1"}, 152 | Username: "drone-scp", 153 | Port: 22, 154 | KeyPath: "./tests/.ssh/id_rsa", 155 | Script: []string{"exit 1"}, 156 | CommandTimeout: 60 * time.Second, 157 | }, 158 | } 159 | 160 | err := plugin.Exec() 161 | // Process exited with status 1 162 | assert.NotNil(t, err) 163 | } 164 | 165 | func TestSSHCommandTimeOut(t *testing.T) { 166 | plugin := Plugin{ 167 | Config: Config{ 168 | Host: []string{"localhost"}, 169 | Username: "drone-scp", 170 | Port: 22, 171 | KeyPath: "./tests/.ssh/id_rsa", 172 | Script: []string{"sleep 5"}, 173 | CommandTimeout: 1 * time.Second, 174 | }, 175 | } 176 | 177 | err := plugin.Exec() 178 | assert.NotNil(t, err) 179 | } 180 | 181 | func TestProxyCommand(t *testing.T) { 182 | plugin := Plugin{ 183 | Config: Config{ 184 | Host: []string{"localhost"}, 185 | Username: "drone-scp", 186 | Port: 22, 187 | KeyPath: "./tests/.ssh/id_rsa", 188 | Script: []string{"whoami"}, 189 | CommandTimeout: 1 * time.Second, 190 | Proxy: easyssh.DefaultConfig{ 191 | Server: "localhost", 192 | User: "drone-scp", 193 | Port: "22", 194 | KeyPath: "./tests/.ssh/id_rsa", 195 | }, 196 | }, 197 | } 198 | 199 | err := plugin.Exec() 200 | assert.Nil(t, err) 201 | } 202 | 203 | func TestSSHCommandError(t *testing.T) { 204 | plugin := Plugin{ 205 | Config: Config{ 206 | Host: []string{"localhost"}, 207 | Username: "drone-scp", 208 | Port: 22, 209 | KeyPath: "./tests/.ssh/id_rsa", 210 | Script: []string{"mkdir a", "mkdir a"}, 211 | CommandTimeout: 60 * time.Second, 212 | }, 213 | } 214 | 215 | err := plugin.Exec() 216 | assert.NotNil(t, err) 217 | } 218 | 219 | func TestSSHCommandExitCodeError(t *testing.T) { 220 | plugin := Plugin{ 221 | Config: Config{ 222 | Host: []string{"localhost"}, 223 | Username: "drone-scp", 224 | Port: 22, 225 | KeyPath: "./tests/.ssh/id_rsa", 226 | Script: []string{ 227 | "set -e", 228 | "echo 1", 229 | "mkdir a", 230 | "mkdir a", 231 | "echo 2", 232 | }, 233 | CommandTimeout: 60 * time.Second, 234 | }, 235 | } 236 | 237 | err := plugin.Exec() 238 | assert.NotNil(t, err) 239 | } 240 | 241 | func TestSetENV(t *testing.T) { 242 | os.Setenv("FOO", `' 1) '`) 243 | plugin := Plugin{ 244 | Config: Config{ 245 | Host: []string{"localhost"}, 246 | Username: "drone-scp", 247 | Port: 22, 248 | KeyPath: "./tests/.ssh/id_rsa", 249 | Envs: []string{"foo"}, 250 | Debug: true, 251 | Script: []string{"whoami; echo $FOO"}, 252 | CommandTimeout: 1 * time.Second, 253 | Proxy: easyssh.DefaultConfig{ 254 | Server: "localhost", 255 | User: "drone-scp", 256 | Port: "22", 257 | KeyPath: "./tests/.ssh/id_rsa", 258 | }, 259 | }, 260 | } 261 | 262 | err := plugin.Exec() 263 | assert.Nil(t, err) 264 | } 265 | 266 | func TestSetExistingENV(t *testing.T) { 267 | os.Setenv("FOO", "Value for foo") 268 | os.Setenv("BAR", "") 269 | plugin := Plugin{ 270 | Config: Config{ 271 | Host: []string{"localhost"}, 272 | Username: "drone-scp", 273 | Port: 22, 274 | KeyPath: "./tests/.ssh/id_rsa", 275 | Envs: []string{"foo", "bar", "baz"}, 276 | Debug: true, 277 | Script: []string{"export FOO", "export BAR", "export BAZ", "env | grep -q '^FOO=Value for foo$'", "env | grep -q '^BAR=$'", "if env | grep -q BAZ; then false; else true; fi"}, 278 | CommandTimeout: 1 * time.Second, 279 | Proxy: easyssh.DefaultConfig{ 280 | Server: "localhost", 281 | User: "drone-scp", 282 | Port: "22", 283 | KeyPath: "./tests/.ssh/id_rsa", 284 | }, 285 | }, 286 | } 287 | 288 | err := plugin.Exec() 289 | assert.Nil(t, err) 290 | } 291 | 292 | func TestSyncMode(t *testing.T) { 293 | plugin := Plugin{ 294 | Config: Config{ 295 | Host: []string{"localhost", "127.0.0.1"}, 296 | Username: "drone-scp", 297 | Port: 22, 298 | KeyPath: "./tests/.ssh/id_rsa", 299 | Script: []string{"whoami", "for i in {1..3}; do echo ${i}; sleep 1; done", "echo 'done'"}, 300 | CommandTimeout: 60 * time.Second, 301 | Sync: true, 302 | }, 303 | } 304 | 305 | err := plugin.Exec() 306 | assert.Nil(t, err) 307 | } 308 | 309 | func Test_escapeArg(t *testing.T) { 310 | type args struct { 311 | arg string 312 | } 313 | tests := []struct { 314 | name string 315 | args args 316 | want string 317 | }{ 318 | { 319 | name: "escape nothing", 320 | args: args{ 321 | arg: "Hi I am appleboy", 322 | }, 323 | want: `'Hi I am appleboy'`, 324 | }, 325 | { 326 | name: "escape single quote", 327 | args: args{ 328 | arg: "Hi I am 'appleboy'", 329 | }, 330 | want: `'Hi I am '\''appleboy'\'''`, 331 | }, 332 | } 333 | for _, tt := range tests { 334 | t.Run(tt.name, func(t *testing.T) { 335 | got := escapeArg(tt.args.arg) 336 | assert.Equal(t, tt.want, got) 337 | }) 338 | } 339 | } 340 | 341 | func TestCommandOutput(t *testing.T) { 342 | var ( 343 | buffer bytes.Buffer 344 | expected = ` 345 | localhost: ======CMD====== 346 | localhost: pwd 347 | whoami 348 | uname 349 | localhost: ======END====== 350 | localhost: out: /home/drone-scp 351 | localhost: out: drone-scp 352 | localhost: out: Linux 353 | 127.0.0.1: ======CMD====== 354 | 127.0.0.1: pwd 355 | whoami 356 | uname 357 | 127.0.0.1: ======END====== 358 | 127.0.0.1: out: /home/drone-scp 359 | 127.0.0.1: out: drone-scp 360 | 127.0.0.1: out: Linux 361 | ` 362 | ) 363 | 364 | plugin := Plugin{ 365 | Config: Config{ 366 | Host: []string{"localhost", "127.0.0.1"}, 367 | Username: "drone-scp", 368 | Port: 22, 369 | KeyPath: "./tests/.ssh/id_rsa", 370 | Script: []string{ 371 | "pwd", 372 | "whoami", 373 | "uname", 374 | }, 375 | CommandTimeout: 60 * time.Second, 376 | Sync: true, 377 | }, 378 | Writer: &buffer, 379 | } 380 | 381 | err := plugin.Exec() 382 | assert.Nil(t, err) 383 | 384 | assert.Equal(t, unindent(expected), unindent(buffer.String())) 385 | } 386 | 387 | func TestScriptStop(t *testing.T) { 388 | var ( 389 | buffer bytes.Buffer 390 | expected = ` 391 | ======CMD====== 392 | mkdir a/b/c 393 | mkdir d/e/f 394 | ======END====== 395 | err: mkdir: can't create directory 'a/b/c': No such file or directory 396 | ` 397 | ) 398 | 399 | plugin := Plugin{ 400 | Config: Config{ 401 | Host: []string{"localhost"}, 402 | Username: "drone-scp", 403 | Port: 22, 404 | KeyPath: "./tests/.ssh/id_rsa", 405 | Script: []string{ 406 | "mkdir a/b/c", 407 | "mkdir d/e/f", 408 | }, 409 | CommandTimeout: 10 * time.Second, 410 | ScriptStop: true, 411 | }, 412 | Writer: &buffer, 413 | } 414 | 415 | err := plugin.Exec() 416 | assert.NotNil(t, err) 417 | 418 | assert.Equal(t, unindent(expected), unindent(buffer.String())) 419 | } 420 | 421 | func TestNoneScriptStop(t *testing.T) { 422 | var ( 423 | buffer bytes.Buffer 424 | expected = ` 425 | ======CMD====== 426 | mkdir a/b/c 427 | mkdir d/e/f 428 | ======END====== 429 | err: mkdir: can't create directory 'a/b/c': No such file or directory 430 | err: mkdir: can't create directory 'd/e/f': No such file or directory 431 | ` 432 | ) 433 | 434 | plugin := Plugin{ 435 | Config: Config{ 436 | Host: []string{"localhost"}, 437 | Username: "drone-scp", 438 | Port: 22, 439 | KeyPath: "./tests/.ssh/id_rsa", 440 | Script: []string{ 441 | "mkdir a/b/c", 442 | "mkdir d/e/f", 443 | }, 444 | CommandTimeout: 10 * time.Second, 445 | }, 446 | Writer: &buffer, 447 | } 448 | 449 | err := plugin.Exec() 450 | assert.NotNil(t, err) 451 | 452 | assert.Equal(t, unindent(expected), unindent(buffer.String())) 453 | } 454 | 455 | func TestEnvOutput(t *testing.T) { 456 | var ( 457 | buffer bytes.Buffer 458 | expected = ` 459 | ======CMD====== 460 | echo "[${ENV_1}]" 461 | echo "[${ENV_2}]" 462 | echo "[${ENV_3}]" 463 | echo "[${ENV_4}]" 464 | echo "[${ENV_5}]" 465 | echo "[${ENV_6}]" 466 | echo "[${ENV_7}]" 467 | ======END====== 468 | ======ENV====== 469 | ENV_1='test' 470 | ENV_2='test test' 471 | ENV_3='test ' 472 | ENV_4=' test test ' 473 | ENV_5='test'\''' 474 | ENV_6='test"' 475 | ENV_7='test,!#;?.@$~'\''"' 476 | ======END====== 477 | out: [test] 478 | out: [test test] 479 | out: [test ] 480 | out: [ test test ] 481 | out: [test'] 482 | out: [test"] 483 | out: [test,!#;?.@$~'"] 484 | ` 485 | ) 486 | 487 | os.Setenv("ENV_1", `test`) 488 | os.Setenv("ENV_2", `test test`) 489 | os.Setenv("ENV_3", `test `) 490 | os.Setenv("ENV_4", ` test test `) 491 | os.Setenv("ENV_5", `test'`) 492 | os.Setenv("ENV_6", `test"`) 493 | os.Setenv("ENV_7", `test,!#;?.@$~'"`) 494 | 495 | plugin := Plugin{ 496 | Config: Config{ 497 | Host: []string{"localhost"}, 498 | Username: "drone-scp", 499 | Port: 22, 500 | KeyPath: "./tests/.ssh/id_rsa", 501 | Envs: []string{"env_1", "env_2", "env_3", "env_4", "env_5", "env_6", "env_7"}, 502 | Debug: true, 503 | Script: []string{ 504 | `echo "[${ENV_1}]"`, 505 | `echo "[${ENV_2}]"`, 506 | `echo "[${ENV_3}]"`, 507 | `echo "[${ENV_4}]"`, 508 | `echo "[${ENV_5}]"`, 509 | `echo "[${ENV_6}]"`, 510 | `echo "[${ENV_7}]"`, 511 | }, 512 | CommandTimeout: 10 * time.Second, 513 | Proxy: easyssh.DefaultConfig{ 514 | Server: "localhost", 515 | User: "drone-scp", 516 | Port: "22", 517 | KeyPath: "./tests/.ssh/id_rsa", 518 | }, 519 | }, 520 | Writer: &buffer, 521 | } 522 | 523 | err := plugin.Exec() 524 | assert.Nil(t, err) 525 | 526 | assert.Equal(t, unindent(expected), unindent(buffer.String())) 527 | } 528 | 529 | func unindent(text string) string { 530 | return strings.TrimSpace(strings.Replace(text, "\t", "", -1)) 531 | } 532 | 533 | func TestPlugin_scriptCommands(t *testing.T) { 534 | type fields struct { 535 | Config Config 536 | Writer io.Writer 537 | } 538 | tests := []struct { 539 | name string 540 | fields fields 541 | want []string 542 | }{ 543 | { 544 | name: "normal testing", 545 | fields: fields{ 546 | Config: Config{ 547 | Script: []string{"mkdir a", "mkdir b"}, 548 | }, 549 | }, 550 | want: []string{"mkdir a", "mkdir b"}, 551 | }, 552 | { 553 | name: "script stop", 554 | fields: fields{ 555 | Config: Config{ 556 | Script: []string{"mkdir a", "mkdir b"}, 557 | ScriptStop: true, 558 | }, 559 | }, 560 | want: []string{"mkdir a", "DRONE_SSH_PREV_COMMAND_EXIT_CODE=$? ; if [ $DRONE_SSH_PREV_COMMAND_EXIT_CODE -ne 0 ]; then exit $DRONE_SSH_PREV_COMMAND_EXIT_CODE; fi;", "mkdir b", "DRONE_SSH_PREV_COMMAND_EXIT_CODE=$? ; if [ $DRONE_SSH_PREV_COMMAND_EXIT_CODE -ne 0 ]; then exit $DRONE_SSH_PREV_COMMAND_EXIT_CODE; fi;"}, 561 | }, 562 | { 563 | name: "normal testing 2", 564 | fields: fields{ 565 | Config: Config{ 566 | Script: []string{"mkdir a\nmkdir c", "mkdir b"}, 567 | ScriptStop: true, 568 | }, 569 | }, 570 | want: []string{"mkdir a", "DRONE_SSH_PREV_COMMAND_EXIT_CODE=$? ; if [ $DRONE_SSH_PREV_COMMAND_EXIT_CODE -ne 0 ]; then exit $DRONE_SSH_PREV_COMMAND_EXIT_CODE; fi;", "mkdir c", "DRONE_SSH_PREV_COMMAND_EXIT_CODE=$? ; if [ $DRONE_SSH_PREV_COMMAND_EXIT_CODE -ne 0 ]; then exit $DRONE_SSH_PREV_COMMAND_EXIT_CODE; fi;", "mkdir b", "DRONE_SSH_PREV_COMMAND_EXIT_CODE=$? ; if [ $DRONE_SSH_PREV_COMMAND_EXIT_CODE -ne 0 ]; then exit $DRONE_SSH_PREV_COMMAND_EXIT_CODE; fi;"}, 571 | }, 572 | { 573 | name: "trim space", 574 | fields: fields{ 575 | Config: Config{ 576 | Script: []string{"mkdir a", "mkdir b", "\t", " "}, 577 | ScriptStop: false, 578 | }, 579 | }, 580 | want: []string{"mkdir a", "mkdir b"}, 581 | }, 582 | } 583 | for _, tt := range tests { 584 | t.Run(tt.name, func(t *testing.T) { 585 | p := Plugin{ 586 | Config: tt.fields.Config, 587 | Writer: tt.fields.Writer, 588 | } 589 | if got := p.scriptCommands(); !reflect.DeepEqual(got, tt.want) { 590 | t.Errorf("Plugin.scriptCommands() = %#v, want %#v", got, tt.want) 591 | } 592 | }) 593 | } 594 | } 595 | --------------------------------------------------------------------------------