Browse Source

init v1 version

master
小红帽 4 weeks ago
commit
6e4cfc3b38
  1. 3
      .gitignore
  2. 22
      LICENSE
  3. 42
      Makefile
  4. 326
      README.md
  5. 20
      client.go
  6. 391
      cmd/stup/main.go
  7. 13
      colors.go
  8. 78
      docs/vssh.md
  9. 7
      example/Dockerfile
  10. 149
      example/Supfile
  11. 1
      example/example.dev.cfg
  12. 25
      example/example.go
  13. 1
      example/example.local.cfg
  14. 1
      example/example.prod.cfg
  15. 1
      example/example.stg.cfg
  16. 18
      example/scripts/docker-build.sh
  17. 16
      go.mod
  18. 17
      go.sum
  19. 119
      localclient.go
  20. 142
      ssh_executor.go
  21. 301
      sshclient.go
  22. 260
      stup.go
  23. 329
      stupfile.go
  24. 53
      tar.go
  25. 168
      task.go
  26. 586
      vssh/client.go
  27. 68
      vssh/client_mem.go
  28. 724
      vssh/client_test.go
  29. 61
      vssh/example_stream_test.go
  30. 41
      vssh/helper.go
  31. 221
      vssh/query.go
  32. 87
      vssh/query_test.go
  33. 458
      vssh/vssh.go
  34. 142
      vssh/vssh_test.go
  35. 1
      vsshclient.go

3
.gitignore

@ -0,0 +1,3 @@
bin/
*.sw?
*.exe

22
LICENSE

@ -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.

42
Makefile

@ -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

326
README.md

@ -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).

20
client.go

@ -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
}

391
cmd/stup/main.go

@ -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)
}
}

13
colors.go

@ -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"
)

78
docs/vssh.md

@ -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)
}
```

7
example/Dockerfile

@ -0,0 +1,7 @@
FROM ubuntu:20.04
COPY ./example /usr/bin/
EXPOSE 8000
CMD ["/usr/bin/example"]

149
example/Supfile

@ -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

1
example/example.dev.cfg

@ -0,0 +1 @@
development

25
example/example.go

@ -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)
}
}

1
example/example.local.cfg

@ -0,0 +1 @@
local

1
example/example.prod.cfg

@ -0,0 +1 @@
production

1
example/example.stg.cfg

@ -0,0 +1 @@
staging

18
example/scripts/docker-build.sh

@ -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

16
go.mod

@ -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
)

17
go.sum

@ -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=

119
localclient.go

@ -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
}

142
ssh_executor.go

@ -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
}

301
sshclient.go

@ -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)
}
}

260