Implement Go version manager

development golang

Introduction

In this post, we will learn more about Go programming language by implementing a Go version manager CLI tool. The goal of this CLI tool is to manage different versions of Go installed on our development machine. As part of implementing the tool, we will learn about command-line flags, bash completion, HTTP client to download archives and interaction with files or directories. Let’s dive into the requirements and the features we will support with the tool and the implementation of the tool using Go.

Supported Features

In order to implement the CLI tool, we will use Go version 1.18. I currently have access to Mac and Linux. So everything we build here will be for Mac or Linux. We will use github.com/posener/complete/v2 for command-line options parsing and bash completion, and github.com/codeclysm/extract for extracting archived gzip files.

The features that the tool will support are:

  • install the specified version of Go on our dev machine
  • delete the specified version of Go from our dev machine
  • list the versions of Go installed on our dev machine
  • set the specified version of Go as the active version

Implementation

Let me outline how the tool will work. The tool will manage different versions of Go on the dev machine, but it will set one version as the active version. The tool will install different versions of Go under /usr/local/go/ directory. Each version will get it’s own directory under /usr/local/go. The tool will then create a symlink with the name /usr/local/go/active that will link to the directory with the active version of Go. For instance, the /usr/local/go directory looks like this:

$ ls -l /usr/local/go
total 0
lrwxr-xr-x   1 varun  staff   22 Aug 14 12:54 active -> /usr/local/go/go1.18.5
drwxr-xr-x  19 varun  wheel  608 Aug 14 12:32 go1.18.5
drwxr-xr-x  17 varun  wheel  544 Aug 14 12:36 go1.19

The symlink /usr/local/go/active links to the active version go1.18.5 directory. When the active version is changed to go1.19, the symlink will then point to the directory /usr/local/go/go1.19. We will also need to ensure that /usr/local/go/active/bin is added to bash PATH environment variable.

Now that we know how to implement, let’s start writing some code. First, we will write a function that will install a given version of Go. The function will basically fetch the Go source from https://go.dev/dl/ website, extract the tar.gz file and copy the contents to /usr/local/go/ directory.

The complete source code is available in the Github repo abvarun226/vgo


const (
    godir       = "/usr/local/go"
    gosourceURL = "https://go.dev/dl/go%s.%s-%s.tar.gz"
)

var (
    availablePlatforms = []string{"darwin", "linux", "freebsd"}
    availableArch      = []string{"amd64", "arm64", "386", "armv6l", "ppc64le", "s390x"}
    letters            = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
)

func download(version, platform, arch string) error {
    // currently, no support for windows OS
    if platform == "windows" {
        return fmt.Errorf("unsupported platform")
    }

    // check if the given os platform and architecture is supported by the tool.
    if !searchStrings(availablePlatforms, platform) || !searchStrings(availableArch, arch) {
        return fmt.Errorf("unsupported platform or arch, supported platform=%v and arch=%v", availablePlatforms, availableArch)
    }

    // check if the given version of Go is already installed
    // versionList function is implemented later in the post.
    versions, errList := versionList()
    if errList != nil {
        return fmt.Errorf("failed to download version: %w", errList)
    }
    if found := searchStrings(versions, version); found {
        return fmt.Errorf("version already exists: %s %v", version, versions)
    }

    // url to download go source from.
    url := fmt.Sprintf(gosourceURL, version, platform, arch)
    
    client := http.Client{Timeout: 60 * time.Second}

    // download the go tar.gz
    resp, errGet := client.Get(url)
    if errGet != nil {
        return fmt.Errorf("failed to get go source tar ball: %w", errGet)
    }
    defer resp.Body.Close()

    data, _ := ioutil.ReadAll(resp.Body)
    buffer := bytes.NewBuffer(data)

    // extract the tar.gz file to /tmp/
    path := "/tmp/" + "go-" + randString(8)
    if err := extract.Gz(context.TODO(), buffer, path, nil); err != nil {
        return fmt.Errorf("failed to extract tar.gz: %w", err)
    }

    // copy the extracted contents from /tmp to /usr/local/go/
    os.Rename(path+"/go", godir+"/go"+version)

    // delete contents from /tmp
    os.Remove(path)

    return nil
}

// generates random string of length n.
func randString(n int) string {
    b := make([]rune, n)
    for i := range b {
        b[i] = letters[rand.Intn(len(letters))]
    }
    return string(b)
}

// search for needle in an array of hay.
func searchStrings(hay []string, needle string) bool {
    for _, h := range hay {
        if h == needle {
            return true
        }
    }
    return false
}

Now that we have downloaded a specified version of Go, let’s look at a function to list the downloaded versions. The function will list all directories under /usr/local/go to compile the list of installed versions of Go.


func versionList() ([]string, error) {
    // list the directories under /usr/local/go/
    fileInfo, err := ioutil.ReadDir(godir)
    if err != nil {
        return nil, fmt.Errorf("unable to list versions: %w", err)
    }

    versions := make([]string, 0)
    for _, file := range fileInfo {
        // look for directories and exclude the "active" symlink.
        if file.IsDir() && file.Name() != activeDir {
            version := strings.TrimPrefix(file.Name(), "go")
            versions = append(versions, version)
        }
    }

    return versions, nil
}

Next we will implement a function that will set a specified version of Go as active.


const (
    goSymLink = "/usr/local/go/active"
    godir     = "/usr/local/go"
)

func setVersion(version string) {
    // check if the given version is already installed.
    versions, errList := versionList()
    if errList != nil {
        fmt.Printf("\n[ERROR] failed to set version: %v", errList)
        os.Exit(1)
    }

    if found := searchStrings(versions, version); !found {
        fmt.Printf("\n[ERROR] unknown go version: %s. List of available versions are: %s\n\n", version, strings.Join(versions, "/"))
        os.Exit(1)
    }

    // path to the directory containing specified version of Go
    gopath := godir + "/go" + version

    // check if path to given version exists
    if !pathExists(gopath) {
        fmt.Printf("\n[ERROR] go path not found: %s\n\n", gopath)
        os.Exit(1)
    }

    // delete the /usr/local/go/active symlink
    os.Remove(goSymLink)

    // create a new symlink from /usr/local/go/active to the specified version of Go
    if err := os.Symlink(gopath, goSymLink); err != nil {
        fmt.Printf("\n[ERROR] failed to symlink go path: %v\n\n", err)
        os.Exit(1)
    }

    // verify that the specified version is active
    out, errExec := exec.Command("go", "version").Output()
    if errExec != nil {
        fmt.Printf("\n[ERROR] failed to execute go version: %v\n", errExec)
        os.Exit(1)
    }
    fmt.Println(string(out))
}

We will need a function to return the active version of Go. We just need to check the symlink /usr/local/go/active to figure out the active version of Go.


func getActiveVersion() (string, error) {
    // check the symlink "/usr/local/go/active" to figure out the active version.
    name, errRead := os.Readlink(goSymLink)
    if errRead != nil {
        return "", fmt.Errorf("failed to read link: %w", errRead)
    }

    name = strings.TrimPrefix(filepath.Base(name), "go")
    return name, nil
}

Finally, we require a function to delete a specified version of Go from the dev machine. We will need to check if the given version is currently not the active version. We then need to validate if the given version is actually installed on the dev machine. If it is installed and not active, then we delete the directory from under /usr/local/go/.


func deleteVersion(version string) {
    // check if given version is the active version, if yes, return error.
    activeVersion, _ := getActiveVersion()
    if activeVersion == version {
        fmt.Print(activeVersionNotice)
        os.Exit(1)
    }

    // check if the given version is actually installed.
    versions, _ := versionList()
    var found bool
    for _, v := range versions {
        if v == version {
            found = true
        }
    }
    if !found {
        fmt.Printf("\n[ERROR] version not found\n")
        os.Exit(1)
    }

    // delete the version from under /usr/local/go/ directory.
    if err := os.RemoveAll(godir + "/go" + version); err != nil {
        fmt.Printf("\n[ERROR] failed to delete directory: %v\n", err)
        os.Exit(1)
    }
}

Now that we have all the functions required to manage the versions of Go, let’s implement the command-line arguments and bash completion. We will use github.com/posener/complete/v2 package to implement command-line flags and bash completion. Each functionality is treated as a sub-command and we pass appropriate arguments or flags to the sub-commands. For instance, to download a specific version of Go, the command will look like:


# download a new version (1.19) for M1 mac (darwin/arm64)
$ vgo download -version 1.19 -platform darwin -arch arm64

To set an installed version of Go as active version, the command will be:


# set the active version of go to 1.19
$ vgo set 1.19
go version go1.19 darwin/arm64

Similaryly, the following commands are to list the installed versions and delete a given version.


# list the installed versions
$ vgo list
Versions: 1.18.5, 1.19

# delete version 1.18.5
$ vgo delete 1.18.5

With this mind, let’s implement the sub-commands along with the arguments and the flags. For set and delete sub-commands, on tab press, the CLI will provide the list of installed versions of Go. For download sub-command, on tab press, the CLI will provide supported OS platform and OS architecture.


import (
    "github.com/posener/complete/v2"
    "github.com/posener/complete/v2/predict"
)

func checkPath() {
    os.Mkdir(godir, 0755)
    path, _ := os.LookupEnv("PATH")
    if !strings.Contains(path, goSymLink) {
        fmt.Println(instructions)
    }
}

func usage() {
    fmt.Printf(cmdUsageNotice, os.Args[0])
}

func main() {
    // initializes the usage/help message to be displayed.
    flag.Usage = usage

    // initialize the download sub-command
    downloadCmd := flag.NewFlagSet("download", flag.ExitOnError)
    version := downloadCmd.String("version", "", "version to be downloaded")
    platform := downloadCmd.String("platform", "darwin", "os platform")
    arch := downloadCmd.String("arch", "arm64", "os architecture")

    // initialize the list sub-command
    listCmd := flag.NewFlagSet("list", flag.ExitOnError)

    // initialize the delete sub-command
    deleteCmd := flag.NewFlagSet("delete", flag.ExitOnError)

    // initialize the set sub-command
    setCmd := flag.NewFlagSet("set", flag.ExitOnError)

    versions, _ := versionList()

    // initialize the bash completion options here
    cmd := &complete.Command{
        Sub: map[string]*complete.Command{
            "set": {
                Args: predict.Set(versions),
            },
            "delete": {
                Args: predict.Set(versions),
            },
            "list": {},
            "download": {
                Flags: map[string]complete.Predictor{
                    "platform": predict.Set(availablePlatforms),
                    "arch":     predict.Set(availableArch),
                },
            },
        },
    }

    // the name of the binary, which is going to be "vgo" 
    cmd.Complete("vgo")

    // the CLI expects at least 2 arguments
    if len(os.Args) < 2 {
        flag.Usage()
        return
    }

    // checkPath will ensure that the directory "/usr/local/go" is created and PATH env variable is set
    checkPath()

    // The second argument is the sub-command, we will consider each sub-command in a case statement
    // and implement the functionality.
    switch os.Args[1] {
    // download sub-command will parse the flags and args, passes them to "download" function
    case "download":
        downloadCmd.Parse(os.Args[2:])
        if err := download(*version, *platform, *arch); err != nil {
            fmt.Printf("download failed: %v\n", err)
            os.Exit(1)
        }

    // list sub-command will use versionList function to list the installed versions
    // it will also make the active version bold on the terminal
    case "list":
        listCmd.Parse(os.Args[2:])

        versions, errList := versionList()
        if errList != nil {
            fmt.Printf("\n[ERROR] failed to list versions: %v\n", errList)
            os.Exit(1)
        }

        activeVersion, _ := getActiveVersion()
        for i := 0; i < len(versions); i++ {
            if activeVersion == versions[i] {
                versions[i] = fmt.Sprintf(boldString, versions[i])
            }
        }
        fmt.Printf("\nVersions: %s\n\n", strings.Join(versions, ", "))

    // delete sub-command will parse the args and pass the version to be deleted to "delete" function
    case "delete":
        deleteCmd.Parse(os.Args[2:])
        if deleteCmd.NArg() != 1 {
            fmt.Printf("delete command expects 1 argument, received %d\n\n", deleteCmd.NArg())
            flag.Usage()
            os.Exit(1)
        }
        deleteVersion(deleteCmd.Arg(0))

    // set sub-command will pass the args and pass the version to be activated to "setVersion" function
    case "set":
        setCmd.Parse(os.Args[2:])
        if setCmd.NArg() != 1 {
            fmt.Printf("set command expects 1 argument, received %d\n\n", setCmd.NArg())
            flag.Usage()
            os.Exit(1)
        }
        setVersion(setCmd.Arg(0))
    
    // default case to handle unexpected sub-commands or arguments.
    default:
        fmt.Println("expected 'download' or 'list' or 'delete' or 'set' subcommands")
        flag.Usage()
        os.Exit(1)
    }

    // parse the flags.
    flag.Parse()
}

This concludes the implementation of the CLI tool vgo.

How to use?

In this section, we will look at how to configure the tool and bash completion. Once the code is complete, compile the code and name the resulting binary vgo. Move this binary to /usr/local/bin or equivalent location on your dev machine. Note that is location has to be part of bash’s PATH environment variable.


# compile the code and move "vgo" to /usr/local/bin
$ go build . -o ./vgo
$ sudo mv vgo /usr/local/bin/

# run this to enable command completion in shell
$ COMP_INSTALL=1 COMP_YES=1 vgo
installing..

# For zsh shell, either open new terminal OR run below command
$ source ~/.zshrc

# For bash shell, either open new terminal OR run below command
$ source ~/.bashrc

The vgo command along with bash completion should now be configured. The complete source code is available in the Github repo abvarun226/vgo

Final Thoughts

The version manager was something I wrote as a weekend project to make it easy for me manage different versions of Go on my Mac laptop. It has been working well for me so far. I have committed the complete source to Github. I hope you learnt some Go reading this article and find this tool helpful. If you find any errors or issues in this post, please do let me know in the comments section below. I’m open to pull requests on Github as well. In case you would like to get notified about more articles like this, please subscribe to my blog.

Get new posts by email

Comments

comments powered by Disqus