Skip to content

Commit

Permalink
feat: Add cache.Go interface, add client tests (#3)
Browse files Browse the repository at this point in the history
* chore: Fix restoring shed cache in CI

* feat: Add cache.Go interface to allow specifying go functionality through dependency injection

* chore: Implement cache.mockGo and create tests for client

* refactor: cache clean command should use shed.CleanCache
  • Loading branch information
cszatmary committed Dec 22, 2020
1 parent 94f76fa commit 40f417a
Show file tree
Hide file tree
Showing 6 changed files with 549 additions and 54 deletions.
3 changes: 3 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ jobs:
name: Restore dependency cache
keys:
- *gomod_cache_key
- restore_cache:
name: Restore tool dependency cache
keys:
- *shed_cache_key
- run:
name: Install dependencies
Expand Down
65 changes: 23 additions & 42 deletions cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@
package cache

import (
"bytes"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"

Expand All @@ -22,13 +20,15 @@ import (
// Cache manages tools in an OS filesystem directory.
type Cache struct {
rootDir string
// For diagnostics
// Used to download and build tools.
goClient Go
// For diagnostics.
logger logrus.FieldLogger
}

// New creates a new Cache instance that uses the given directory.
func New(dir string, logger logrus.FieldLogger) *Cache {
return &Cache{rootDir: dir, logger: logger}
func New(dir string, goClient Go, logger logrus.FieldLogger) *Cache {
return &Cache{rootDir: dir, goClient: goClient, logger: logger}
}

// Dir returns the OS filesystem directory used by this Cache.
Expand Down Expand Up @@ -90,8 +90,7 @@ func (c *Cache) Install(t tool.Tool) (tool.Tool, error) {
return downloadedTool, nil
}

// Build using go build
err = execGo(binDir, "build", "-o", binPath, downloadedTool.ImportPath)
err = c.goClient.Build(downloadedTool.ImportPath, binPath, binDir)
if err != nil {
return downloadedTool, errors.WithMessagef(err, "failed to build tool: %s", downloadedTool)
}
Expand Down Expand Up @@ -131,19 +130,19 @@ func (c *Cache) download(t tool.Tool) (tool.Tool, error) {
return t, errors.Wrapf(err, "failed to read file %q", modfilePath)
}

gomod, err := modfile.Parse(modfilePath, data, nil)
modFile, err := modfile.Parse(modfilePath, data, nil)
if err != nil {
return t, errors.Wrapf(err, "failed to parse go.mod file %q", modfilePath)
}

modfileOK := true
// There should only be a single require, otherwise something is wrong
if len(gomod.Require) != 1 {
if len(modFile.Require) != 1 {
modfileOK = false
c.logger.Debugf("expected 1 required statement in go.mod, found %d", len(gomod.Require))
c.logger.Debugf("expected 1 required statement in go.mod, found %d", len(modFile.Require))
}

mod := gomod.Require[0].Mod
mod := modFile.Require[0].Mod
// Use contains since actual module could have less then what we are installing
// Ex: golang.org/x/tools vs golang.org/x/tools/cmd/stringer
if !strings.Contains(t.ImportPath, mod.Path) {
Expand Down Expand Up @@ -188,18 +187,16 @@ func (c *Cache) download(t tool.Tool) (tool.Tool, error) {

// Create empty go.mod file so we can install module
// Can just use _ as the module name since this is a "fake" module
err = execGo(modDir, "mod", "init", "_")
err = createGoModFile("_", modDir)
if err != nil {
return t, err
}

// Download using go get -d to get the source
// What's nice here is we leverage the power of go get so we don't need to
// reinvent the module resolution & downloading. Also we can reuse an existing
// download that's already cached.
// Always download even if the modfile existed, just to be safe.
// Download the module source. What's nice here is we leverage the power of
// go get so we don't need to reinvent the module resolution & downloading.
// Also we can reuse an existing download that's already cached.

err = execGo(modDir, "get", "-d", t.Module())
err = c.goClient.GetD(t.Module(), modDir)
if err != nil {
return t, err
}
Expand Down Expand Up @@ -230,14 +227,14 @@ func (c *Cache) download(t tool.Tool) (tool.Tool, error) {

// Create empty go.mod file so we can download the tool
// Can just use _ as the module name since this is a "fake" module
err = execGo(modDir, "mod", "init", "_")
err = createGoModFile("_", modDir)
if err != nil {
return t, err
}

// Download using got get -d to get the source
// go get will do the heavy lifting to figure out the latest version
err = execGo(modDir, "get", "-d", t.Module())
// Download the module source. This will do the heavy lifting to figure out
// the latest version.
err = c.goClient.GetD(t.Module(), modDir)
if err != nil {
return t, err
}
Expand All @@ -248,16 +245,16 @@ func (c *Cache) download(t tool.Tool) (tool.Tool, error) {
return t, errors.Wrapf(err, "failed to read file %q", modfilePath)
}

gomod, err := modfile.Parse(modfilePath, data, nil)
modFile, err := modfile.Parse(modfilePath, data, nil)
if err != nil {
return t, errors.Wrapf(err, "failed to parse go.mod file %q", modfilePath)
}

// There should only be a single require, otherwise we have a bug
if len(gomod.Require) != 1 {
return t, errors.Errorf("expected 1 required statement in go.mod, found %d", len(gomod.Require))
if len(modFile.Require) != 1 {
return t, errors.Errorf("expected 1 required statement in go.mod, found %d", len(modFile.Require))
}
t.Version = gomod.Require[0].Mod.Version
t.Version = modFile.Require[0].Mod.Version

// We got the version, now we need to rename the dir so it includes the version
vfp, err := t.Filepath()
Expand Down Expand Up @@ -300,19 +297,3 @@ func (c *Cache) ToolPath(t tool.Tool) (string, error) {
}
return binPath, nil
}

func execGo(dir string, args ...string) error {
cmd := exec.Command("go", args...)
cmd.Dir = dir

stderr := &bytes.Buffer{}
cmd.Stderr = stderr

err := cmd.Run()
if err != nil {
argsStr := strings.Join(args, " ")
return errors.Wrapf(err, "failed to run 'go %s', stderr: %s", argsStr, stderr.String())
}

return nil
}
215 changes: 215 additions & 0 deletions cache/go.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package cache

import (
"bytes"
"go/build"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"

"github.com/getshiphub/shed/internal/util"
"github.com/getshiphub/shed/tool"
"github.com/pkg/errors"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)

// createGoModFile creates and writes an empty go.mod file at the path referenced by dir.
// mod is used as the module name. This functions similar to 'go mod init'.
func createGoModFile(mod, dir string) error {
modFile := &modfile.File{}
modFile.AddComment("// Autogenerated by https://github.com/getshiphub/shed. DO NOT EDIT")
// AddModuleStmt never actually returns an error, not sure why it's in the signature
modFile.AddModuleStmt(mod) //nolint:errcheck

// Add go statement
tags := build.Default.ReleaseTags
version := tags[len(tags)-1]
if !strings.HasPrefix(version, "go") || !modfile.GoVersionRE.MatchString(version[2:]) {
return errors.Errorf("unrecognized default go version %q", version)
}
if err := modFile.AddGoStmt(version[2:]); err != nil {
return errors.Wrap(err, "failed to add go statement to modfile")
}

data, err := modFile.Format()
if err != nil {
return errors.Wrapf(err, "failed to create modfile")
}
modfilePath := filepath.Join(dir, "go.mod")
err = ioutil.WriteFile(modfilePath, data, 0o644)
if err != nil {
return errors.Wrapf(err, "failed to write modfile %s", modfilePath)
}
return nil
}

// Go represents the core functionality provided by the go command. It allows for downloading
// and building of modules.
type Go interface {
// Build builds pkg and outputs the binary at outPath. dir is used as the working directory
// when building. pkg must be a valid import path.
// Build functions like 'go build -o'.
Build(pkg, outPath, dir string) error
// GetD downloads the source code for the module mod. dir is used as the working directory
// and is expected to contain a go.mod file which will be updated with the installed module.
// mod must be a valid module name, that is an import path, optionally with a version.
// If no version is provided, the latest version will be downloaded.
// GetD functions like 'got get -d' in module aware mode.
GetD(mod, dir string) error
}

// realGo is the main implementation of the Go interface.
// It is a wrapper around the go command.
type realGo struct{}

// NewGo returns a new Go instance which allows for downloading and building modules.
func NewGo() Go {
return realGo{}
}

func (realGo) Build(pkg, outPath, dir string) error {
return execGo(dir, "build", "-o", outPath, pkg)
}

func (realGo) GetD(mod, dir string) error {
return execGo(dir, "get", "-d", mod)
}

func execGo(dir string, args ...string) error {
cmd := exec.Command("go", args...)
cmd.Dir = dir
stderr := &bytes.Buffer{}
cmd.Stderr = stderr

err := cmd.Run()
if err != nil {
argsStr := strings.Join(args, " ")
return errors.Wrapf(err, "failed to run 'go %s', stderr: %s", argsStr, stderr.String())
}
return nil
}

// mockGo provides a implementation of the Go interface that is suitable for testing.
type mockGo struct {
// Tool import path to module
registry map[string]mockModule
}

type mockModule struct {
// Name of the module, i.e. the import path
name string
// List of semver versions, must be sorted from earliest to latest version
versions []string
}

// NewMockGo returns a new Go instance that is suitable for testing.
// Tools is a list of tools which contains the import path and version.
func NewMockGo(tools []string) (Go, error) {
registry := make(map[string]mockModule)
for _, tn := range tools {
t, err := tool.Parse(tn)
if err != nil {
return nil, err
}

// Module already exists, add new version
m, ok := registry[t.ImportPath]
if ok {
m.versions = append(m.versions, t.Version)
registry[t.ImportPath] = m
continue
}

// Take first 3 parts as the module name
// This should be could enough for testing purposes
modName := strings.Join(strings.Split(t.ImportPath, "/")[:3], "/")
m = mockModule{name: modName, versions: []string{t.Version}}
registry[t.ImportPath] = m
}

for _, m := range registry {
// Sort versions so we can easily find the latest version
sort.Slice(m.versions, func(i, j int) bool {
return semver.Compare(m.versions[i], m.versions[j]) == -1
})
}
return &mockGo{registry: registry}, nil
}

func (mg *mockGo) Build(pkg, outPath, dir string) error {
if _, ok := mg.registry[pkg]; !ok {
return errors.Errorf("unknown package %s", pkg)
}
if !util.FileOrDirExists(dir) {
return errors.Errorf("directory %s does not exist", dir)
}
// Can just write an empty file to outPath so the binary "exists"
err := ioutil.WriteFile(outPath, nil, 0o644)
if err != nil {
return errors.Wrapf(err, "failed to write build to %s", outPath)
}
return nil
}

func (mg *mockGo) GetD(mod, dir string) error {
t, err := tool.Parse(mod)
if err != nil {
return err
}

m, ok := mg.registry[t.ImportPath]
if !ok {
return errors.Errorf("unknown package %s", mod)
}

modver := module.Version{Path: m.name}
if t.Version != "" {
// If version is provided, see if it exists
found := false
for _, v := range m.versions {
if v == t.Version {
modver.Version = v
found = true
break
}
}
if !found {
return errors.Errorf("module %s has no version %s", t.ImportPath, t.Version)
}
} else {
// Find latest version
modver.Version = m.versions[len(m.versions)-1]
}

modfilePath := filepath.Join(dir, "go.mod")
data, err := ioutil.ReadFile(modfilePath)
if os.IsNotExist(err) {
// Treat no go.mod as an error because it is required for shed to work properly.
// If there is no go.mod then go get will function in non-module mode which we don't want.
return errors.Errorf("failed to download %s, no go.mod file found at %s", mod, dir)
} else if err != nil {
return errors.Wrapf(err, "failed to read %s", modfilePath)
}

modFile, err := modfile.Parse(modfilePath, data, nil)
if err != nil {
return errors.Wrapf(err, "failed to parse go.mod file %s", modfilePath)
}

// Add resolved module to go.mod
modFile.AddNewRequire(modver.Path, modver.Version, true)
newData, err := modFile.Format()
if err != nil {
return errors.Wrapf(err, "failed to update modfile %s", modfilePath)
}
err = ioutil.WriteFile(modfilePath, newData, 0o644)
if err != nil {
return errors.Wrapf(err, "failed to write modfile %s", modfilePath)
}
return nil
}
Loading

0 comments on commit 40f417a

Please sign in to comment.