commit
6e4cfc3b38
35 changed files with 4892 additions and 0 deletions
@ -0,0 +1,3 @@ |
|||
bin/ |
|||
*.sw? |
|||
*.exe |
@ -0,0 +1,22 @@ |
|||
MIT License |
|||
|
|||
Copyright (c) 2015 Pressly Inc. www.pressly.com |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining |
|||
a copy of this software and associated documentation files (the |
|||
"Software"), to deal in the Software without restriction, including |
|||
without limitation the rights to use, copy, modify, merge, publish, |
|||
distribute, sublicense, and/or sell copies of the Software, and to |
|||
permit persons to whom the Software is furnished to do so, subject to |
|||
the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be |
|||
included in all copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE |
|||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION |
|||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION |
|||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@ -0,0 +1,42 @@ |
|||
.PHONY: all build dist test install clean tools deps update-deps |
|||
|
|||
all: |
|||
@echo "build - Build stup" |
|||
@echo "dist - Build stup distribution binaries" |
|||
@echo "test - Run tests" |
|||
@echo "install - Install binary" |
|||
@echo "clean - Clean up" |
|||
@echo "" |
|||
@echo "tools - Install tools" |
|||
@echo "vendor-list - List vendor package tree" |
|||
@echo "vendor-update - Update vendored packages" |
|||
|
|||
build: |
|||
@mkdir -p ./bin |
|||
@rm -f ./bin/* |
|||
go build -o ./bin/stup ./cmd/stup |
|||
|
|||
dist: |
|||
@mkdir -p ./bin |
|||
@rm -f ./bin/* |
|||
GOOS=darwin GOARCH=amd64 go build -o ./bin/stup-darwin64 ./cmd/stup |
|||
GOOS=linux GOARCH=amd64 go build -o ./bin/stup-linux64 ./cmd/stup |
|||
GOOS=windows GOARCH=amd64 go build -o ./bin/stup-windows64.exe ./cmd/stup |
|||
|
|||
test: |
|||
go test ./... |
|||
|
|||
install: |
|||
go install ./cmd/stup |
|||
|
|||
clean: |
|||
@rm -rf ./bin |
|||
|
|||
tools: |
|||
go get -u github.com/kardianos/govendor |
|||
|
|||
vendor-list: |
|||
@govendor list |
|||
|
|||
vendor-update: |
|||
@govendor update +external |
@ -0,0 +1,326 @@ |
|||
Start Up |
|||
======== |
|||
|
|||
Start Up is a simple deployment tool that performs given set of commands on multiple hosts in parallel. It reads Stupfile, a YAML configuration file, which defines networks (groups of hosts), commands and targets. |
|||
|
|||
# Installation |
|||
|
|||
$ make dist |
|||
|
|||
# Usage |
|||
|
|||
$ stup [OPTIONS] NETWORK COMMAND [...] |
|||
|
|||
### Options |
|||
|
|||
| Option | Description | |
|||
|-------------------|----------------------------------| |
|||
| `-f Stupfile` | Custom path to Stupfile | |
|||
| `-e`, `--env=[]` | Set environment variables | |
|||
| `--only REGEXP` | Filter hosts matching regexp | |
|||
| `--except REGEXP` | Filter out hosts matching regexp | |
|||
| `--debug`, `-D` | Enable debug/verbose mode | |
|||
| `--disable-prefix`| Disable hostname prefix | |
|||
| `--help`, `-h` | Show help/usage | |
|||
| `--version`, `-v` | Print version | |
|||
|
|||
## Network |
|||
|
|||
A group of hosts. |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
networks: |
|||
production: |
|||
hosts: |
|||
- api1.example.com |
|||
- api2.example.com |
|||
- api3.example.com |
|||
staging: |
|||
# fetch dynamic list of hosts |
|||
inventory: curl http://example.com/latest/meta-data/hostname |
|||
``` |
|||
|
|||
`$ stup production COMMAND` will run COMMAND on `api1`, `api2` and `api3` hosts in parallel. |
|||
|
|||
## Command |
|||
|
|||
A shell command(s) to be run remotely. |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
commands: |
|||
restart: |
|||
desc: Restart example Docker container |
|||
run: sudo docker restart example |
|||
tail-logs: |
|||
desc: Watch tail of Docker logs from all hosts |
|||
run: sudo docker logs --tail=20 -f example |
|||
``` |
|||
|
|||
`$ stup staging restart` will restart all staging Docker containers in parallel. |
|||
|
|||
`$ stup production tail-logs` will tail Docker logs from all production containers in parallel. |
|||
|
|||
### Serial command (a.k.a. Rolling Update) |
|||
|
|||
`serial: N` constraints a command to be run on `N` hosts at a time at maximum. Rolling Update for free! |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
commands: |
|||
restart: |
|||
desc: Restart example Docker container |
|||
run: sudo docker restart example |
|||
serial: 2 |
|||
``` |
|||
|
|||
`$ stup production restart` will restart all Docker containers, two at a time at maximum. |
|||
|
|||
### Once command (one host only) |
|||
|
|||
`once: true` constraints a command to be run only on one host. Useful for one-time tasks. |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
commands: |
|||
build: |
|||
desc: Build Docker image and push to registry |
|||
run: sudo docker build -t image:latest . && sudo docker push image:latest |
|||
once: true # one host only |
|||
pull: |
|||
desc: Pull latest Docker image from registry |
|||
run: sudo docker pull image:latest |
|||
``` |
|||
|
|||
`$ stup production build pull` will build Docker image on one production host only and spread it to all hosts. |
|||
|
|||
### Local command |
|||
|
|||
Runs command always on localhost. |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
commands: |
|||
prepare: |
|||
desc: Prepare to upload |
|||
local: npm run build |
|||
``` |
|||
|
|||
### Upload command |
|||
|
|||
Uploads files/directories to all remote hosts. Uses `tar` under the hood. |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
commands: |
|||
upload: |
|||
desc: Upload dist files to all hosts |
|||
upload: |
|||
- src: ./dist |
|||
dst: /tmp/ |
|||
``` |
|||
|
|||
### Interactive Bash on all hosts |
|||
|
|||
Do you want to interact with multiple hosts at once? Sure! |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
commands: |
|||
bash: |
|||
desc: Interactive Bash on all hosts |
|||
stdin: true |
|||
run: bash |
|||
``` |
|||
|
|||
```bash |
|||
$ stup production bash |
|||
# |
|||
# type in commands and see output from all hosts! |
|||
# ^C |
|||
``` |
|||
|
|||
Passing prepared commands to all hosts: |
|||
```bash |
|||
$ echo 'sudo apt-get update -y' | stup production bash |
|||
|
|||
# or: |
|||
$ stup production bash <<< 'sudo apt-get update -y' |
|||
|
|||
# or: |
|||
$ cat <<EOF | stup production bash |
|||
sudo apt-get update -y |
|||
date |
|||
uname -a |
|||
EOF |
|||
``` |
|||
|
|||
### Interactive Docker Exec on all hosts |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
commands: |
|||
exec: |
|||
desc: Exec into Docker container on all hosts |
|||
stdin: true |
|||
run: sudo docker exec -i $CONTAINER bash |
|||
``` |
|||
|
|||
```bash |
|||
$ stup production exec |
|||
ps aux |
|||
strace -p 1 # trace system calls and signals on all your production hosts |
|||
``` |
|||
|
|||
## Target |
|||
|
|||
Target is an alias for multiple commands. Each command will be run on all hosts in parallel, |
|||
`stup` will check return status from all hosts, and run subsequent commands on success only |
|||
(thus any error on any host will interrupt the process). |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
|
|||
targets: |
|||
deploy: |
|||
- build |
|||
- pull |
|||
- migrate-db-up |
|||
- stop-rm-run |
|||
- health |
|||
- slack-notify |
|||
``` |
|||
|
|||
`$ stup production deploy` |
|||
|
|||
is equivalent to |
|||
|
|||
`$ stup production build pull migrate-db-up stop-rm-run health slack-notify airbrake-notify` |
|||
|
|||
# Stupfile |
|||
|
|||
See [example Stupfile](./example/Stupfile). |
|||
|
|||
### Basic structure |
|||
|
|||
```yaml |
|||
# Stupfile |
|||
--- |
|||
version: 1 |
|||
|
|||
# Global environment variables |
|||
env: |
|||
NAME: api |
|||
IMAGE: example/api |
|||
|
|||
networks: |
|||
local: |
|||
hosts: |
|||
- address: localhost |
|||
staging: |
|||
hosts: |
|||
- address: stg1.example.com |
|||
production: |
|||
hosts: |
|||
- address: api1.example.com |
|||
- address: api2.example.com |
|||
user: ec2-user |
|||
sudo: true |
|||
|
|||
commands: |
|||
echo: |
|||
desc: Print some env vars |
|||
run: echo $NAME $IMAGE $STUP_NETWORK |
|||
date: |
|||
desc: Print OS name and current date/time |
|||
run: uname -a; date |
|||
|
|||
targets: |
|||
all: |
|||
- echo |
|||
- date |
|||
``` |
|||
|
|||
### Default environment variables available in Stupfile |
|||
|
|||
- `$STUP_HOST` - Current host. |
|||
- `$STUP_NETWORK` - Current network. |
|||
- `$STUP_USER` - User who invoked stup command. |
|||
- `$STUP_TIME` - Date/time of stup command invocation. |
|||
- `$STUP_ENV` - Environment variables provided on stup command invocation. You can pass `$STUP_ENV` to another `stup` or `docker` commands in your Stupfile. |
|||
|
|||
# Running stup from Stupfile |
|||
|
|||
Stupfile doesn't let you import another Stupfile. Instead, it lets you run `stup` sub-process from inside your Stupfile. This is how you can structure larger projects: |
|||
|
|||
``` |
|||
./Stupfile |
|||
./database/Stupfile |
|||
./services/scheduler/Stupfile |
|||
``` |
|||
|
|||
Top-level Stupfile calls `stup` with Stupfiles from sub-projects: |
|||
```yaml |
|||
restart-scheduler: |
|||
desc: Restart scheduler |
|||
local: > |
|||
stup -f ./services/scheduler/Stupfile $STUP_ENV $STUP_NETWORK restart |
|||
db-up: |
|||
desc: Migrate database |
|||
local: > |
|||
stup -f ./database/Stupfile $STUP_ENV $STUP_NETWORK up |
|||
``` |
|||
|
|||
# Common SSH Problem |
|||
|
|||
if for some reason stup doesn't connect and you get the following error, |
|||
|
|||
```bash |
|||
connecting to clients failed: connecting to remote host failed: Connect("myserver@xxx.xxx.xxx.xxx"): ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain |
|||
``` |
|||
|
|||
it means that your `ssh-agent` dosen't have access to your public and private keys. in order to fix this issue, follow the below instructions: |
|||
|
|||
- run the following command and make sure you have a key register with `ssh-agent` |
|||
|
|||
```bash |
|||
ssh-add -l |
|||
``` |
|||
|
|||
if you see something like `The agent has no identities.` it means that you need to manually add your key to `ssh-agent`. |
|||
in order to do that, run the following command |
|||
|
|||
```bash |
|||
ssh-add ~/.ssh/id_rsa |
|||
``` |
|||
|
|||
you should now be able to use stup with your ssh key. |
|||
|
|||
|
|||
# TODO |
|||
|
|||
- add secret vault database |
|||
- add host manage plugin |
|||
- add service manage plugin |
|||
- add package manage plugin |
|||
|
|||
# Development |
|||
|
|||
fork it, hack it.. |
|||
|
|||
$ make build |
|||
|
|||
create new Pull Request |
|||
|
|||
# License |
|||
|
|||
Licensed under the [MIT License](./LICENSE). |
@ -0,0 +1,20 @@ |
|||
package stup |
|||
|
|||
import ( |
|||
"io" |
|||
"os" |
|||
) |
|||
|
|||
type Client interface { |
|||
Connect(host string) error |
|||
Run(task *Task) error |
|||
Wait() error |
|||
Close() error |
|||
Prefix() (string, int) |
|||
Write(p []byte) (n int, err error) |
|||
WriteClose() error |
|||
Stdin() io.WriteCloser |
|||
Stderr() io.Reader |
|||
Stdout() io.Reader |
|||
Signal(os.Signal) error |
|||
} |
@ -0,0 +1,391 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"flag" |
|||
"fmt" |
|||
"io/ioutil" |
|||
"os" |
|||
"os/user" |
|||
"path/filepath" |
|||
"regexp" |
|||
"strings" |
|||
"text/tabwriter" |
|||
"time" |
|||
|
|||
"stup" |
|||
|
|||
"github.com/mikkeloscar/sshconfig" |
|||
"github.com/pkg/errors" |
|||
) |
|||
|
|||
var ( |
|||
stupfile string |
|||
envVars flagStringSlice |
|||
sshConfig string |
|||
onlyHosts string |
|||
exceptHosts string |
|||
|
|||
debug bool |
|||
disablePrefix bool |
|||
|
|||
showVersion bool |
|||
showHelp bool |
|||
|
|||
ErrUsage = errors.New("Usage: stup [OPTIONS] NETWORK COMMAND [...]\n stup [ --help | -v | --version ]") |
|||
ErrUnknownNetwork = errors.New("Unknown network") |
|||
ErrNetworkNoHosts = errors.New("No hosts defined for a given network") |
|||
ErrCmd = errors.New("Unknown command/target") |
|||
ErrTargetNoCommands = errors.New("No commands defined for a given target") |
|||
ErrConfigFile = errors.New("Unknown ssh_config file") |
|||
) |
|||
|
|||
type flagStringSlice []string |
|||
|
|||
func (f *flagStringSlice) String() string { |
|||
return fmt.Sprintf("%v", *f) |
|||
} |
|||
|
|||
func (f *flagStringSlice) Set(value string) error { |
|||
*f = append(*f, value) |
|||
return nil |
|||
} |
|||
|
|||
func init() { |
|||
flag.StringVar(&stupfile, "f", "", "Custom path to ./Supfile[.yml]") |
|||
flag.Var(&envVars, "e", "Set environment variables") |
|||
flag.Var(&envVars, "env", "Set environment variables") |
|||
flag.StringVar(&sshConfig, "sshconfig", "", "Read SSH Config file, ie. ~/.ssh/config file") |
|||
flag.StringVar(&onlyHosts, "only", "", "Filter hosts using regexp") |
|||
flag.StringVar(&exceptHosts, "except", "", "Filter out hosts using regexp") |
|||
|
|||
flag.BoolVar(&debug, "D", false, "Enable debug mode") |
|||
flag.BoolVar(&debug, "debug", false, "Enable debug mode") |
|||
flag.BoolVar(&disablePrefix, "disable-prefix", false, "Disable hostname prefix") |
|||
|
|||
flag.BoolVar(&showVersion, "v", false, "Print version") |
|||
flag.BoolVar(&showVersion, "version", false, "Print version") |
|||
flag.BoolVar(&showHelp, "h", false, "Show help") |
|||
flag.BoolVar(&showHelp, "help", false, "Show help") |
|||
} |
|||
|
|||
func networkUsage(conf *stup.Stupfile) { |
|||
w := &tabwriter.Writer{} |
|||
w.Init(os.Stderr, 4, 4, 2, ' ', 0) |
|||
defer w.Flush() |
|||
|
|||
// Print available networks/hosts.
|
|||
fmt.Fprintln(w, "Networks:\t") |
|||
for _, name := range conf.Networks.Names { |
|||
fmt.Fprintf(w, "- %v\n", name) |
|||
network, _ := conf.Networks.Get(name) |
|||
for _, host := range network.Hosts { |
|||
fmt.Fprintf(w, "\t- %v\n", host) |
|||
} |
|||
} |
|||
fmt.Fprintln(w) |
|||
} |
|||
|
|||
func cmdUsage(conf *stup.Stupfile) { |
|||
w := &tabwriter.Writer{} |
|||
w.Init(os.Stderr, 4, 4, 2, ' ', 0) |
|||
defer w.Flush() |
|||
|
|||
// Print available targets/commands.
|
|||
fmt.Fprintln(w, "Targets:\t") |
|||
for _, name := range conf.Targets.Names { |
|||
cmds, _ := conf.Targets.Get(name) |
|||
fmt.Fprintf(w, "- %v\t%v\n", name, strings.Join(cmds, " ")) |
|||
} |
|||
fmt.Fprintln(w, "\t") |
|||
fmt.Fprintln(w, "Commands:\t") |
|||
for _, name := range conf.Commands.Names { |
|||
cmd, _ := conf.Commands.Get(name) |
|||
fmt.Fprintf(w, "- %v\t%v\n", name, cmd.Desc) |
|||
} |
|||
fmt.Fprintln(w) |
|||
} |
|||
|
|||
// parseArgs parses args and returns network and commands to be run.
|
|||
// On error, it prints usage and exits.
|
|||
func parseArgs(conf *stup.Stupfile) (*stup.Network, []*stup.Command, error) { |
|||
var commands []*stup.Command |
|||
|
|||
args := flag.Args() |
|||
if len(args) < 1 { |
|||
networkUsage(conf) |
|||
return nil, nil, ErrUsage |
|||
} |
|||
|
|||
// Does the <network> exist?
|
|||
network, ok := conf.Networks.Get(args[0]) |
|||
if !ok { |
|||
networkUsage(conf) |
|||
return nil, nil, ErrUnknownNetwork |
|||
} |
|||
|
|||
// Parse CLI --env flag env vars, override values defined in Network env.
|
|||
for _, env := range envVars { |
|||
if len(env) == 0 { |
|||
continue |
|||
} |
|||
i := strings.Index(env, "=") |
|||
if i < 0 { |
|||
if len(env) > 0 { |
|||
network.Env.Set(env, "") |
|||
} |
|||
continue |
|||
} |
|||
network.Env.Set(env[:i], env[i+1:]) |
|||
} |
|||
|
|||
hosts, err := network.ParseInventory() |
|||
if err != nil { |
|||
return nil, nil, err |
|||
} |
|||
network.Hosts = append(network.Hosts, hosts...) |
|||
|
|||
// Does the <network> have at least one host?
|
|||
if len(network.Hosts) == 0 { |
|||
networkUsage(conf) |
|||
return nil, nil, ErrNetworkNoHosts |
|||
} |
|||
|
|||
// Check for the second argument
|
|||
if len(args) < 2 { |
|||
cmdUsage(conf) |
|||
return nil, nil, ErrUsage |
|||
} |
|||
|
|||
// In case of the network.Env needs an initialization
|
|||
if network.Env == nil { |
|||
network.Env = make(stup.EnvList, 0) |
|||
} |
|||
|
|||
// Add default env variable with current network
|
|||
network.Env.Set("SUP_NETWORK", args[0]) |
|||
|
|||
// Add default nonce
|
|||
network.Env.Set("SUP_TIME", time.Now().UTC().Format(time.RFC3339)) |
|||
if os.Getenv("SUP_TIME") != "" { |
|||
network.Env.Set("SUP_TIME", os.Getenv("SUP_TIME")) |
|||
} |
|||
|
|||
// Add user
|
|||
if os.Getenv("SUP_USER") != "" { |
|||
network.Env.Set("SUP_USER", os.Getenv("SUP_USER")) |
|||
} else { |
|||
network.Env.Set("SUP_USER", os.Getenv("USER")) |
|||
} |
|||
|
|||
for _, cmd := range args[1:] { |
|||
// Target?
|
|||
target, isTarget := conf.Targets.Get(cmd) |
|||
if isTarget { |
|||
// Loop over target's commands.
|
|||
for _, cmd := range target { |
|||
command, isCommand := conf.Commands.Get(cmd) |
|||
if !isCommand { |
|||
cmdUsage(conf) |
|||
return nil, nil, fmt.Errorf("%v: %v", ErrCmd, cmd) |
|||
} |
|||
command.Name = cmd |
|||
commands = append(commands, &command) |
|||
} |
|||
} |
|||
|
|||
// Command?
|
|||
command, isCommand := conf.Commands.Get(cmd) |
|||
if isCommand { |
|||
command.Name = cmd |
|||
commands = append(commands, &command) |
|||
} |
|||
|
|||
if !isTarget && !isCommand { |
|||
cmdUsage(conf) |
|||
return nil, nil, fmt.Errorf("%v: %v", ErrCmd, cmd) |
|||
} |
|||
} |
|||
|
|||
return &network, commands, nil |
|||
} |
|||
|
|||
func resolvePath(path string) string { |
|||
if path == "" { |
|||
return "" |
|||
} |
|||
if path[:2] == "~/" { |
|||
usr, err := user.Current() |
|||
if err == nil { |
|||
path = filepath.Join(usr.HomeDir, path[2:]) |
|||
} |
|||
} |
|||
return path |
|||
} |
|||
|
|||
func main() { |
|||
flag.Parse() |
|||
|
|||
if showHelp { |
|||
fmt.Fprintln(os.Stderr, ErrUsage, "\n\nOptions:") |
|||
flag.PrintDefaults() |
|||
return |
|||
} |
|||
|
|||
if showVersion { |
|||
fmt.Fprintln(os.Stderr, stup.VERSION) |
|||
return |
|||
} |
|||
|
|||
if stupfile == "" { |
|||
stupfile = "./Stupfile" |
|||
} |
|||
data, err := ioutil.ReadFile(resolvePath(stupfile)) |
|||
if err != nil { |
|||
firstErr := err |
|||
data, err = ioutil.ReadFile("./Stupfile.yml") // Alternative to ./Stupfile.
|
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, firstErr) |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
} |
|||
conf, err := stup.NewStupfile(data) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
// Parse network and commands to be run from args.
|
|||
network, commands, err := parseArgs(conf) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
// --only flag filters hosts
|
|||
if onlyHosts != "" { |
|||
expr, err := regexp.CompilePOSIX(onlyHosts) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
var hosts []stup.Instance |
|||
for _, host := range network.Hosts { |
|||
if expr.MatchString(host.Address) { |
|||
hosts = append(hosts, host) |
|||
} |
|||
} |
|||
if len(hosts) == 0 { |
|||
fmt.Fprintln(os.Stderr, fmt.Errorf("no hosts match --only '%v' regexp", onlyHosts)) |
|||
os.Exit(1) |
|||
} |
|||
network.Hosts = hosts |
|||
} |
|||
|
|||
// --except flag filters out hosts
|
|||
if exceptHosts != "" { |
|||
expr, err := regexp.CompilePOSIX(exceptHosts) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
var hosts []stup.Instance |
|||
for _, host := range network.Hosts { |
|||
if !expr.MatchString(host.Address) { |
|||
hosts = append(hosts, host) |
|||
} |
|||
} |
|||
if len(hosts) == 0 { |
|||
fmt.Fprintln(os.Stderr, fmt.Errorf("no hosts left after --except '%v' regexp", onlyHosts)) |
|||
os.Exit(1) |
|||
} |
|||
network.Hosts = hosts |
|||
} |
|||
|
|||
// --sshconfig flag location for ssh_config file
|
|||
if sshConfig != "" { |
|||
confHosts, err := sshconfig.ParseSSHConfig(resolvePath(sshConfig)) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
// flatten Host -> *SSHHost, not the prettiest
|
|||
// but will do
|
|||
confMap := map[string]*sshconfig.SSHHost{} |
|||
for _, conf := range confHosts { |
|||
for _, host := range conf.Host { |
|||
confMap[host] = conf |
|||
} |
|||
} |
|||
|
|||
var ident []byte |
|||
// check network.Hosts for match
|
|||
for n, host := range network.Hosts { |
|||
conf, found := confMap[host.Address] |
|||
if found { |
|||
ident, err = ioutil.ReadFile(resolvePath(conf.IdentityFile)) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
network.Hosts[n].User = conf.User |
|||
network.Hosts[n].Identity = string(ident) |
|||
network.Hosts[n].Address = fmt.Sprintf("%s:%d", conf.HostName, conf.Port) |
|||
} |
|||
} |
|||
} |
|||
|
|||
var vars stup.EnvList |
|||
for _, val := range append(conf.Env, network.Env...) { |
|||
vars.Set(val.Key, val.Value) |
|||
} |
|||
if err := vars.ResolveValues(); err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
// Parse CLI --env flag env vars, define $SUP_ENV and override values defined in Supfile.
|
|||
var cliVars stup.EnvList |
|||
for _, env := range envVars { |
|||
if len(env) == 0 { |
|||
continue |
|||
} |
|||
i := strings.Index(env, "=") |
|||
if i < 0 { |
|||
if len(env) > 0 { |
|||
vars.Set(env, "") |
|||
} |
|||
continue |
|||
} |
|||
vars.Set(env[:i], env[i+1:]) |
|||
cliVars.Set(env[:i], env[i+1:]) |
|||
} |
|||
|
|||
// SUP_ENV is generated only from CLI env vars.
|
|||
// Separate loop to omit duplicates.
|
|||
supEnv := "" |
|||
for _, v := range cliVars { |
|||
supEnv += fmt.Sprintf(" -e %v=%q", v.Key, v.Value) |
|||
} |
|||
vars.Set("SUP_ENV", strings.TrimSpace(supEnv)) |
|||
|
|||
// Create new Stackup app.
|
|||
app, err := stup.New(conf) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
app.Debug(debug) |
|||
app.Prefix(!disablePrefix) |
|||
|
|||
// Run all the commands in the given network.
|
|||
err = app.Run(network, vars, commands...) |
|||
if err != nil { |
|||
fmt.Fprintln(os.Stderr, err) |
|||
os.Exit(1) |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
package stup |
|||
|
|||
var ( |
|||
Colors = []string{ |
|||
"\033[32m", // green
|
|||
"\033[33m", // yellow
|
|||
"\033[36m", // cyan
|
|||
"\033[35m", // magenta
|
|||
"\033[31m", // red
|
|||
"\033[34m", // blue
|
|||
} |
|||
ResetColor = "\033[0m" |
|||
) |
@ -0,0 +1,78 @@ |
|||
# vSSH |
|||
|
|||
Go library to handle tens of thousands SSH connections and execute the command(s) with higher-level API for building network device / server automation. |
|||
|
|||
## Features |
|||
- Connect to multiple remote machines concurrently |
|||
- Persistent SSH connection |
|||
- DSL query based on the labels |
|||
- Manage number of sessions per SSH connection |
|||
- Limit amount of stdout and stderr data in bytes |
|||
- Higher-level API for building automation |
|||
|
|||
### Sample query with label |
|||
```go |
|||
labels := map[string]string { |
|||
"POP" : "LAX", |
|||
"OS" : "JUNOS", |
|||
} |
|||
// sets labels to a client |
|||
vs.AddClient(addr, config, vssh.SetLabels(labels)) |
|||
// query with label |
|||
vs.RunWithLabel(ctx, cmd, timeout, "(POP == LAX || POP == DCA) && OS == JUNOS") |
|||
``` |
|||
|
|||
### Basic example |
|||
```go |
|||
vs := vssh.New().Start() |
|||
config := vssh.GetConfigUserPass("vssh", "vssh") |
|||
for _, addr := range []string{"54.193.17.197:22", "192.168.2.19:22"} { |
|||
vs.AddClient(addr, config, vssh.SetMaxSessions(4)) |
|||
} |
|||
vs.Wait() |
|||
|
|||
ctx, cancel := context.WithCancel(context.Background()) |
|||
defer cancel() |
|||
|
|||
cmd:= "ping -c 4 192.168.55.10" |
|||
timeout, _ := time.ParseDuration("6s") |
|||
respChan := vs.Run(ctx, cmd, timeout) |
|||
|
|||
for resp := range respChan { |
|||
if err := resp.Err(); err != nil { |
|||
log.Println(err) |
|||
continue |
|||
} |
|||
|
|||
outTxt, errTxt, _ := resp.GetText(vs) |
|||
fmt.Println(outTxt, errTxt, resp.ExitStatus()) |
|||
} |
|||
``` |
|||
|
|||
### Stream example |
|||
```go |
|||
vs := vssh.New().Start() |
|||
config, _ := vssh.GetConfigPEM("vssh", "mypem.pem") |
|||
vs.AddClient("54.193.17.197:22", config, vssh.SetMaxSessions(4)) |
|||
vs.Wait() |
|||
|
|||
ctx, cancel := context.WithCancel(context.Background()) |
|||
defer cancel() |
|||
|
|||
cmd:= "ping -c 4 192.168.55.10" |
|||
timeout, _ := time.ParseDuration("6s") |
|||
respChan := vs.Run(ctx, cmd, timeout) |
|||
|
|||
resp := <- respChan |
|||
if err := resp.Err(); err != nil { |
|||
log.Fatal(err) |
|||
} |
|||
|
|||
stream := resp.GetStream() |
|||
defer stream.Close() |
|||
|
|||
for stream.ScanStdout() { |
|||
txt := stream.TextStdout() |
|||
fmt.Println(txt) |
|||
} |
|||
``` |
@ -0,0 +1,7 @@ |
|||
FROM ubuntu:20.04 |
|||
|
|||
COPY ./example /usr/bin/ |
|||
|
|||
EXPOSE 8000 |
|||
|
|||
CMD ["/usr/bin/example"] |
@ -0,0 +1,149 @@ |
|||
# Supfile for "Example" Docker service |
|||
--- |
|||
version: 0.4 |
|||
|
|||
env: |
|||
# Environment variables for all commands |
|||
NAME: example |
|||
REPO: github.com/pressly/sup |
|||
BRANCH: master |
|||
IMAGE: pressly/example |
|||
HOST_PORT: 8000 |
|||
CONTAINER_PORT: 8000 |
|||
|
|||
networks: |
|||
# Groups of hosts |
|||
local: |
|||
hosts: |
|||
- localhost |
|||
|
|||
dev: |
|||
env: |
|||
# Extra environment variable for dev hosts only |
|||
DOCKER_HOST: tcp://127.0.0.1:2375 |
|||
hosts: |
|||
- docker@192.168.59.103 |
|||
|
|||
stg: |
|||
hosts: |
|||
- ubuntu@stg.example.com |
|||
|
|||
prod: |
|||
inventory: for i in 1 2 3 4; do echo "ubuntu@prod$i.example.com"; done |
|||
|
|||
k8s: |
|||
inventory: for i in $(kubectl get nodes -o jsonpath={.items[*].status.addresses[?\(@.type==\"InternalIP\"\)].address}); do echo "ubuntu@$i"; done |
|||
|
|||
commands: |
|||
# Named set of commands to be run remotely |
|||
ping: |
|||
desc: Print uname and current date/time. |
|||
run: uname -a; date |
|||
|
|||
pre-build: |
|||
desc: Initialize directory |
|||
run: mkdir -p /tmp/$IMAGE |
|||
|
|||
mytest: |
|||
run: echo $SUP_TIME |
|||
|
|||
build: |
|||
desc: Build Docker image from current directory, push to Docker Hub |
|||
# local: sup $SUP_ENV -f ./builder/Supfile $SUP_NETWORK build |
|||
upload: |
|||
- src: ./ |
|||
dst: /tmp/$IMAGE |
|||
script: ./scripts/docker-build.sh |
|||
once: true |
|||
|
|||
pull: |
|||
desc: Pull latest Docker image |
|||
run: sudo docker pull $IMAGE |
|||
|
|||
config: |
|||
desc: Upload/test config file. |
|||
upload: |
|||
- src: ./example.$SUP_NETWORK.cfg |
|||
dst: /tmp/ |
|||
run: test -f /tmp/example.$SUP_NETWORK.cfg |
|||
|
|||
stop: |
|||
desc: Stop Docker container |
|||
run: sudo docker stop $NAME || exit 0 |
|||
|
|||
rm: |
|||
desc: Remove Docker container |
|||
run: sudo docker rm $NAME || exit 0 |
|||
|
|||
start: |
|||
desc: Start a stopped Docker container |
|||
run: sudo docker start $NAME || exit 0 |
|||
|
|||
run: |
|||
desc: Run Docker container |
|||
run: > |
|||
sudo docker run -d \ |
|||
-p $HOST_PORT:$CONTAINER_PORT \ |
|||
-v /tmp/example.$SUP_NETWORK.cfg:/etc/example.cfg \ |
|||
--restart=always \ |
|||
--name $NAME $IMAGE |
|||
|
|||
restart: |
|||
desc: Restart Docker container |
|||
run: sudo docker restart $NAME || exit 0 |
|||
|
|||
stop-rm-run: |
|||
desc: Rolling update (stop & remove old Docker container, run new one) |
|||
run: > |
|||
sudo docker stop $NAME || :; \ |
|||
sudo docker rm $NAME || :; \ |
|||
sudo docker run -d \ |
|||
-p $HOST_PORT:$CONTAINER_PORT \ |
|||
-v /tmp/example.$SUP_NETWORK.cfg:/etc/example.cfg \ |
|||
--restart=always \ |
|||
--name $NAME $IMAGE |
|||
serial: 1 |
|||
|
|||
ps: |
|||
desc: List running Docker containers |
|||
run: sudo docker ps | grep $NAME |
|||
|
|||
logs: |
|||
desc: Docker logs |
|||
run: sudo docker logs $NAME |
|||
|
|||
tail-logs: |
|||
desc: Tail Docker logs |
|||
run: sudo docker logs --tail=20 -f $NAME |
|||
|
|||
health: |
|||
desc: Application health check |
|||
run: curl localhost:$HOST_PORT |
|||
|
|||
slack-notify: |
|||
desc: Notify Slack about new deployment |
|||
local: > |
|||
curl -X POST --data-urlencode 'payload={"channel": "#_team_", "text": "['$SUP_NETWORK'] '$SUP_USER' deployed '$NAME'"}' \ |
|||
https://hooks.slack.com/services/X/Y/Z |
|||
|
|||
bash: |
|||
desc: Interactive shell on all hosts |
|||
stdin: true |
|||
run: bash |
|||
|
|||
exec: |
|||
desc: Interactive docker exec on all hosts |
|||
stdin: true |
|||
run: sudo docker exec -i $NAME bash |
|||
|
|||
targets: # Aliases to run multiple commands at once |
|||
deploy: |
|||
- pre-build |
|||
- build |
|||
- pull |
|||
- config |
|||
- stop-rm-run |
|||
- ps |
|||
- logs |
|||
- health |
|||
- slack-notify |
@ -0,0 +1 @@ |
|||
development |
@ -0,0 +1,25 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"io/ioutil" |
|||
"log" |
|||
"net/http" |
|||
) |
|||
|
|||
func main() { |
|||
config, err := ioutil.ReadFile("/etc/example.cfg") |
|||
if err != nil { |
|||
log.Fatal(err) |
|||
} |
|||
|
|||
// Define handler that returns "Hello $ENV"
|
|||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
|||
w.Write([]byte("Hello ")) |
|||
w.Write(config) |
|||
}) |
|||
|
|||
err = http.ListenAndServe(":8000", nil) |
|||
if err != nil { |
|||
log.Fatal(err) |
|||
} |
|||
} |
@ -0,0 +1 @@ |
|||
local |
@ -0,0 +1 @@ |
|||
production |
@ -0,0 +1 @@ |
|||
staging |
@ -0,0 +1,18 @@ |
|||
#!/bin/bash |
|||
set -e |
|||
|
|||
cd /tmp/$IMAGE |
|||
|
|||
# Cleanup. |
|||
sudo rm -rf bin |
|||
|
|||
# Bulder image. Build binaries (make dist) into bin/ dir. |
|||
sudo docker run --rm \ |
|||
-v $(pwd):/go/src/$REPO/$NAME \ |
|||
-w /go/src/$REPO/$NAME \ |
|||
golang:1.5.2 go build |
|||
|
|||
# Bake bin/* into the resulting image. |
|||
sudo docker build --no-cache -t $IMAGE . |
|||
|
|||
sudo docker push $IMAGE |
@ -0,0 +1,16 @@ |
|||
module stup |
|||
|
|||
go 1.18 |
|||
|
|||
require ( |
|||
github.com/goware/prefixer v0.0.0-20160118172347-395022866408 |
|||
github.com/mikkeloscar/sshconfig v0.1.1 |
|||
github.com/pkg/errors v0.9.1 |
|||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d |
|||
gopkg.in/yaml.v2 v2.4.0 |
|||
) |
|||
|
|||
require ( |
|||
github.com/mitchellh/go-homedir v1.1.0 // indirect |
|||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect |
|||
) |
@ -0,0 +1,17 @@ |
|||
github.com/goware/prefixer v0.0.0-20160118172347-395022866408 h1:Y9iQJfEqnN3/Nce9cOegemcy/9Ai5k3huT6E80F3zaw= |
|||
github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukgRPJ7bJ9a1fdfQ9j8i/cEcRAoLZzbxYpNB/s= |
|||
github.com/mikkeloscar/sshconfig v0.1.1 h1:WJLz/y4M0jMkYHDJkydcbOb/S8UAJ1denM9fCpwKV5c= |
|||
github.com/mikkeloscar/sshconfig v0.1.1/go.mod h1:NavXZq+n9+iOgFT6fOobpl6nFBltLYOIjejTwNQTK7A= |
|||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= |
|||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= |
|||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= |
|||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= |
|||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= |
|||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= |
|||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= |
|||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= |
|||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= |
@ -0,0 +1,119 @@ |
|||
package stup |
|||
|
|||
import ( |
|||
"fmt" |
|||
"io" |
|||
"os" |
|||
"os/exec" |
|||
"os/user" |
|||
|
|||
"github.com/pkg/errors" |
|||
) |
|||
|
|||
// Client is a wrapper over the SSH connection/sessions.
|
|||
type LocalhostClient struct { |
|||
cmd *exec.Cmd |
|||
user string |
|||
stdin io.WriteCloser |
|||
stdout io.Reader |
|||
stderr io.Reader |
|||
running bool |
|||
env string //export FOO="bar"; export BAR="baz";
|
|||
} |
|||
|
|||
func (c *LocalhostClient) Connect(_ string) error { |
|||
u, err := user.Current() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
c.user = u.Username |
|||
return nil |
|||
} |
|||
|
|||
func (c *LocalhostClient) Run(task *Task) error { |
|||
var err error |
|||
|
|||
if c.running { |
|||
return fmt.Errorf("Command already running") |
|||
} |
|||
|
|||
cmd := exec.Command("bash", "-c", c.env+task.Run) |
|||
c.cmd = cmd |
|||
|
|||
c.stdout, err = cmd.StdoutPipe() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
c.stderr, err = cmd.StderrPipe() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
c.stdin, err = cmd.StdinPipe() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
if err := c.cmd.Start(); err != nil { |
|||
return ErrTask{task, err.Error()} |
|||
} |
|||
|
|||
c.running = true |
|||
return nil |
|||
} |
|||
|
|||
func (c *LocalhostClient) Wait() error { |
|||
if !c.running { |
|||
return fmt.Errorf("trying to wait on stopped command") |
|||
} |
|||
err := c.cmd.Wait() |
|||
c.running = false |
|||
return err |
|||
} |
|||
|
|||
func (c *LocalhostClient) Close() error { |
|||
return nil |
|||
} |
|||
|
|||
func (c *LocalhostClient) Stdin() io.WriteCloser { |
|||
return c.stdin |
|||
} |
|||
|
|||
func (c *LocalhostClient) Stderr() io.Reader { |
|||
return c.stderr |
|||
} |
|||
|
|||
func (c *LocalhostClient) Stdout() io.Reader { |
|||
return c.stdout |
|||
} |
|||
|
|||
func (c *LocalhostClient) Prefix() (string, int) { |
|||
host := c.user + "@localhost" + " | " |
|||
return ResetColor + host, len(host) |
|||
} |
|||
|
|||
func (c *LocalhostClient) Write(p []byte) (n int, err error) { |
|||
return c.stdin.Write(p) |
|||
} |
|||
|
|||
func (c *LocalhostClient) WriteClose() error { |
|||
return c.stdin.Close() |
|||
} |
|||
|
|||
func (c *LocalhostClient) Signal(sig os.Signal) error { |
|||
return c.cmd.Process.Signal(sig) |
|||
} |
|||
|
|||
func ResolveLocalPath(cwd, path, env string) (string, error) { |
|||
// Check if file exists first. Use bash to resolve $ENV_VARs.
|
|||
cmd := exec.Command("bash", "-c", env+"echo -n "+path) |
|||
cmd.Dir = cwd |
|||
resolvedFilename, err := cmd.Output() |
|||
if err != nil { |
|||
return "", errors.Wrap(err, "resolving path failed") |
|||
} |
|||
|
|||
return string(resolvedFilename), nil |
|||
} |
@ -0,0 +1,142 @@ |
|||
package stup |
|||
|
|||
import ( |
|||
"bytes" |
|||
"fmt" |
|||
"io" |
|||
"os" |
|||
"strings" |
|||
"sync" |
|||
|
|||
"github.com/pkg/errors" |
|||
"golang.org/x/crypto/ssh" |
|||
) |
|||
|
|||
type ( |
|||
SSHExecutor struct { |
|||
session *ssh.Session |
|||
sudopass string |
|||
} |
|||
) |
|||
|
|||
var ( |
|||
mtx = &sync.Mutex{} |
|||
sudoFunc = func(sudoerPassword string, in io.Writer, output *bytes.Buffer, endChan chan struct{}) { |
|||
for { |
|||
select { |
|||
case <-endChan: |
|||
|
|||
default: |
|||
//TODO: Refactor it
|
|||
mtx.Lock() |
|||
if output.Len() > 0 { |
|||
msg := output.String() |
|||
if strings.Contains(msg, "[sudo] ") { |
|||
_, err := in.Write([]byte(sudoerPassword + "\n")) |
|||
if err != nil && err != io.EOF { |
|||
fmt.Fprintf(os.Stderr, "%v", errors.Wrap(err, "some went wrong when trying remote sudo")) |
|||
} |
|||
} |
|||
} |
|||
mtx.Unlock() |
|||
} |
|||
} |
|||
} |
|||
|
|||
execFunc = func(session *ssh.Session, command string) { |
|||
err := session.Run(command) |
|||
if err != nil { |
|||
fmt.Fprintf(os.Stderr, "%v", errors.Wrap(err, "some went wrong when trying execute remote command")) |
|||
} |
|||
} |
|||
) |
|||
|
|||
func NewSSHExecutor(session *ssh.Session, password string) *SSHExecutor { |
|||
return &SSHExecutor{session: session, sudopass: password} |
|||
} |
|||
|
|||
func (s *SSHExecutor) PrepareStreams(rsOut, rsErr io.Writer) (stdout io.Reader, stderr io.Reader, err error) { |
|||
if rsOut != nil { |
|||
stdout, err = s.session.StdoutPipe() |
|||
if err != nil { |
|||
return nil, nil, fmt.Errorf("unable to setup stdout for session: %v", err) |
|||
} |
|||
go io.Copy(rsOut, stdout) |
|||
} |
|||
|
|||
if rsErr != nil { |
|||
stderr, err = s.session.StderrPipe() |
|||
if err != nil { |
|||
return nil, nil, fmt.Errorf("unable to setup stderr for session: %v", err) |
|||
} |
|||
go io.Copy(rsErr, stderr) |
|||
} |
|||
|
|||
return |
|||
} |
|||
|
|||
func (s *SSHExecutor) Execute(command string) error { |
|||
var err error |
|||
|
|||
modes := ssh.TerminalModes{ |
|||
ssh.ECHO: 0, // disable echoing
|
|||
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
|
|||
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
|
|||
} |
|||
|
|||
err = s.session.RequestPty("xterm", 80, 40, modes) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
// Capture stdout and stderr from remote server
|
|||
var rsOut bytes.Buffer |
|||
var rsErr bytes.Buffer |
|||
|
|||
stdOut, stdErr, err := s.PrepareStreams(&rsOut, &rsErr) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
// Set stdin to provide sudo passwd if it's necessary
|
|||
stdIn, _ := s.session.StdinPipe() |
|||
|
|||
endChan := make(chan struct{}) // sudo func ending chan
|
|||
go sudoFunc(s.sudopass, stdIn, &rsOut, endChan) |
|||
|
|||
// Execute the remote command
|
|||
execFunc(s.session, command) |
|||
err = waitForReaderEmpty(stdOut, &rsOut) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = waitForReaderEmpty(stdErr, &rsErr) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
close(endChan) |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func waitForReaderEmpty(reader io.Reader, buff *bytes.Buffer) error { |
|||
var ( |
|||
b = make([]byte, 1024) |
|||
) |
|||
|
|||
for { |
|||
mtx.Lock() |
|||
n, err := reader.Read(b) |
|||
mtx.Unlock() |
|||
if n == 0 || err == io.EOF { |
|||
break |
|||
} |
|||
if err != nil { |
|||
return err |
|||
} |
|||
buff.Write(b[:n]) |
|||
} |
|||
return nil |
|||
} |
@ -0,0 +1,301 @@ |
|||
package stup |
|||
|
|||
import ( |
|||
"fmt" |
|||
"io" |
|||
"io/ioutil" |
|||
"net" |
|||
"os" |
|||
"os/user" |
|||
"path/filepath" |
|||
"strings" |
|||
"sync" |
|||
|
|||
"golang.org/x/crypto/ssh" |
|||
"golang.org/x/crypto/ssh/agent" |
|||
) |
|||
|
|||
// Client is a wrapper over the SSH connection/sessions.
|
|||
type SSHClient struct { |
|||
conn *ssh.Client |
|||
sess *ssh.Session |
|||
user string |
|||
host string |
|||
pass string |
|||
remoteStdin io.WriteCloser |
|||
remoteStdout io.Reader |
|||
remoteStderr io.Reader |
|||
connOpened bool |
|||
sessOpened bool |
|||
running bool |
|||
env string //export FOO="bar"; export BAR="baz";
|
|||
color string |
|||
} |
|||
|
|||
type ErrConnect struct { |
|||
User string |
|||
Host string |
|||
Reason string |
|||
} |
|||
|
|||
func (e ErrConnect) Error() string { |
|||
return fmt.Sprintf(`Connect("%v@%v"): %v`, e.User, e.Host, e.Reason) |
|||
} |
|||
|
|||
// parseHost parses and normalizes <user>@<host:port> from a given string.
|
|||
func (c *SSHClient) parseHost(host string) error { |
|||
c.host = host |
|||
|
|||
// Remove extra "ssh://" schema
|
|||
if len(c.host) > 6 && c.host[:6] == "ssh://" { |
|||
c.host = c.host[6:] |
|||
} |
|||
|
|||
// Split by the last "@", since there may be an "@" in the username.
|
|||
if at := strings.LastIndex(c.host, "@"); at != -1 { |
|||
c.user = c.host[:at] |
|||
c.host = c.host[at+1:] |
|||
} |
|||
|
|||
// Add default user, if not set
|
|||
if c.user == "" { |
|||
u, err := user.Current() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
c.user = u.Username |
|||
} |
|||
|
|||
if strings.Contains(c.host, "/") { |
|||
return ErrConnect{c.user, c.host, "unexpected slash in the host URL"} |
|||
} |
|||
|
|||
// Add default port, if not set
|
|||
if !strings.Contains(c.host, ":") { |
|||
c.host += ":22" |
|||
} |
|||
|
|||
_, _, err := net.SplitHostPort(c.host) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
var initAuthMethodOnce sync.Once |
|||
var authMethod ssh.AuthMethod |
|||
|
|||
// initAuthMethod initiates SSH authentication method.
|
|||
func initAuthMethod() { |
|||
var signers []ssh.Signer |
|||
|
|||
// If there's a running SSH Agent, try to use its Private keys.
|
|||
sock, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) |
|||
if err == nil { |
|||
agent := agent.NewClient(sock) |
|||
signers, _ = agent.Signers() |
|||
} |
|||
|
|||
// Try to read user's SSH private keys form the standard paths.
|
|||
files, _ := filepath.Glob(os.Getenv("HOME") + "/.ssh/id_*") |
|||
for _, file := range files { |
|||
if strings.HasSuffix(file, ".pub") { |
|||
continue // Skip public keys.
|
|||
} |
|||
data, err := ioutil.ReadFile(file) |
|||
if err != nil { |
|||
continue |
|||
} |
|||
signer, err := ssh.ParsePrivateKey(data) |
|||
if err != nil { |
|||
continue |
|||
} |
|||
signers = append(signers, signer) |
|||
|
|||
} |
|||
authMethod = ssh.PublicKeys(signers...) |
|||
} |
|||
|
|||
// SSHDialFunc can dial an ssh server and return a client
|
|||
type SSHDialFunc func(net, addr string, config *ssh.ClientConfig) (*ssh.Client, error) |
|||
|
|||
// Connect creates SSH connection to a specified host.
|
|||
// It expects the host of the form "[ssh://]host[:port]".
|
|||
func (c *SSHClient) Connect(host string) error { |
|||
return c.ConnectWith(host, ssh.Dial) |
|||
} |
|||
|
|||
// ConnectWith creates a SSH connection to a specified host. It will use dialer to establish the
|
|||
// connection.
|
|||
// TODO: Split Signers to its own method.
|
|||
func (c *SSHClient) ConnectWith(host string, dialer SSHDialFunc) error { |
|||
if c.connOpened { |
|||
return fmt.Errorf("already connected") |
|||
} |
|||
|
|||
initAuthMethodOnce.Do(initAuthMethod) |
|||
|
|||
err := c.parseHost(host) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
if c.pass != "" { |
|||
authMethod = ssh.Password(c.pass) |
|||
} |
|||
|
|||
config := &ssh.ClientConfig{ |
|||
User: c.user, |
|||
Auth: []ssh.AuthMethod{ |
|||
authMethod, |
|||
}, |
|||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), |
|||
} |
|||
|
|||
c.conn, err = dialer("tcp", c.host, config) |
|||
if err != nil { |
|||
return ErrConnect{c.user, c.host, err.Error()} |
|||
} |
|||
c.connOpened = true |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// Run runs the task.Run command remotely on c.host.
|
|||
func (c *SSHClient) Run(task *Task) error { |
|||
if c.running { |
|||
return fmt.Errorf("session already running") |
|||
} |
|||
if c.sessOpened { |
|||
return fmt.Errorf("session already connected") |
|||
} |
|||
|
|||
sess, err := c.conn.NewSession() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
c.remoteStdin, err = sess.StdinPipe() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
c.remoteStdout, err = sess.StdoutPipe() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
c.remoteStderr, err = sess.StderrPipe() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
if task.TTY { |
|||
// Set up terminal modes
|
|||
modes := ssh.TerminalModes{ |
|||
ssh.ECHO: 0, // disable echoing
|
|||
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
|
|||
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
|
|||
} |
|||
// Request pseudo terminal
|
|||
if err := sess.RequestPty("xterm", 80, 40, modes); err != nil { |
|||
return ErrTask{task, fmt.Sprintf("request for pseudo terminal failed: %s", err)} |
|||
} |
|||
} |
|||
|
|||
err = NewSSHExecutor(sess, c.pass).Execute(c.env + task.Run) |
|||
if err != nil { |
|||
return ErrTask{task, err.Error()} |
|||
} |
|||
|
|||
c.sess = sess |
|||
c.sessOpened = true |
|||
c.running = true |
|||
return nil |
|||
} |
|||
|
|||
// Wait waits until the remote command finishes and exits.
|
|||
// It closes the SSH session.
|
|||
func (c *SSHClient) Wait() error { |
|||
if !c.running { |
|||
return fmt.Errorf("trying to wait on stopped session") |
|||
} |
|||
|
|||
err := c.sess.Wait() |
|||
c.sess.Close() |
|||
c.running = false |
|||
c.sessOpened = false |
|||
|
|||
return err |
|||
} |
|||
|
|||
// DialThrough will create a new connection from the ssh server sc is connected to. DialThrough is an SSHDialer.
|
|||
func (sc *SSHClient) DialThrough(net, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { |
|||
conn, err := sc.conn.Dial(net, addr) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
return ssh.NewClient(c, chans, reqs), nil |
|||
|
|||
} |
|||
|
|||
// Close closes the underlying SSH connection and session.
|
|||
func (c *SSHClient) Close() error { |
|||
if c.sessOpened { |
|||
c.sess.Close() |
|||
c.sessOpened = false |
|||
} |
|||
if !c.connOpened { |
|||
return fmt.Errorf("trying to close the already closed connection") |
|||
} |
|||
|
|||
err := c.conn.Close() |
|||
c.connOpened = false |
|||
c.running = false |
|||
|
|||
return err |
|||
} |
|||
|
|||
func (c *SSHClient) Stdin() io.WriteCloser { |
|||
return c.remoteStdin |
|||
} |
|||
|
|||
func (c *SSHClient) Stderr() io.Reader { |
|||
return c.remoteStderr |
|||
} |
|||
|
|||
func (c *SSHClient) Stdout() io.Reader { |
|||
return c.remoteStdout |
|||
} |
|||
|
|||
func (c *SSHClient) Prefix() (string, int) { |
|||
host := c.user + "@" + c.host + " | " |
|||
return c.color + host + ResetColor, len(host) |
|||
} |
|||
|
|||
func (c *SSHClient) Write(p []byte) (n int, err error) { |
|||
return c.remoteStdin.Write(p) |
|||
} |
|||
|
|||
func (c *SSHClient) WriteClose() error { |
|||
return c.remoteStdin.Close() |
|||
} |
|||
|
|||
func (c *SSHClient) Signal(sig os.Signal) error { |
|||
if !c.sessOpened { |
|||
return fmt.Errorf("session is not open") |
|||
} |
|||
|
|||
switch sig { |
|||
case os.Interrupt: |
|||
c.remoteStdin.Write([]byte("\x03")) |
|||
return c.sess.Signal(ssh.SIGINT) |
|||
default: |
|||
return fmt.Errorf("%v not supported", sig) |
|||
} |
|||
} |