Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tool version reporting #902

Merged
merged 1 commit into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions toolversions/toolversions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package toolversions

import (
"fmt"
"path/filepath"
"regexp"
"strings"

"github.com/bitrise-io/go-utils/v2/command"
"github.com/bitrise-io/go-utils/v2/env"
"github.com/bitrise-io/go-utils/v2/log"
)

type ToolVersionReporter interface {
// IsAvailable returns true if the tool version manager is available and actively manages the tool versions.
IsAvailable() bool

// CurrentToolVersions returns a snapshot of the currently active tools and versions.
// The returned map is keyed by tool name.
// Tool names and reported versions are implementation-specific. Tool names are normalized to lowercase.
CurrentToolVersions() (map[string]ToolVersion, error)
}

type ToolVersion struct {
Version string
IsInstalled bool
DeclaredByFile string
IsGlobal bool
}

type ASDFVersionReporter struct {
cmdLocator env.CommandLocator
cmdFactory command.Factory
logger log.Logger
userHomeDir string
}

func NewASDFVersionReporter(cmdLocator env.CommandLocator, cmdFactory command.Factory, logger log.Logger, userHomeDir string) ASDFVersionReporter {
return ASDFVersionReporter{
cmdLocator: cmdLocator,
cmdFactory: cmdFactory,
logger: logger,
userHomeDir: userHomeDir,
}
}

func (r *ASDFVersionReporter) IsAvailable() bool {
_, err := r.cmdLocator.LookPath("asdf")
if err != nil {
r.logger.Debugf("asdf not found in path")
return false
}

code, err := r.cmdFactory.Create("asdf", []string{"current"}, &command.Opts{}).RunAndReturnExitCode()
if err != nil {
r.logger.Debugf("run asdf current: %s", err)
return false
}
if code != 0 {
r.logger.Debugf("run asdf current: nonzero exit code: %d", code)
return false
}

return true
}

func (r *ASDFVersionReporter) CurrentToolVersions() (map[string]ToolVersion, error) {
cmd := r.cmdFactory.Create("asdf", []string{"current"}, &command.Opts{})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens to default tools (e.g. Flutter) that aren't set by asdf but available in our stack? Do we capture them ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet, but we are constantly trying to move more and more tools under ASDF (or any other version manager in the future).
But @godrei had a good analysis of Flutter versions recently that you might find interesting: https://bitrise.atlassian.net/wiki/spaces/ENGI/pages/2734293197/RFC+Flutter+SDK+version+on+stacks

out, err := cmd.RunAndReturnTrimmedCombinedOutput()
if err != nil {
return nil, fmt.Errorf("run asdf current: %s", err)
}

asdfVersionRegexp, err := regexp.Compile(`([a-z]+)\s+(\S+)\s+(.+)`)
if err != nil {
return nil, fmt.Errorf("compile regex: %s", err)
}

toolVersions := map[string]ToolVersion{}
for _, line := range strings.Split(out, "\n") {
matches := asdfVersionRegexp.FindAllStringSubmatch(line, -1)
if len(matches) == 0 {
continue
}
captureGroups := matches[0]
if len(captureGroups) != 4 {
// Entire match + 3 capture groups
return nil, fmt.Errorf("unexpected number of matches (%d) in input: %s, matches: %s", len(matches), line, matches)
}
tool := captureGroups[1]
version := captureGroups[2]
declaredBy := captureGroups[3]

if tool == "alias" {
// Meta-tool, ignore
continue
}

if version == "______" {
// No version is set globally, ignore
continue
}

isInstalled := !strings.HasPrefix(declaredBy, "Not installed.")
var declaredByFile string
var isGlobal bool
file := filepath.Base(declaredBy)
if file != "." && isInstalled {
declaredByFile = file
isGlobal = filepath.Dir(declaredBy) == r.userHomeDir
}

toolVersions[strings.ToLower(tool)] = ToolVersion{
Version: version,
IsInstalled: isInstalled,
DeclaredByFile: declaredByFile,
IsGlobal: isGlobal,
}
}

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

import (
"fmt"
"strings"
"testing"

"github.com/bitrise-io/go-utils/v2/command"
"github.com/bitrise-io/go-utils/v2/log"
"github.com/stretchr/testify/assert"
)

const validASDFOutput = `alias ______ No version is set. Run "asdf <global|shell|local> alias <version>"
flutter 3.16.1-stable /Users/bitrise/.tool-versions
golang 1.18 /Users/bitrise/Projects/steps/.tool-versions
java 17 Not installed. Run "asdf install java 17"
nodejs 19.7.0 Not installed. Run "asdf install nodejs 19.7.0"
ruby 3.1.3 /Users/bitrise/.tool-versions
python ______ No version is set. Run "asdf <global|shell|local> python <version>"`

func TestIsAvailable(t *testing.T) {
tests := []struct {
name string
systemPath string
cmdOutput string
cmdExitCode int
expected bool
}{
{
name: "asdf is available",
systemPath: "/bin:/usr/bin:/root/.asdf/bin/asdf",
cmdOutput: validASDFOutput,
cmdExitCode: 0,
expected: true,
},
{
name: "asdf is not available",
systemPath: "/bin:/usr/bin",
cmdOutput: "",
cmdExitCode: 1,
expected: false,
},
{
name: "asdf is not working",
systemPath: "/bin:/usr/bin:/root/.asdf/bin/asdf",
cmdOutput: "",
cmdExitCode: 1,
expected: false,
},

}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := log.NewLogger()
logger.EnableDebugLog(true)
r := NewASDFVersionReporter(
fakeCommandLocator{path: tt.systemPath},
fakeCommandFactory{stdout: tt.cmdOutput, exitCode: tt.cmdExitCode},
logger,
"/Users/bitrise",
)

result := r.IsAvailable()
assert.Equal(t, tt.expected, result)
})
}
}

func TestCurrentToolVersions(t *testing.T) {
tests := []struct {
name string
cmdOutput string
cmdError error
expected map[string]ToolVersion
expectErr bool
}{
{
name: "valid output",
cmdOutput: validASDFOutput,
expected: map[string]ToolVersion{
"flutter": {
Version: "3.16.1-stable",
IsInstalled: true,
DeclaredByFile: ".tool-versions",
IsGlobal: true,
},
"golang": {
Version: "1.18",
IsInstalled: true,
DeclaredByFile: ".tool-versions",
IsGlobal: false,
},
"java": {
Version: "17",
IsInstalled: false,
DeclaredByFile: "",
IsGlobal: false,
},
"nodejs": {
Version: "19.7.0",
IsInstalled: false,
DeclaredByFile: "",
IsGlobal: false,
},
"ruby": {
Version: "3.1.3",
IsInstalled: true,
DeclaredByFile: ".tool-versions",
IsGlobal: true,
},
},
expectErr: false,
},
{
name: "empty output",
cmdOutput: "",
expected: map[string]ToolVersion{},
expectErr: false,
},
{
name: "invalid output",
cmdOutput: "error",
expected: map[string]ToolVersion{},
expectErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := log.NewLogger()
logger.EnableDebugLog(true)
r := NewASDFVersionReporter(
fakeCommandLocator{path: "/root/.asdf/bin/asdf"},
fakeCommandFactory{stdout: tt.cmdOutput},
logger,
"/Users/bitrise",
)

result, err := r.CurrentToolVersions()
if tt.expectErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}

type fakeCommandLocator struct {
path string
}

func (f fakeCommandLocator) LookPath(name string) (string, error) {
return f.path, nil
}

type fakeCommandFactory struct {
stdout string
exitCode int
}

func (f fakeCommandFactory) Create(name string, args []string, opts *command.Opts) command.Command {
return fakeCommand{
command: fmt.Sprintf("%s %s", name, strings.Join(args, " ")),
stdout: f.stdout,
exitCode: f.exitCode,
}
}

type fakeCommand struct {
command string
stdout string
stderr string
exitCode int
}

func (c fakeCommand) PrintableCommandArgs() string {
return c.command
}

func (c fakeCommand) Run() error {
if c.exitCode != 0 {
return fmt.Errorf("exit code %d", c.exitCode)
}
return nil
}

func (c fakeCommand) RunAndReturnExitCode() (int, error) {
if c.exitCode != 0 {
return c.exitCode, fmt.Errorf("exit code %d", c.exitCode)
}
return c.exitCode, nil
}

func (c fakeCommand) RunAndReturnTrimmedOutput() (string, error) {
if c.exitCode != 0 {
return "", fmt.Errorf("exit code %d", c.exitCode)
}
return c.stdout, nil
}

func (c fakeCommand) RunAndReturnTrimmedCombinedOutput() (string, error) {
if c.exitCode != 0 {
return "", fmt.Errorf("exit code %d", c.exitCode)
}
return fmt.Sprintf("%s%s", c.stdout, c.stderr), nil
}

func (c fakeCommand) Start() error {
return nil
}

func (c fakeCommand) Wait() error {
return nil
}
Loading