From 2fb0bd3203f1376139f71e20a2ea0bc4ede1fdb2 Mon Sep 17 00:00:00 2001 From: Blaine Gardner Date: Sat, 15 Sep 2018 22:22:25 -0600 Subject: [PATCH] Minimum viable product Is able to take 2 flags: -command 'some command string' (mandatory) -identity-file Still to do: - read hosts from a file - flag to select which subset of hosts to run command on Signed-off-by: Blaine Gardner --- .gitignore | 1 + Gopkg.lock | 25 +++++++ Gopkg.toml | 30 ++++++++ cmd/octopus/octopus.go | 163 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 .gitignore create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 cmd/octopus/octopus.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48b8bf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..35bc787 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,25 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + digest = "1:b166c998eb589cd6da3aa24901f328e2d14cfe17cda0957129f880723ee9384b" + name = "golang.org/x/crypto" + packages = [ + "curve25519", + "ed25519", + "ed25519/internal/edwards25519", + "internal/chacha20", + "internal/subtle", + "poly1305", + "ssh", + ] + pruneopts = "UT" + revision = "0e37d006457bf46f9e6692014ba72ef82c33022c" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = ["golang.org/x/crypto/ssh"] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..d7072c2 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,30 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[prune] + go-tests = true + unused-packages = true diff --git a/cmd/octopus/octopus.go b/cmd/octopus/octopus.go new file mode 100644 index 0000000..228d91e --- /dev/null +++ b/cmd/octopus/octopus.go @@ -0,0 +1,163 @@ +// Package octopus is a commandline tool for running the same command on multiple remote hosts in +// parallel. +package main + +import ( + "bytes" + "flag" + "fmt" + "io/ioutil" + "log" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +const ( + noHostNameText = "! could not get hostname !" +) + +// Each of octopus's tentacles is a remote connection to a host executing the command +type tentacle struct { + host string + hostname string + stdout *bytes.Buffer + err error +} + +func main() { + identityFile := flag.String("identity-file", "~/.ssh/id_rsa", + "identity file used to authenticate to remote hosts") + command := flag.String("command", "", "(required) command to execute on remote hosts") + flag.Parse() + + if strings.Trim(*command, " \t") == "" { + fmt.Printf("ERROR! '-command' must be specified\n\n") + flag.PrintDefaults() + os.Exit(1) + } + + key, err := ioutil.ReadFile(*identityFile) + if err != nil { + log.Fatalf("unable to read private key: %v", err) + } + + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + log.Fatalf("unable to parse private key: %v", err) + } + + config := &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + hosts := []string{"10.86.1.87", "10.86.1.103"} + tentacles := make(chan tentacle, len(hosts)) + + for i := 0; i < len(hosts); i++ { + go runCommand(hosts[i], *command, config, tentacles) + } + + numErrors := 0 + + for range hosts { + t := <-tentacles + err := t.print() + if err != nil { + numErrors++ + } + } + + os.Exit(numErrors) +} + +func runCommand(host, command string, config *ssh.ClientConfig, out chan<- tentacle) { + t := tentacle{ + host: host, + hostname: "", + err: fmt.Errorf("run command failed"), + } + defer func() { out <- t }() + + client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", t.host), config) + if err != nil { + t.err = fmt.Errorf("%v: %v", t.err, err) + return + // log.Fatal("Failed to dial: ", err) + } + + hn := make(chan tentacle) + go getHostname(client, hn) + + session, err := client.NewSession() + if err != nil { + t.err = fmt.Errorf("%v: %v", t.err, err) + // log.Fatal("Failed to create session: ", err) + } + defer session.Close() + + t.stdout = new(bytes.Buffer) + stderr := new(bytes.Buffer) + session.Stdout = t.stdout + session.Stderr = stderr + err = session.Run(command) + + if err != nil { + t.err = fmt.Errorf("%v: %v\n\n%s\n\n%s", t.err, err, strings.TrimRight(stderr.String(), "\n"), "") + } else { + t.err = nil + } + + tn := <-hn + t.hostname = tn.hostname + return +} + +func getHostname(client *ssh.Client, out chan<- tentacle) { + defer close(out) + + t := tentacle{ + hostname: noHostNameText, + } + defer func() { out <- t }() + + session, err := client.NewSession() + if err != nil { + t.err = fmt.Errorf("%v: %v", t.err, err) + return + } + defer session.Close() + + t.stdout = new(bytes.Buffer) + stderr := new(bytes.Buffer) + session.Stdout = t.stdout + session.Stderr = stderr + err = session.Run("hostname") + if err == nil { + t.hostname = strings.TrimRight(t.stdout.String(), "\n") + } + + return +} + +func (t *tentacle) print() error { + fmt.Println("-----") + fmt.Println(t.hostname) + if t.hostname == noHostNameText { + fmt.Println(t.host) + } + fmt.Printf("-----\n\n") + o := strings.TrimRight(t.stdout.String(), "\n") + if o != "" { + fmt.Printf("%s\n\n", o) + } + if t.err != nil { + fmt.Printf("Error: %v", t.err) + } + return t.err +}