
Write Shell Scripts in Go
Despite decades of advancement of computer technology, it is still sometimes necessary to interact with a server over that most modern of teletype interface, the command line. The command line's interactivity and it's ability to be automated, combined with how easy it is to write a text based interface in our text based programming languages means that many programmers still use it on a daily basis. Although for a lot of people, it's probably just git commands at this point.
Traditionally, to automate tasks using the command line you'd write a bash script or, in more contemporary times, a Python script. Provided you had the right version of Python installed on the server, and all the dependencies. Or issue remote commands over SSH using a library like
fabric so that the server Python thing isn't an issue.
However, using Go for various scripts and tools like this can be really nice. Go happens to be a particularly good choice for doing the kinds of things you would use shell scripts for. Go binaries are statically linked and have no dependencies, plus they can be compiled for your Linux server from any machine.
Here's an example of a basic Go script to print the word hi:
package main
import (
"fmt"
"os"
"os/exec"
"strings"
)
func main() {
Run("echo hi")
}
func Run(command string) {
fmt.Printf("run: %s\n", command)
args := strings.Split(command, " ")
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("command failed: %s\n", command)
panic(err)
}
}go get goroutines.com/shell-basic
A small wrapper function around exec.Command() is sufficient for almost any command-liney task.
To run this example, download and install it with go get:
go get goroutines.com/shell-basic
shell-basic
To compile it for a linux server:
GOPATH=$PWD GOOS=linux GOARCH=amd64 go build shell-basic
Then you can copy it to the server with scp and run it over ssh.
scp shell-basic <username>@<servername>:shell-basic
ssh <username>@<servername> -- ./shell-basic
For a more complicated example, here's downloading
Redis and building it. I've added a couple of helper functions including
V(error) a function that just panics if the error value is non-nil.
package main
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"strings"
)
func main() {
if !Exists("redis.tar.gz") {
Download("http://download.redis.io/redis-stable.tar.gz", "redis.tar.gz")
}
if Exists("redis") {
V(os.RemoveAll("redis"))
}
V(os.MkdirAll("redis", 0755))
Run("tar zxvf redis.tar.gz -C redis --strip-components=1")
V(os.Chdir("redis"))
Run("make")
}
func V(err error) {
if err != nil {
panic(err)
}
}
func Exists(path string) bool {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return false
}
V(err)
}
return true
}
func Download(fileURL string, filename string) {
resp, err := http.Get(fileURL)
V(err)
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
V(err)
V(ioutil.WriteFile(filename, body, 0644))
}
func Run(command string) {
fmt.Printf("run: %s\n", command)
args := strings.Split(command, " ")
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("command failed: %s\n", command)
V(err)
}
}go get goroutines.com/shell-redis
Running Fancy Commands
What if you need to check the exit code of a command? Sometimes you want to run a command and either require a specific exit code or else want to allow multiple valid exit codes (
cmd.Run() returns an error on a non-zero exit code). Since this example uses
syscall it is not super portable, but should work on Mac and Linux.
func RunExit(command string) int {
args := strings.Split(command, " ")
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if exiterr, ok := err.(*exec.ExitError); ok {
return exiterr.Sys().(syscall.WaitStatus).ExitStatus()
} else {
fmt.Printf("command failed: %s\n", command)
panic(err)
}
}
return 0
}
Capturing the output of commands for further processing is also easy:
func Capture(command string) string {
args := strings.Split(command, " ")
output, err := exec.Command(args[0], args[1:]...).CombinedOutput()
if err != nil {
fmt.Printf("command failed: %s\n output=%s\n", command, output)
panic(err)
}
return string(output)
}
The above functions rely on the command string not having spaces between arguments, so what if you want to do something like git commit -a -m "Commit message"? For this it's probably best to fall back to using the multiple arguments of exec.Command() rather than a single command string. Here's a version of Run() which takes either a single string or a list of strings:
func Run(command string, args ...string) {
var cmd *exec.Cmd
if len(args) == 0 {
fmt.Printf("run: %s\n", command)
args := strings.Split(command, " ")
cmd = exec.Command(args[0], args[1:]...)
} else {
fmt.Printf("run: %s %s\n", command, strings.Join(args, " "))
cmd = exec.Command(command, args...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("command failed: %s\n", command)
panic(err)
}
}
Given that every time I try to program a bash script, I've already forgotten the syntax for an if statement, writing shell scripts in Go is pretty nice. The biggest drawbacks are that the executables are megabytes in size, which is mostly not an issue for most servers today, and that it's harder to change the compiled form of the script. Even with that, it's pretty nice to be able to write the script in Go and get a lot of control over the behavior of the script as well as make use of Go's very nice standard library.