diff --git a/.gitignore b/.gitignore index 8269d0c..6837bb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ bin/ *.sw? +tmp +lab +dist +example diff --git a/cmd/sup/main.go b/cmd/sup/main.go index e1f35ee..f1f8732 100644 --- a/cmd/sup/main.go +++ b/cmd/sup/main.go @@ -5,8 +5,6 @@ import ( "fmt" "io/ioutil" "os" - "os/user" - "path/filepath" "regexp" "strings" "text/tabwriter" @@ -127,14 +125,14 @@ func parseArgs(conf *sup.Supfile) (*sup.Network, []*sup.Command, error) { if len(env) == 0 { continue } - i := strings.Index(env, "=") - if i < 0 { + before, after, ok0 := strings.Cut(env, "=") + if !ok0 { if len(env) > 0 { network.Env.Set(env, "") } continue } - network.Env.Set(env[:i], env[i+1:]) + network.Env.Set(before, after) } hosts, err := network.ParseInventory() @@ -208,19 +206,6 @@ func parseArgs(conf *sup.Supfile) (*sup.Network, []*sup.Command, error) { 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() @@ -238,7 +223,7 @@ func main() { if supfile == "" { supfile = "./Supfile" } - data, err := ioutil.ReadFile(resolvePath(supfile)) + data, err := os.ReadFile(sup.ResolvePath(supfile)) if err != nil { firstErr := err data, err = ioutil.ReadFile("./Supfile.yml") // Alternative to ./Supfile. @@ -305,7 +290,7 @@ func main() { // --sshconfig flag location for ssh_config file if sshConfig != "" { - confHosts, err := sshconfig.ParseSSHConfig(resolvePath(sshConfig)) + confHosts, err := sshconfig.Parse(sup.ResolvePath(sshConfig)) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -320,15 +305,19 @@ func main() { } } - // check network.Hosts for match + // check network.Hosts for match and expand them + var newHosts []string for _, host := range network.Hosts { conf, found := confMap[host] if found { + newHosts = append(newHosts, fmt.Sprintf("%s:%d", conf.HostName, conf.Port)) network.User = conf.User - network.IdentityFile = resolvePath(conf.IdentityFile) - network.Hosts = []string{fmt.Sprintf("%s:%d", conf.HostName, conf.Port)} + network.IdentityFile = sup.ResolvePath(conf.IdentityFile) + } else { + newHosts = append(newHosts, host) } } + network.Hosts = newHosts } var vars sup.EnvList @@ -346,24 +335,24 @@ func main() { if len(env) == 0 { continue } - i := strings.Index(env, "=") - if i < 0 { + before, after, ok := strings.Cut(env, "=") + if !ok { if len(env) > 0 { vars.Set(env, "") } continue } - vars.Set(env[:i], env[i+1:]) - cliVars.Set(env[:i], env[i+1:]) + vars.Set(before, after) + cliVars.Set(before, after) } // SUP_ENV is generated only from CLI env vars. // Separate loop to omit duplicates. - supEnv := "" + var supEnv strings.Builder for _, v := range cliVars { - supEnv += fmt.Sprintf(" -e %v=%q", v.Key, v.Value) + supEnv.WriteString(fmt.Sprintf(" -e %v=%q", v.Key, v.Value)) } - vars.Set("SUP_ENV", strings.TrimSpace(supEnv)) + vars.Set("SUP_ENV", strings.TrimSpace(supEnv.String())) // Create new Stackup app. app, err := sup.New(conf) diff --git a/fn.go b/fn.go new file mode 100644 index 0000000..449cc95 --- /dev/null +++ b/fn.go @@ -0,0 +1,19 @@ +package sup + +import ( + "os/user" + "path/filepath" +) + +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 +} diff --git a/go.mod b/go.mod index e1dce7e..297ff7b 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,26 @@ module github.com/pressly/sup -go 1.13 +go 1.24.0 require ( + github.com/cheggaaa/pb/v3 v3.1.7 github.com/goware/prefixer v0.0.0-20160118172347-395022866408 - github.com/kr/pretty v0.2.0 // indirect - github.com/mikkeloscar/sshconfig v0.0.0-20190102082740-ec0822bcc4f4 + github.com/mikkeloscar/sshconfig v0.1.1 github.com/pkg/errors v0.9.1 - golang.org/x/crypto v0.0.0-20200208060501-ecb85df21340 - golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect + golang.org/x/crypto v0.48.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/VividCortex/ewma v1.2.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/kr/pretty v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - gopkg.in/yaml.v2 v2.2.8 ) diff --git a/go.sum b/go.sum index eb3a23c..560c2d4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/cheggaaa/pb/v3 v3.1.7 h1:2FsIW307kt7A/rz/ZI2lvPO+v3wKazzE4K/0LtTWsOI= +github.com/cheggaaa/pb/v3 v3.1.7/go.mod h1:/Ji89zfVPeC/u5j8ukD0MBPHt2bzTYp74lQ7KlgFWTQ= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= @@ -5,30 +13,27 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mikkeloscar/sshconfig v0.0.0-20161223095632-fc5e37b16b68 h1:Z1BVWGqEm0aveMz9ffiFnJthFjM5+YFdFqFklQ/hPBI= -github.com/mikkeloscar/sshconfig v0.0.0-20161223095632-fc5e37b16b68/go.mod h1:GvQCIGDpivPr+e8cuBt3c4+NTOJm66zpBrMjkit8jmw= -github.com/mikkeloscar/sshconfig v0.0.0-20190102082740-ec0822bcc4f4 h1:6mjPKnEtYKqYTqIXAraugfl5bkaW+A6wJAupYKAWMXM= -github.com/mikkeloscar/sshconfig v0.0.0-20190102082740-ec0822bcc4f4/go.mod h1:GvQCIGDpivPr+e8cuBt3c4+NTOJm66zpBrMjkit8jmw= -github.com/pkg/errors v0.7.1-0.20160627222352-a2d6902c6d2a h1:dKpZ0nc8i7prliB4AIfJulQxsX7whlVwi6j5HqaYUl4= -github.com/pkg/errors v0.7.1-0.20160627222352-a2d6902c6d2a/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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-20160804082612-7a1054f3ac58 h1:ytej7jB0ejb21kF+TjEWykw7n4sG85mxyjgYHgF/7ZQ= -golang.org/x/crypto v0.0.0-20160804082612-7a1054f3ac58/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200208060501-ecb85df21340 h1:KOcEaR10tFr7gdJV2GCKw8Os5yED1u1aOqHjOAb6d2Y= -golang.org/x/crypto v0.0.0-20200208060501-ecb85df21340/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129 h1:RBgb9aPUbZ9nu66ecQNIBNsA7j3mB5h8PNDIfhPjaJg= -gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/ssh.go b/ssh.go index 8644fff..41f295e 100644 --- a/ssh.go +++ b/ssh.go @@ -1,9 +1,9 @@ package sup import ( + "errors" "fmt" "io" - "io/ioutil" "net" "os" "os/user" @@ -13,6 +13,7 @@ import ( "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/terminal" ) // Client is a wrapper over the SSH connection/sessions. @@ -29,6 +30,10 @@ type SSHClient struct { running bool env string //export FOO="bar"; export BAR="baz"; color string + + ask bool // For interactive "ask:root@..." + password string // For config "password: ..." + identityFile string } type ErrConnect struct { @@ -54,6 +59,15 @@ func (c *SSHClient) parseHost(host string) error { if at := strings.LastIndex(c.host, "@"); at != -1 { c.user = c.host[:at] c.host = c.host[at+1:] + + // Check if the username starts with "ask:" + c.ask = false + if after, ok := strings.CutPrefix(c.user, "ask:"); ok { + // Remove "ask:" from the username + c.user = after + // Set the flag so ConnectWith knows to prompt + c.ask = true + } } // Add default user, if not set @@ -97,7 +111,7 @@ func initAuthMethod() { if strings.HasSuffix(file, ".pub") { continue // Skip public keys. } - data, err := ioutil.ReadFile(file) + data, err := os.ReadFile(file) if err != nil { continue } @@ -130,19 +144,54 @@ func (c *SSHClient) ConnectWith(host string, dialer SSHDialFunc) error { initAuthMethodOnce.Do(initAuthMethod) - err := c.parseHost(host) - if err != nil { + if err := c.parseHost(host); err != nil { return err } + auths := []ssh.AuthMethod{ + authMethod, + } + + if c.identityFile != "" { + resolvedPath := ResolvePath(c.identityFile) + key, err := os.ReadFile(resolvedPath) + if err != nil { + return ErrConnect{c.user, c.host, fmt.Sprintf("reading private key %s: %v", c.identityFile, err)} + } + + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + var passphraseMissingError *ssh.PassphraseMissingError + if errors.As(err, &passphraseMissingError) { + return ErrConnect{c.user, c.host, fmt.Sprintf("private key %s is encrypted with passphrase (not supported)", c.identityFile)} + } + return ErrConnect{c.user, c.host, fmt.Sprintf("parsing private key %s: %v", c.identityFile, err)} + } + + auths = append([]ssh.AuthMethod{ssh.PublicKeys(signer)}, auths...) + } + + if c.password != "" { + auths = append(auths, ssh.Password(c.password)) + } + + if c.ask { + fmt.Printf("Enter Password for %s@%s: ", c.user, c.host) + pass, err := terminal.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return err + } + fmt.Println() + auths = append(auths, ssh.Password(string(pass))) + } + config := &ssh.ClientConfig{ - User: c.user, - Auth: []ssh.AuthMethod{ - authMethod, - }, + User: c.user, + Auth: auths, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } + var err error c.conn, err = dialer("tcp", c.host, config) if err != nil { return ErrConnect{c.user, c.host, err.Error()} @@ -155,10 +204,10 @@ func (c *SSHClient) ConnectWith(host string, dialer SSHDialFunc) error { // 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") + return fmt.Errorf("session already running") } if c.sessOpened { - return fmt.Errorf("Session already connected") + return fmt.Errorf("session already connected") } sess, err := c.conn.NewSession() @@ -241,7 +290,7 @@ func (c *SSHClient) Close() error { c.sessOpened = false } if !c.connOpened { - return fmt.Errorf("Trying to close the already closed connection") + return fmt.Errorf("trying to close the already closed connection") } err := c.conn.Close() diff --git a/sup.go b/sup.go index d815068..0004a76 100644 --- a/sup.go +++ b/sup.go @@ -13,7 +13,7 @@ import ( "golang.org/x/crypto/ssh" ) -const VERSION = "0.5" +const VERSION = "0.5.2" type Stackup struct { conf *Supfile @@ -29,7 +29,8 @@ func New(conf *Supfile) (*Stackup, error) { // Run runs set of commands on multiple hosts defined by network sequentially. // TODO: This megamoth method needs a big refactor and should be split -// to multiple smaller methods. +// +// to multiple smaller methods. func (sup *Stackup) Run(network *Network, envVars EnvList, commands ...*Command) error { if len(commands) == 0 { return errors.New("no commands to be run") @@ -40,7 +41,10 @@ func (sup *Stackup) Run(network *Network, envVars EnvList, commands ...*Command) // Create clients for every host (either SSH or Localhost). var bastion *SSHClient if network.Bastion != "" { - bastion = &SSHClient{} + bastion = &SSHClient{ + password: network.Password, + identityFile: ResolvePath(network.IdentityFile), + } if err := bastion.Connect(network.Bastion); err != nil { return errors.Wrap(err, "connecting to bastion failed") } @@ -70,9 +74,11 @@ func (sup *Stackup) Run(network *Network, envVars EnvList, commands ...*Command) // SSH client. remote := &SSHClient{ - env: env + `export SUP_HOST="` + host + `";`, - user: network.User, - color: Colors[i%len(Colors)], + env: env + `export SUP_HOST="` + host + `";`, + user: network.User, + color: Colors[i%len(Colors)], + password: network.Password, + identityFile: ResolvePath(network.IdentityFile), } if bastion != nil { diff --git a/supfile.go b/supfile.go index 2cf88b5..3c95f89 100644 --- a/supfile.go +++ b/supfile.go @@ -9,11 +9,9 @@ import ( "strings" "github.com/pkg/errors" - "gopkg.in/yaml.v2" ) -// Supfile represents the Stack Up configuration YAML file. type Supfile struct { Networks Networks `yaml:"networks"` Commands Commands `yaml:"commands"` @@ -22,25 +20,22 @@ type Supfile struct { Version string `yaml:"version"` } -// Network is group of hosts with extra custom env vars. type Network struct { - Env EnvList `yaml:"env"` - Inventory string `yaml:"inventory"` - Hosts []string `yaml:"hosts"` - Bastion string `yaml:"bastion"` // Jump host for the environment - - // Should these live on Hosts too? We'd have to change []string to struct, even in Supfile. - User string // `yaml:"user"` - IdentityFile string // `yaml:"identity_file"` + Env EnvList `yaml:"env"` + Inventory string `yaml:"inventory"` + Hosts []string `yaml:"hosts"` + Bastion string `yaml:"bastion"` + Password string `yaml:"password"` + User string `yaml:"user"` + IdentityFile string `yaml:"identity_file"` } -// Networks is a list of user-defined networks type Networks struct { Names []string nets map[string]Network } -func (n *Networks) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (n *Networks) UnmarshalYAML(unmarshal func(any) error) error { err := unmarshal(&n.nets) if err != nil { return err @@ -65,29 +60,26 @@ func (n *Networks) Get(name string) (Network, bool) { return net, ok } -// Command represents command(s) to be run remotely. type Command struct { - Name string `yaml:"-"` // Command name. - Desc string `yaml:"desc"` // Command description. - Local string `yaml:"local"` // Command(s) to be run locally. - Run string `yaml:"run"` // Command(s) to be run remotelly. - Script string `yaml:"script"` // Load command(s) from script and run it remotelly. - Upload []Upload `yaml:"upload"` // See Upload struct. - Stdin bool `yaml:"stdin"` // Attach localhost STDOUT to remote commands' STDIN? - Once bool `yaml:"once"` // The command should be run "once" (on one host only). - Serial int `yaml:"serial"` // Max number of clients processing a task in parallel. - - // API backward compatibility. Will be deprecated in v1.0. - RunOnce bool `yaml:"run_once"` // The command should be run once only. + Name string `yaml:"-"` + Desc string `yaml:"desc"` + Local string `yaml:"local"` + Run string `yaml:"run"` + Script string `yaml:"script"` + Upload []Upload `yaml:"upload"` + Stdin bool `yaml:"stdin"` + Once bool `yaml:"once"` + Serial int `yaml:"serial"` + + RunOnce bool `yaml:"run_once"` } -// Commands is a list of user-defined commands type Commands struct { Names []string cmds map[string]Command } -func (c *Commands) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (c *Commands) UnmarshalYAML(unmarshal func(any) error) error { err := unmarshal(&c.cmds) if err != nil { return err @@ -112,13 +104,12 @@ func (c *Commands) Get(name string) (Command, bool) { return cmd, ok } -// Targets is a list of user-defined targets type Targets struct { Names []string targets map[string][]string } -func (t *Targets) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (t *Targets) UnmarshalYAML(unmarshal func(any) error) error { err := unmarshal(&t.targets) if err != nil { return err @@ -143,15 +134,14 @@ func (t *Targets) Get(name string) ([]string, bool) { return cmds, ok } -// Upload represents file copy operation from localhost Src path to Dst -// path of every host in a given Network. type Upload struct { - Src string `yaml:"src"` - Dst string `yaml:"dst"` - Exc string `yaml:"exclude"` + Src string `yaml:"src"` + Dst string `yaml:"dst"` + Exc string `yaml:"exclude"` + Flatten bool `yaml:"flatten"` + Sudo bool `yaml:"sudo"` } -// EnvVar represents an environment variable type EnvVar struct { Key string Value string @@ -161,13 +151,10 @@ func (e EnvVar) String() string { return e.Key + `=` + e.Value } -// AsExport returns the environment variable as a bash export statement func (e EnvVar) AsExport() string { return `export ` + e.Key + `="` + e.Value + `";` } -// EnvList is a list of environment variables that maps to a YAML map, -// but maintains order, enabling late variables to reference early variables. type EnvList []*EnvVar func (e EnvList) Slice() []string { @@ -178,7 +165,7 @@ func (e EnvList) Slice() []string { return envs } -func (e *EnvList) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (e *EnvList) UnmarshalYAML(unmarshal func(any) error) error { items := []yaml.MapItem{} err := unmarshal(&items) @@ -195,7 +182,6 @@ func (e *EnvList) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } -// Set key to be equal value in this list. func (e *EnvList) Set(key, value string) { for i, v := range *e { if v.Key == key { @@ -215,11 +201,11 @@ func (e *EnvList) ResolveValues() error { return nil } - exports := "" + var exports strings.Builder for i, v := range *e { - exports += v.AsExport() + exports.WriteString(v.AsExport()) - cmd := exec.Command("bash", "-c", exports+"echo -n "+v.Value+";") + cmd := exec.Command("bash", "-c", exports.String()+"echo -n "+v.Value+";") cwd, err := os.Getwd() if err != nil { return err @@ -237,13 +223,11 @@ func (e *EnvList) ResolveValues() error { } func (e *EnvList) AsExport() string { - // Process all ENVs into a string of form - // `export FOO="bar"; export BAR="baz";`. - exports := `` + var exports strings.Builder for _, v := range *e { - exports += v.AsExport() + " " + exports.WriteString(v.AsExport() + " ") } - return exports + return exports.String() } type ErrMustUpdate struct { @@ -262,7 +246,6 @@ func (e ErrUnsupportedSupfileVersion) Error() string { return fmt.Sprintf("%v\n\nCheck your Supfile version (available latest version: v0.5)", e.Msg) } -// NewSupfile parses configuration file and returns Supfile or error. func NewSupfile(data []byte) (*Supfile, error) { var conf Supfile @@ -270,7 +253,6 @@ func NewSupfile(data []byte) (*Supfile, error) { return nil, err } - // API backward compatibility. Will be deprecated in v1.0. switch conf.Version { case "": conf.Version = "0.1" @@ -318,8 +300,7 @@ func NewSupfile(data []byte) (*Supfile, error) { fallthrough - case "0.4", "0.5": - + case "0.4", "0.5", "0.5.1", "0.5.2": default: return nil, ErrUnsupportedSupfileVersion{"unsupported Supfile version " + conf.Version} } @@ -327,8 +308,6 @@ func NewSupfile(data []byte) (*Supfile, error) { return &conf, nil } -// ParseInventory runs the inventory command, if provided, and appends -// the command's output lines to the manually defined list of hosts. func (n Network) ParseInventory() ([]string, error) { if n.Inventory == "" { return nil, nil @@ -355,7 +334,6 @@ func (n Network) ParseInventory() ([]string, error) { } host = strings.TrimSpace(host) - // skip empty lines and comments if host == "" || host[:1] == "#" { continue } diff --git a/tar.go b/tar.go index 10582f5..8174f78 100644 --- a/tar.go +++ b/tar.go @@ -15,30 +15,34 @@ import ( // RemoteTarCommand returns command to be run on remote SSH host // to properly receive the created TAR stream. // TODO: Check for relative directory. -func RemoteTarCommand(dir string) string { - return fmt.Sprintf("tar -C \"%s\" -xzf -", dir) +func RemoteTarCommand(dir string, useSudo bool) string { + cmd := fmt.Sprintf("tar --warning=no-unknown-keyword -C \"%s\" -xzf -", dir) + if useSudo { + return "sudo " + cmd + } + return cmd } -func LocalTarCmdArgs(path, exclude string) []string { - args := []string{} +func LocalTarCmdArgs(basedir, path, exclude string) []string { + var args []string // Added pattens to exclude from tar compress - excludes := strings.Split(exclude, ",") - for _, exclude := range excludes { + excludes := strings.SplitSeq(exclude, ",") + for exclude := range excludes { trimmed := strings.TrimSpace(exclude) if trimmed != "" { args = append(args, `--exclude=`+trimmed) } } - args = append(args, "-C", ".", "-czf", "-", path) + args = append(args, "-C", basedir, "-czf", "-", path) return args } // NewTarStreamReader creates a tar stream reader from a local path. // TODO: Refactor. Use "archive/tar" instead. -func NewTarStreamReader(cwd, path, exclude string) (io.Reader, error) { - cmd := exec.Command("tar", LocalTarCmdArgs(path, exclude)...) +func NewTarStreamReader(cwd, basedir, path, exclude string) (io.Reader, error) { + cmd := exec.Command("tar", LocalTarCmdArgs(basedir, path, exclude)...) cmd.Dir = cwd stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/task.go b/task.go index eebc3c7..bc6ab21 100644 --- a/task.go +++ b/task.go @@ -5,11 +5,12 @@ import ( "io" "io/ioutil" "os" + "path/filepath" + "github.com/cheggaaa/pb/v3" "github.com/pkg/errors" ) -// Task represents a set of commands to be run. type Task struct { Run string Input io.Reader @@ -17,6 +18,19 @@ type Task struct { TTY bool } +type finishReader struct { + io.Reader + bar *pb.ProgressBar +} + +func (r *finishReader) Read(p []byte) (n int, err error) { + n, err = r.Reader.Read(p) + if err == io.EOF { + r.bar.Finish() + } + return +} + func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]*Task, error) { var tasks []*Task @@ -25,20 +39,44 @@ func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]* return nil, errors.Wrap(err, "resolving CWD failed") } - // Anything to upload? for _, upload := range cmd.Upload { uploadFile, err := ResolveLocalPath(cwd, upload.Src, env) if err != nil { - return nil, errors.Wrap(err, "upload: "+upload.Src) + return nil, errors.Wrap(err, "upload src: "+upload.Src) + } + + uploadDst, err := ResolveLocalPath(cwd, upload.Dst, env) + if err != nil { + return nil, errors.Wrap(err, "upload dst: "+upload.Dst) + } + + tarBaseDir := "." + tarTarget := uploadFile + + if upload.Flatten { + tarBaseDir = filepath.Dir(uploadFile) + tarTarget = filepath.Base(uploadFile) } - uploadTarReader, err := NewTarStreamReader(cwd, uploadFile, upload.Exc) + + uploadTarReader, err := NewTarStreamReader(cwd, tarBaseDir, tarTarget, upload.Exc) if err != nil { return nil, errors.Wrap(err, "upload: "+upload.Src) } + bar := pb.New64(0) + bar.Set(pb.Bytes, true) + bar.Set("prefix", fmt.Sprintf("Uploading %s: ", uploadFile)) + bar.SetTemplateString(`{{string . "prefix"}}{{counters . }} {{speed . }}`) + bar.Start() + + proxyReader := &finishReader{ + Reader: bar.NewProxyReader(uploadTarReader), + bar: bar, + } + task := Task{ - Run: RemoteTarCommand(upload.Dst), - Input: uploadTarReader, + Run: RemoteTarCommand(uploadDst, upload.Sudo), + Input: proxyReader, TTY: false, } @@ -46,12 +84,8 @@ func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]* task.Clients = []Client{clients[0]} tasks = append(tasks, &task) } else if cmd.Serial > 0 { - // Each "serial" task client group is executed sequentially. for i := 0; i < len(clients); i += cmd.Serial { - j := i + cmd.Serial - if j > len(clients) { - j = len(clients) - } + j := min(i+cmd.Serial, len(clients)) copy := task copy.Clients = clients[i:j] tasks = append(tasks, ©) @@ -62,7 +96,6 @@ func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]* } } - // Script. Read the file as a multiline input command. if cmd.Script != "" { f, err := os.Open(cmd.Script) if err != nil { @@ -87,12 +120,8 @@ func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]* task.Clients = []Client{clients[0]} tasks = append(tasks, &task) } else if cmd.Serial > 0 { - // Each "serial" task client group is executed sequentially. for i := 0; i < len(clients); i += cmd.Serial { - j := i + cmd.Serial - if j > len(clients) { - j = len(clients) - } + j := min(i+cmd.Serial, len(clients)) copy := task copy.Clients = clients[i:j] tasks = append(tasks, ©) @@ -103,7 +132,6 @@ func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]* } } - // Local command. if cmd.Local != "" { local := &LocalhostClient{ env: env + `export SUP_HOST="localhost";`, @@ -123,7 +151,6 @@ func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]* tasks = append(tasks, task) } - // Remote command. if cmd.Run != "" { task := Task{ Run: cmd.Run, @@ -139,12 +166,8 @@ func (sup *Stackup) createTasks(cmd *Command, clients []Client, env string) ([]* task.Clients = []Client{clients[0]} tasks = append(tasks, &task) } else if cmd.Serial > 0 { - // Each "serial" task client group is executed sequentially. for i := 0; i < len(clients); i += cmd.Serial { - j := i + cmd.Serial - if j > len(clients) { - j = len(clients) - } + j := min(i+cmd.Serial, len(clients)) copy := task copy.Clients = clients[i:j] tasks = append(tasks, ©)