diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..c81f9cf --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,42 @@ +linters: + disable-all: true + enable: + - revive + +output: + sort-results: true + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + +linters-settings: + revive: + ignore-generated-header: true + severity: warning + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + - name: unreachable-code + - name: redefines-builtin-id diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b9732..983c366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,20 @@ +# 1.4.0 (March 14, 2022) + +## Enhancements + +* [IMPORTANT] Refactor Python support, making use of virtual environments to isolate dependencies for each Python package. + * Refer to README.md for new system dependencies. + # 1.3.1 (December 8, 2021) ## Enhancements + * Improved message for updating CLI version # 1.3.0 (October 6, 2021) ## Fixes + * Remove old binary in PowerShell terminal ([#125](https://github.com/akamai/cli/issues/125)). * Document CLI exit codes. * Review exit code when trying to install an already installed command ([#83](https://github.com/akamai/cli/issues/83)). diff --git a/Makefile b/Makefile index 93af0cc..4942902 100644 --- a/Makefile +++ b/Makefile @@ -1,41 +1,102 @@ +BIN = $(CURDIR)/bin +GOCMD = go +GOTEST = $(GOCMD) test +GOBUILD = $(GOCMD) build +M = $(shell echo ">") + +$(BIN): + @mkdir -p $@ +$(BIN)/%: | $(BIN) ; $(info $(M) Installing $(PACKAGE)...) + @tmp=$$(mktemp -d); \ + env GO111MODULE=off GOPATH=$$tmp GOBIN=$(BIN) $(GOCMD) get $(PACKAGE) \ + || ret=$$?; \ + rm -rf $$tmp ; exit $$ret + +GOIMPORTS = $(BIN)/goimports +$(BIN)/goimports: PACKAGE=golang.org/x/tools/cmd/goimports + +GOCOV = $(BIN)/gocov +$(BIN)/gocov: PACKAGE=github.com/axw/gocov/... + +GOCOVXML = $(BIN)/gocov-xml +$(BIN)/gocov-xml: PACKAGE=github.com/AlekSi/gocov-xml + +GOJUNITREPORT = $(BIN)/go-junit-report +$(BIN)/go-junit-report: PACKAGE=github.com/jstemmer/go-junit-report + +GOLANGCILINT = $(BIN)/golangci-lint +GOLANGCI_LINT_VERSION = v1.41.1 +$(BIN)/golangci-lint: ; $(info $(M) Installing golangci-lint...) + @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(BIN) $(GOLANGCI_LINT_VERSION) + +COVERAGE_MODE = atomic +COVERAGE_DIR = $(CURDIR)/test/coverage +COVERAGE_PROFILE = $(COVERAGE_DIR)/profile.out +COVERAGE_XML = $(COVERAGE_DIR)/coverage.xml +COVERAGE_HTML = $(COVERAGE_DIR)/index.html + .PHONY: all -all: fmt lint vet coverage +all: clean fmt-check lint coverage create-junit-report create-coverage-files clean-tools .PHONY: build -build: - go build -o akamai cli/main.go +build: ; $(info $(M) Building 'akamai' binary...) @ ## Build the binary from source + $(GOBUILD) -o $(CURDIR)/akamai cli/main.go .PHONY: test -test: - go test -count=1 ./... - -.PHONY: coverage-ui -coverage-ui: - go test -covermode=count -coverprofile=coverage.out ./... - go tool cover -html=coverage.out +test: ; $(info $(M) Running tests...) ## Run all unit tests + $(GOTEST) -count=1 ./... .PHONY: coverage -coverage: - go test -coverprofile coverage.out ./... - go tool cover -func coverage.out | grep total +coverage: ; $(info $(M) Running tests with coverage...) @ ## Run tests and generate coverage profile + @mkdir -p $(COVERAGE_DIR) + @$(GOTEST) -v -covermode=$(COVERAGE_MODE) \ + -coverprofile="$(COVERAGE_PROFILE)" ./... | tee test/tests.output -.PHONY: lint -lint: - golint -set_exit_status ./... +.PHONY: create-junit-report +create-junit-report: | $(GOJUNITREPORT) ; $(info $(M) Creating juint xml report) @ ## Generate junit-style coverage report + @cat $(CURDIR)/test/tests.output | $(GOJUNITREPORT) > $(CURDIR)/test/tests.xml + @sed -i -e 's/skip=/skipped=/g;s/ failures=/ errors="0" failures=/g' $(CURDIR)/test/tests.xml + +.PHONY: create-coverage-files +create-coverage-files: | $(GOCOV) $(GOCOVXML); $(info $(M) Creating coverage files...) @ ## Generate coverage report files + @$(GOCMD) tool cover -html=$(COVERAGE_PROFILE) -o $(COVERAGE_HTML) + @$(GOCOV) convert $(COVERAGE_PROFILE) | $(GOCOVXML) > $(COVERAGE_XML) -.PHONY: vet -vet: - go vet ./... +.PHONY: lint +lint: | $(GOLANGCILINT); $(info $(M) Running linter...) @ ## Run golangci-lint on all source files + @$(BIN)/golangci-lint run .PHONY: fmt -fmt: - go fmt ./... +fmt: | $(GOIMPORTS); $(info $(M) Running goimports...) @ ## Run goimports on all source files + @$(GOIMPORTS) -w . -.PHONY: release -release: - ./build.sh +.PHONY: fmt-check +fmt-check: | $(GOIMPORTS); $(info $(M) Running format and imports check...) @ ## Run goimports on all source files (do not modify files) + $(eval OUTPUT = $(shell $(GOIMPORTS) -l .)) + @if [ "$(OUTPUT)" != "" ]; then\ + echo "Found following files with incorrect format and/or imports:";\ + echo "$(OUTPUT)";\ + false;\ + fi +.PHONY: release +release: ; $(info $(M) Generating release binaries and signatures...) @ ## Generate release binaries + @./build.sh .PHONY: pack -pack: - tar -zcvf cli.tar.gz . \ No newline at end of file +pack: ; $(info $(M) Generating tarball...) @ ## Create cli tarball + @tar -zcf cli.tar.gz * + +.PHONY: ; clean +clean: ; $(info $(M) Removing 'bin' directory and test results...) @ ## Cleanup installed packages and test reports + @rm -rf $(BIN) + @rm -rf $(BIN)/test/tests.* $(BIN)/test/coverage + +clean-tools: ## Cleanup installed packages + @rm -rf $(BIN)/go* + +.PHONY: help +help: ## List all make targets + echo $(MAKEFILE_LIST) + @grep -E '^[ a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md index cfb93da..a09eda6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -

+


- + Akamai CLI usage

@@ -29,6 +29,15 @@ Install Akamai CLI by downloading a [release binary](https://github.com/akamai/c You can also use [Homebrew](#install-with-homebrew), [Docker](#install-with-docker), or compile from [source](#compile-from-source). +### System dependencies for Python-based packages + +If you're using a Python-based CLI package, install these extra dependencies: + +- Python 3.3 or above +- [Python 3 `pip` package installer](https://pip.pypa.io/en/stable/installation) +- [Python 3 `venv` module](https://docs.python.org/3/library/venv.html) +- Up-to-date common CA certificates for your operating system (PEM files) + ### Install from binaries Follow the instructions for your operating system. @@ -284,16 +293,16 @@ The package you install needs a `cli.json` file. This is where you specify the c - `commands`: Lists commands included in the package. - `name`: The command name, used as the executable name. - `aliases`: An array of aliases that invoke the same command. - - `version`: The command version. + - `version`: The command version. - `description`: A short description for the command. - `bin`: A URL to fetch a binary package from if it cannot be installed from source. The `bin` URL may contain the following placeholders: - - `{{.Version}}`: The command version. - - `{{.Name}}`: The command name. + - `{{.Version}}`: The command version. + - `{{.Name}}`: The command name. - `{{.OS}}`: The current operating system, either `windows`, `mac`, or `linux`. - - `{{.Arch}}`: The current OS architecture, either `386` or `amd64`. + - `{{.Arch}}`: The current OS architecture, either `386` or `amd64`. - `{{.BinSuffix}}`: The binary suffix for the current OS: `.exe` for `windows`. ### Example diff --git a/assets/package-list.json b/assets/package-list.json index 6d0a4e1..9b23e99 100644 --- a/assets/package-list.json +++ b/assets/package-list.json @@ -103,13 +103,24 @@ { - "title": "Certificate Provisioning Service (CPS)", - "name": "cps", - "version": "v1.0.8", - "url": "https://github.com/akamai/cli-cps", - "issues": "https://github.com/akamai/cli-cps/issues", - "commands": [{"name":"cps","aliases":["certs"],"version":"1.0.8","description":"Access Certificate Provisioning System (CPS) Information"}], - "requirements": {"python":"3.0.0"} + "title": "Certificate Provisioning Service (CPS)", + "name": "cps", + "version": "v1.0.9", + "url": "https://github.com/akamai/cli-cps", + "issues": "https://github.com/akamai/cli-cps/issues", + "commands": [ + { + "name": "cps", + "aliases": [ + "certs" + ], + "version": "1.0.9", + "description": "Access Certificate Provisioning System (CPS) Information" + } + ], + "requirements": { + "python": "3.0.0" + } }, diff --git a/cli/app/access_nix.go b/cli/app/access_nix.go index 3043c65..8159470 100644 --- a/cli/app/access_nix.go +++ b/cli/app/access_nix.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows // Copyright 2018. Akamai Technologies, Inc diff --git a/cli/app/firstrun.go b/cli/app/firstrun.go index f155331..55f9763 100644 --- a/cli/app/firstrun.go +++ b/cli/app/firstrun.go @@ -1,4 +1,5 @@ -//+build !nofirstrun +//go:build !nofirstrun +// +build !nofirstrun // Copyright 2018. Akamai Technologies, Inc // @@ -23,14 +24,12 @@ import ( "runtime" "strings" - "github.com/fatih/color" - "github.com/kardianos/osext" - "github.com/akamai/cli/pkg/config" - "github.com/akamai/cli/pkg/stats" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/tools" + "github.com/fatih/color" + "github.com/kardianos/osext" ) const ( diff --git a/cli/app/firstrun_noinstall.go b/cli/app/firstrun_noinstall.go index 2eb7b5c..90ad63a 100644 --- a/cli/app/firstrun_noinstall.go +++ b/cli/app/firstrun_noinstall.go @@ -1,4 +1,5 @@ -//+build nofirstrun +//go:build nofirstrun +// +build nofirstrun // Copyright 2018. Akamai Technologies, Inc // @@ -18,6 +19,7 @@ package app import ( "context" + "github.com/akamai/cli/pkg/stats" ) diff --git a/cli/app/run.go b/cli/app/run.go index 8cde436..4c62f69 100644 --- a/cli/app/run.go +++ b/cli/app/run.go @@ -102,18 +102,12 @@ func findCollisions(availableCmds []*cli.Command, args []string) error { metaCmds := []string{"help", "uninstall", "update"} for _, c := range metaCmds { if c == args[1] && len(args) > 2 { - if err := findDuplicate(availableCmds, args[2]); err != nil { - return err - } - - return nil + return findDuplicate(availableCmds, args[2]) } } // rest of commands: we need to check the first parameter (args[1]) - if err := findDuplicate(availableCmds, args[1]); err != nil { - return err - } + return findDuplicate(availableCmds, args[1]) } return nil diff --git a/pkg/app/cli.go b/pkg/app/cli.go index f6ca271..78e3771 100644 --- a/pkg/app/cli.go +++ b/pkg/app/cli.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path" "strings" "time" @@ -13,42 +14,16 @@ import ( "github.com/fatih/color" "github.com/kardianos/osext" + "github.com/mitchellh/go-homedir" "github.com/urfave/cli/v2" ) -const sleepTime24Hours = time.Hour * 24 +const sleep24HDuration = time.Hour * 24 // CreateApp creates and sets up *cli.App func CreateApp(ctx context.Context) *cli.App { - term := terminal.Get(ctx) - - appName := "akamai" - app := cli.NewApp() - app.Name = appName - app.HelpName = appName - app.Usage = "Akamai CLI" - app.Version = version.Version - app.Writer = term - app.ErrWriter = term.Error() - app.Copyright = "Copyright (C) Akamai Technologies, Inc" - app.EnableBashCompletion = true - app.BashComplete = DefaultAutoComplete - cli.VersionFlag = &cli.BoolFlag{ - Name: "version", - Usage: "Output CLI version", - } - - cli.BashCompletionFlag = &cli.BoolFlag{ - Name: "generate-auto-complete", - Hidden: true, - } - cli.HelpFlag = &cli.BoolFlag{ - Name: "help", - Usage: "show help", - } - - SetHelpTemplates() - app.Flags = []cli.Flag{ + app := createAppTemplate(ctx, "", "Akamai CLI", "", version.Version, false) + app.Flags = append(app.Flags, &cli.BoolFlag{ Name: "bash", Usage: "Output bash auto-complete", @@ -67,17 +42,7 @@ func CreateApp(ctx context.Context) *cli.App { Hidden: true, EnvVars: []string{"AKAMAI_CLI_DAEMON"}, }, - &cli.StringFlag{ - Name: "edgerc", - Usage: "edgerc config path passed to executed commands, defaults to ~/.edgerc", - Aliases: []string{"e"}, - }, - &cli.StringFlag{ - Name: "section", - Usage: "edgerc section name passed to executed commands, defaults to 'default'", - Aliases: []string{"s"}, - }, - } + ) app.Action = func(c *cli.Context) error { return defaultAction(c) @@ -99,7 +64,7 @@ func CreateApp(ctx context.Context) *cli.App { if c.IsSet("daemon") { for { - time.Sleep(sleepTime24Hours) + time.Sleep(sleep24HDuration) } } return nil @@ -108,6 +73,85 @@ func CreateApp(ctx context.Context) *cli.App { return app } +// CreateAppTemplate creates a basic *cli.App template +func CreateAppTemplate(ctx context.Context, commandName, usage, description, version string) *cli.App { + return createAppTemplate(ctx, commandName, usage, description, version, true) +} + +func createAppTemplate(ctx context.Context, commandName, usage, description, version string, useDefaults bool) *cli.App { + _, inCli := os.LookupEnv("AKAMAI_CLI") + term := terminal.Get(ctx) + + appName := "akamai" + if commandName != "" { + appName = "akamai-" + commandName + if inCli { + appName = "akamai " + commandName + } + } + + app := cli.NewApp() + app.Name = appName + app.HelpName = appName + app.Usage = usage + app.Description = description + app.Version = version + + app.Copyright = "Copyright (C) Akamai Technologies, Inc" + app.Writer = term + app.ErrWriter = term.Error() + app.EnableBashCompletion = true + app.BashComplete = DefaultAutoComplete + + var edgercpath, section string + if useDefaults { + edgercpath, _ = homedir.Dir() + edgercpath = path.Join(edgercpath, ".edgerc") + + section = "default" + } + + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "edgerc", + Aliases: []string{"e"}, + Usage: "Location of the credentials file", + Value: edgercpath, + EnvVars: []string{"AKAMAI_EDGERC"}, + }, + &cli.StringFlag{ + Name: "section", + Aliases: []string{"s"}, + Usage: "Section of the credentials file", + Value: section, + EnvVars: []string{"AKAMAI_EDGERC_SECTION"}, + }, + &cli.StringFlag{ + Name: "accountkey", + Aliases: []string{"account-key"}, + Usage: "Account switch key", + EnvVars: []string{"AKAMAI_EDGERC_ACCOUNT_KEY"}, + }, + } + + cli.VersionFlag = &cli.BoolFlag{ + Name: "version", + Usage: "Output CLI version", + } + cli.BashCompletionFlag = &cli.BoolFlag{ + Name: "generate-auto-complete", + Hidden: true, + } + cli.HelpFlag = &cli.BoolFlag{ + Name: "help", + Usage: "show help", + } + + SetHelpTemplates() + + return app +} + // DefaultAutoComplete ... func DefaultAutoComplete(ctx *cli.Context) { term := terminal.Get(ctx.Context) @@ -187,7 +231,7 @@ func SetHelpTemplates() { "{{if .Commands}} command [command flags]{{end}} "+ "{{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}"+ "\n\n{{end}}") + - "{{if .Description}}\n\n" + + "{{if .Description}}" + color.YellowString("Description:\n") + " {{.Description}}" + "\n\n{{end}}" + diff --git a/pkg/app/cli_test.go b/pkg/app/cli_test.go index 255ff78..c599694 100644 --- a/pkg/app/cli_test.go +++ b/pkg/app/cli_test.go @@ -32,6 +32,42 @@ func TestCreateApp(t *testing.T) { assert.NotNil(t, app.Before) } +func TestCreateAppTemplate_EmptyCommandName(t *testing.T) { + term := terminal.Color() + ctx := terminal.Context(context.Background(), term) + + app := CreateAppTemplate(ctx, "", "Akamai CLI", "some description", version.Version) + assert.Equal(t, "akamai", app.Name) + assert.Equal(t, "Akamai CLI", app.Usage) + assert.Equal(t, "some description", app.Description) + assert.Equal(t, version.Version, app.Version) + assert.Equal(t, term, app.Writer) + assert.Equal(t, term.Error(), app.ErrWriter) + assert.Equal(t, "Copyright (C) Akamai Technologies, Inc", app.Copyright) + assert.True(t, app.EnableBashCompletion) + assert.True(t, hasFlag(app, "edgerc")) + assert.True(t, hasFlag(app, "section")) + assert.True(t, hasFlag(app, "accountkey")) +} + +func TestCreateAppTemplate_NonEmptyCommandName(t *testing.T) { + term := terminal.Color() + ctx := terminal.Context(context.Background(), term) + + app := CreateAppTemplate(ctx, "test", "Akamai CLI", "some description", version.Version) + assert.Equal(t, "akamai-test", app.Name) +} + +func TestCreateAppTemplate_NonEmptyCommandNameWithEnvAKAMAI_CLI(t *testing.T) { + term := terminal.Color() + ctx := terminal.Context(context.Background(), term) + + assert.NoError(t, os.Setenv("AKAMAI_CLI", "")) + app := CreateAppTemplate(ctx, "test", "", "", "") + assert.Equal(t, "akamai test", app.Name) + assert.NoError(t, os.Unsetenv("AKAMAI_CLI")) +} + func TestVersion(t *testing.T) { term := terminal.Color() ctx := terminal.Context(context.Background(), term) diff --git a/pkg/commands/command.go b/pkg/commands/command.go index 12a8306..2ab717d 100644 --- a/pkg/commands/command.go +++ b/pkg/commands/command.go @@ -25,32 +25,44 @@ import ( "strings" "syscall" - "github.com/fatih/color" - "github.com/urfave/cli/v2" - "github.com/akamai/cli/pkg/app" "github.com/akamai/cli/pkg/git" "github.com/akamai/cli/pkg/packages" "github.com/akamai/cli/pkg/tools" + "github.com/akamai/cli/pkg/version" + "github.com/fatih/color" + "github.com/urfave/cli/v2" ) -type command struct { - Name string `json:"name"` - Aliases []string `json:"aliases"` - Version string `json:"version"` - Description string `json:"description"` - Usage string `json:"usage"` - Arguments string `json:"arguments"` - Bin string `json:"bin"` - AutoComplete bool `json:"auto-complete"` - - Flags []cli.Flag `json:"-"` - Docs string `json:"-"` - BinSuffix string `json:"-"` - OS string `json:"-"` - Arch string `json:"-"` - Subcommands []*cli.Command `json:"-"` -} +type ( + command struct { + Name string `json:"name"` + Aliases []string `json:"aliases"` + Version string `json:"version"` + Description string `json:"description"` + Usage string `json:"usage"` + Arguments string `json:"arguments"` + Bin string `json:"bin"` + AutoComplete bool `json:"auto-complete"` + + Flags []cli.Flag `json:"-"` + Docs string `json:"-"` + BinSuffix string `json:"-"` + OS string `json:"-"` + Arch string `json:"-"` + Subcommands []*cli.Command `json:"-"` + } + + // Command represents an external command being prepared or run + Command struct { + cmd *exec.Cmd + } + + // Cmd is a wrapper for exec.Cmd methods + Cmd interface { + Run() error + } +) func getBuiltinCommands(c *cli.Context) []subcommands { commands := make([]subcommands, 0) @@ -107,13 +119,19 @@ func subcommandToCliCommands(from subcommands, gitRepo git.Repository, langManag SkipFlagParsing: true, BashComplete: func(c *cli.Context) { if command.AutoComplete { - executable, err := findExec(c.Context, langManager, c.Command.Name) + executable, packageReqs, err := findExec(c.Context, langManager, c.Command.Name) + if err != nil { + return + } + + packageDir, err := findBinPackageDir(executable) if err != nil { return } executable = append(executable, os.Args[2:]...) - if err = passthruCommand(executable); err != nil { + subCmd := createCommand(executable[0], executable[1:]) + if err = passthruCommand(c.Context, subCmd, langManager, *packageReqs, packageDir); err != nil { return } } @@ -267,7 +285,8 @@ func createInstalledCommands(_ context.Context, gitRepo git.Repository, langMana return commands } -func findExec(ctx context.Context, langManager packages.LangManager, cmd string) ([]string, error) { +// findExec returns paths to language interpreter (if necessary) and package binary +func findExec(ctx context.Context, langManager packages.LangManager, cmd string) ([]string, *packages.LanguageRequirements, error) { // "command" becomes: akamai-command, and akamaiCommand // "command-name" becomes: akamai-command-name, and akamaiCommandName cmdName := "akamai" @@ -280,7 +299,7 @@ func findExec(ctx context.Context, langManager packages.LangManager, cmd string) systemPath := os.Getenv("PATH") packagePaths := getPackageBinPaths() if err := os.Setenv("PATH", packagePaths); err != nil { - return nil, err + return nil, nil, err } // Quick look for executables on the path @@ -292,16 +311,16 @@ func findExec(ctx context.Context, langManager packages.LangManager, cmd string) if path != "" { if err := os.Setenv("PATH", systemPath); err != nil { - return nil, err + return nil, nil, err } - return []string{path}, nil + return []string{path}, nil, nil } if err := os.Setenv("PATH", systemPath); err != nil { - return nil, err + return nil, nil, err } if packagePaths == "" { - return nil, errors.New("no executables found") + return nil, nil, packages.ErrNoExeFound } for _, path := range filepath.SplitList(packagePaths) { @@ -328,29 +347,28 @@ func findExec(ctx context.Context, langManager packages.LangManager, cmd string) continue } - cmdFile := files[0] + cmdBinary := files[0] - packageDir := findPackageDir(filepath.Dir(cmdFile)) + packageDir := findPackageDir(filepath.Dir(cmdBinary)) cmdPackage, err := readPackage(packageDir) if err != nil { - return nil, err + return nil, nil, err } - cmd, err := langManager.FindExec(ctx, cmdPackage.Requirements, cmdFile) + comm, err := langManager.FindExec(ctx, cmdPackage.Requirements, cmdBinary) if err != nil { - return nil, err + return nil, nil, err } - return cmd, nil + return comm, &cmdPackage.Requirements, nil } - return nil, errors.New("no executables found") + return nil, nil, packages.ErrNoExeFound } func getPackageBinPaths() string { path := "" - akamaiCliPath, err := tools.GetAkamaiCliSrcPath() - if err == nil && akamaiCliPath != "" { + if akamaiCliPath, err := tools.GetAkamaiCliSrcPath(); err == nil { paths, _ := filepath.Glob(filepath.Join(akamaiCliPath, "*")) if len(paths) > 0 { path += strings.Join(paths, string(os.PathListSeparator)) @@ -364,11 +382,31 @@ func getPackageBinPaths() string { return path } -func passthruCommand(executable []string) error { - subCmd := exec.Command(executable[0], executable[1:]...) - subCmd.Stdin = os.Stdin - subCmd.Stderr = os.Stderr - subCmd.Stdout = os.Stdout +// passthruCommand performs the external Cmd invocation and previous set up, if required +func passthruCommand(ctx context.Context, subCmd Cmd, langManager packages.LangManager, languageRequirements packages.LanguageRequirements, dirName string) error { + /* + Checking if any additional VE set up for Python commands is required. + Just run package setup if both conditions below are met: + * required python >= 3.0.0 + * virtual environment is not present + */ + if v3Comparison := version.Compare(languageRequirements.Python, "3.0.0"); languageRequirements.Python != "" && (v3Comparison == version.Greater || v3Comparison == version.Equals) { + vePath, err := tools.GetPkgVenvPath(filepath.Base(dirName)) + if err != nil { + return err + } + veExists, err := langManager.FileExists(vePath) + if err != nil { + return err + } + if !veExists { + if err := langManager.PrepareExecution(ctx, languageRequirements, dirName); err != nil { + return err + } + } + } + defer langManager.FinishExecution(ctx, languageRequirements, dirName) + err := subCmd.Run() exitCode := 1 @@ -382,3 +420,38 @@ func passthruCommand(executable []string) error { } return nil } + +func findBinPackageDir(binPath []string) (string, error) { + if len(binPath) == 0 { + return "", packages.ErrPackageExecutableNotFound + } + + absPath, err := filepath.Abs(binPath[len(binPath)-1]) + if err != nil { + return "", err + } + + info, err := os.Stat(absPath) + if err != nil { + return "", err + } + if info.IsDir() { + return "", errors.New("the specified binary is a directory") + } + + return filepath.Dir(filepath.Dir(absPath)), nil +} + +func createCommand(name string, args []string) *Command { + comm := &Command{cmd: exec.Command(name, args...)} + comm.cmd.Stdin = os.Stdin + comm.cmd.Stderr = os.Stderr + comm.cmd.Stdout = os.Stdout + + return comm +} + +// Run starts the command and waits for it to complete +func (c *Command) Run() error { + return c.cmd.Run() +} diff --git a/pkg/commands/command_config.go b/pkg/commands/command_config.go index 1755a22..bf1393f 100644 --- a/pkg/commands/command_config.go +++ b/pkg/commands/command_config.go @@ -16,14 +16,13 @@ package commands import ( "fmt" - "github.com/akamai/cli/pkg/log" - "github.com/fatih/color" "strings" "time" "github.com/akamai/cli/pkg/config" + "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/terminal" - + "github.com/fatih/color" "github.com/urfave/cli/v2" ) @@ -34,7 +33,7 @@ func cmdConfigSet(c *cli.Context) (e error) { logger.Debug("CONFIG SET START") defer func() { if e == nil { - logger.Debugf("CONFIG SET FINISH: %v", time.Now().Sub(start)) + logger.Debugf("CONFIG SET FINISH: %v", time.Since(start)) } else { logger.Errorf("CONFIG SET ERROR: %v", e.Error()) } @@ -59,7 +58,7 @@ func cmdConfigGet(c *cli.Context) (e error) { logger.Debug("CONFIG GET START") defer func() { if e == nil { - logger.Debugf("CONFIG GET FINISH: %v", time.Now().Sub(start)) + logger.Debugf("CONFIG GET FINISH: %v", time.Since(start)) } else { logger.Errorf("CONFIG GET ERROR: %v", e.Error()) } @@ -82,7 +81,7 @@ func cmdConfigUnset(c *cli.Context) (e error) { logger.Debug("CONFIG UNSET START") defer func() { if e == nil { - logger.Debugf("CONFIG UNSET FINISH: %v", time.Now().Sub(start)) + logger.Debugf("CONFIG UNSET FINISH: %v", time.Since(start)) } else { logger.Errorf("CONFIG UNSET ERROR: %v", e.Error()) } @@ -107,7 +106,7 @@ func cmdConfigList(c *cli.Context) (e error) { logger.Debug("CONFIG LIST START") defer func() { if e == nil { - logger.Debugf("CONFIG LIST FINISH: %v", time.Now().Sub(start)) + logger.Debugf("CONFIG LIST FINISH: %v", time.Since(start)) } else { logger.Errorf("CONFIG LIST ERROR: %v", e.Error()) } diff --git a/pkg/commands/command_config_test.go b/pkg/commands/command_config_test.go index 0ee217e..188e826 100644 --- a/pkg/commands/command_config_test.go +++ b/pkg/commands/command_config_test.go @@ -2,13 +2,14 @@ package commands import ( "fmt" + "os" + "testing" + "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "os" - "testing" ) func TestCmdConfigSet(t *testing.T) { @@ -41,7 +42,7 @@ func TestCmdConfigSet(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", Subcommands: []*cli.Command{ @@ -93,7 +94,7 @@ func TestCmdConfigGet(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", Subcommands: []*cli.Command{ @@ -153,7 +154,7 @@ func TestCmdConfigUnset(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", Subcommands: []*cli.Command{ @@ -223,7 +224,7 @@ func TestCmdConfigList(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", Subcommands: []*cli.Command{ diff --git a/pkg/commands/command_help.go b/pkg/commands/command_help.go index a52e3cc..a876157 100644 --- a/pkg/commands/command_help.go +++ b/pkg/commands/command_help.go @@ -15,9 +15,9 @@ package commands import ( - "github.com/akamai/cli/pkg/log" "os" + "github.com/akamai/cli/pkg/log" "github.com/urfave/cli/v2" ) diff --git a/pkg/commands/command_help_test.go b/pkg/commands/command_help_test.go index b81b980..f1fea44 100644 --- a/pkg/commands/command_help_test.go +++ b/pkg/commands/command_help_test.go @@ -2,15 +2,16 @@ package commands import ( "bytes" + "os" + "regexp" + "testing" + "github.com/akamai/cli/pkg/app" "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "os" - "regexp" - "testing" ) func TestCmdHelp(t *testing.T) { @@ -51,7 +52,7 @@ func TestCmdHelp(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} wr := bytes.Buffer{} testApp, ctx := setupTestApp(test.cmd, m) testApp.Commands = append(testApp.Commands, &cli.Command{ diff --git a/pkg/commands/command_install.go b/pkg/commands/command_install.go index b533728..c7cf891 100644 --- a/pkg/commands/command_install.go +++ b/pkg/commands/command_install.go @@ -18,20 +18,19 @@ import ( "context" "errors" "fmt" - "github.com/akamai/cli/pkg/log" "os" "path/filepath" "strings" "time" - "github.com/fatih/color" - "github.com/urfave/cli/v2" - "github.com/akamai/cli/pkg/git" + "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/packages" "github.com/akamai/cli/pkg/stats" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/tools" + "github.com/fatih/color" + "github.com/urfave/cli/v2" ) var ( @@ -46,7 +45,7 @@ func cmdInstall(git git.Repository, langManager packages.LangManager) cli.Action logger.Debug("INSTALL START") defer func() { if e == nil { - logger.Debugf("INSTALL FINISH: %v", time.Now().Sub(start)) + logger.Debugf("INSTALL FINISH: %v", time.Since(start)) } else { var exitErr cli.ExitCoder if errors.As(e, &exitErr) && exitErr.ExitCode() == 0 { @@ -91,16 +90,12 @@ func packageListDiff(c *cli.Context, oldcmds []subcommands) { var old []command for _, oldcmd := range oldcmds { - for _, cmd := range oldcmd.Commands { - old = append(old, cmd) - } + old = append(old, oldcmd.Commands...) } var newCmds []command for _, newcmd := range cmds { - for _, cmd := range newcmd.Commands { - newCmds = append(newCmds, cmd) - } + newCmds = append(newCmds, newcmd.Commands...) } var added = make(map[string]bool) diff --git a/pkg/commands/command_install_test.go b/pkg/commands/command_install_test.go index 3720ccc..39ad2fc 100644 --- a/pkg/commands/command_install_test.go +++ b/pkg/commands/command_install_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" + git2 "gopkg.in/src-d/go-git.v4" ) func TestCmdInstall(t *testing.T) { @@ -37,7 +38,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") }) m.term.On("OK").Return().Once() m.term.On("Spinner").Return(m.term).Once() @@ -70,7 +71,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") input, err := ioutil.ReadFile("./testdata/.akamai-cli/src/cli-test-cmd/cli.json") require.NoError(t, err) output := strings.ReplaceAll(string(input), "${REPOSITORY_URL}", os.Getenv("REPOSITORY_URL")) @@ -106,7 +107,7 @@ func TestCmdInstall(t *testing.T) { require.NoError(t, os.RemoveAll("./testdata/.akamai-cli/src/cli-test-cmd")) }, }, - "package already exists": { + "package directory already exists": { args: []string{"installed"}, init: func(t *testing.T, m *mocked) { m.term.On("Spinner").Return(m.term).Once() @@ -130,10 +131,11 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(fmt.Errorf("oops")).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") }) m.term.On("Stop", terminal.SpinnerStatusFail).Return().Once() m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) + m.gitRepo.On("Clone", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(git2.ErrRepositoryAlreadyExists) }, withError: "Unable to clone repository: oops", }, @@ -145,7 +147,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-invalid-json", "https://github.com/akamai/cli-test-invalid-json.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo_invalid_json/cli.json", "./testdata/.akamai-cli/src/cli-test-invalid-json") + mustCopyFile(t, "./testdata/repo_invalid_json/cli.json", "./testdata/.akamai-cli/src/cli-test-invalid-json") }) m.term.On("Spinner").Return(m.term).Once() m.term.On("OK").Return().Once() @@ -171,7 +173,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") }) m.term.On("Spinner").Return(m.term).Once() m.term.On("OK").Return().Once() @@ -201,7 +203,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") input, err := ioutil.ReadFile("./testdata/.akamai-cli/src/cli-test-cmd/cli.json") require.NoError(t, err) output := strings.ReplaceAll(string(input), "${REPOSITORY_URL}", os.Getenv("REPOSITORY_URL")) @@ -240,7 +242,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") }) m.term.On("Spinner").Return(m.term).Once() m.term.On("OK").Return().Once() @@ -278,7 +280,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") input, err := ioutil.ReadFile("./testdata/.akamai-cli/src/cli-test-cmd/cli.json") require.NoError(t, err) output := strings.ReplaceAll(string(input), "${REPOSITORY_URL}", os.Getenv("REPOSITORY_URL")) @@ -321,7 +323,7 @@ func TestCmdInstall(t *testing.T) { m.gitRepo.On("Clone", "testdata/.akamai-cli/src/cli-test-cmd", "https://github.com/akamai/cli-test-cmd.git", false, m.term).Return(nil).Once(). Run(func(args mock.Arguments) { - copyFile(t, "./testdata/repo_no_binary/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") + mustCopyFile(t, "./testdata/repo_no_binary/cli.json", "./testdata/.akamai-cli/src/cli-test-cmd") input, err := ioutil.ReadFile("./testdata/.akamai-cli/src/cli-test-cmd/cli.json") require.NoError(t, err) output := strings.ReplaceAll(string(input), "${REPOSITORY_URL}", os.Getenv("REPOSITORY_URL")) @@ -365,7 +367,7 @@ func TestCmdInstall(t *testing.T) { defer srv.Close() require.NoError(t, os.Setenv("REPOSITORY_URL", srv.URL)) require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) - m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}} + m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}, nil} command := &cli.Command{ Name: "install", Action: cmdInstall(m.gitRepo, m.langManager), diff --git a/pkg/commands/command_list.go b/pkg/commands/command_list.go index 7af99c1..468f877 100644 --- a/pkg/commands/command_list.go +++ b/pkg/commands/command_list.go @@ -16,15 +16,13 @@ package commands import ( "fmt" - "github.com/akamai/cli/pkg/log" "time" + "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/terminal" - + "github.com/akamai/cli/pkg/tools" "github.com/fatih/color" "github.com/urfave/cli/v2" - - "github.com/akamai/cli/pkg/tools" ) func cmdList(c *cli.Context) (e error) { @@ -34,7 +32,7 @@ func cmdList(c *cli.Context) (e error) { logger.Debug("LIST START") defer func() { if e == nil { - logger.Debugf("LIST FINISH: %v", time.Now().Sub(start)) + logger.Debugf("LIST FINISH: %v", time.Since(start)) } else { logger.Errorf("LIST ERROR: %v", e.Error()) } diff --git a/pkg/commands/command_list_test.go b/pkg/commands/command_list_test.go index 89e1341..c267a5b 100644 --- a/pkg/commands/command_list_test.go +++ b/pkg/commands/command_list_test.go @@ -2,6 +2,11 @@ package commands import ( "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/tools" @@ -9,10 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "net/http" - "net/http/httptest" - "os" - "testing" ) func TestCmdListWithRemote(t *testing.T) { @@ -65,7 +66,7 @@ func TestCmdListWithRemote(t *testing.T) { require.NoError(t, os.Setenv("AKAMAI_CLI_PACKAGE_REPO", srv.URL)) require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) t.Run(name, func(t *testing.T) { - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "list", Flags: []cli.Flag{ diff --git a/pkg/commands/command_search.go b/pkg/commands/command_search.go index 1f7caae..cad6595 100644 --- a/pkg/commands/command_search.go +++ b/pkg/commands/command_search.go @@ -18,7 +18,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/akamai/cli/pkg/log" "io/ioutil" "net/http" "os" @@ -26,12 +25,11 @@ import ( "strings" "time" + "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/terminal" - + "github.com/akamai/cli/pkg/tools" "github.com/fatih/color" "github.com/urfave/cli/v2" - - "github.com/akamai/cli/pkg/tools" ) type packageList struct { @@ -62,7 +60,7 @@ func cmdSearch(c *cli.Context) (e error) { logger.Debug("SEARCH START") defer func() { if e == nil { - logger.Debugf("SEARCH FINISHED: %v", time.Now().Sub(start)) + logger.Debugf("SEARCH FINISHED: %v", time.Since(start)) } else { logger.Errorf("SEARCH ERROR: %v", e.Error()) } diff --git a/pkg/commands/command_search_test.go b/pkg/commands/command_search_test.go index ff68c43..90a1782 100644 --- a/pkg/commands/command_search_test.go +++ b/pkg/commands/command_search_test.go @@ -2,6 +2,12 @@ package commands import ( "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "testing" + "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/tools" @@ -9,11 +15,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "io/ioutil" - "net/http" - "net/http/httptest" - "os" - "testing" ) func TestCmdSearch(t *testing.T) { @@ -106,7 +107,7 @@ func TestCmdSearch(t *testing.T) { require.NoError(t, os.Setenv("AKAMAI_CLI_PACKAGE_REPO", srv.URL)) require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) t.Run(name, func(t *testing.T) { - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "search", Action: cmdSearch, diff --git a/pkg/commands/command_subcommand.go b/pkg/commands/command_subcommand.go index aa7b2ff..b41ab76 100644 --- a/pkg/commands/command_subcommand.go +++ b/pkg/commands/command_subcommand.go @@ -21,16 +21,13 @@ import ( "runtime" "strings" + "github.com/akamai/cli/pkg/git" "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/packages" - "github.com/akamai/cli/pkg/stats" "github.com/akamai/cli/pkg/terminal" - "github.com/fatih/color" "github.com/urfave/cli/v2" - - "github.com/akamai/cli/pkg/git" ) func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.ActionFunc { @@ -41,7 +38,7 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act commandName := strings.ToLower(c.Command.Name) - executable, err := findExec(c.Context, langManager, commandName) + executable, _, err := findExec(c.Context, langManager, commandName) if err != nil { errMsg := color.RedString("Executable \"%s\" not found.", commandName) logger.Error(errMsg) @@ -58,7 +55,20 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act cmdPackage, _ := readPackage(packageDir) if cmdPackage.Requirements.Python != "" { - var err error + exec, err := langManager.FindExec(c.Context, cmdPackage.Requirements, packageDir) + if err != nil { + return err + } + + if len(executable) == 1 { + executable = append([]string{exec[0]}, executable...) + } else { + if strings.Contains(strings.ToLower(executable[0]), "python") || + strings.Contains(strings.ToLower(executable[0]), "py.exe") { + executable[0] = exec[0] + } + } + if runtime.GOOS == "linux" { _, err = os.Stat(filepath.Join(packageDir, ".local")) } else if runtime.GOOS == "darwin" { @@ -105,26 +115,54 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act } } - executable = append(executable, c.Args().Slice()...) if err := os.Setenv("AKAMAI_CLI_COMMAND", commandName); err != nil { return err } if err := os.Setenv("AKAMAI_CLI_COMMAND_VERSION", currentCmd.Version); err != nil { return err } + + cmdPackage, err = readPackage(packageDir) + if err != nil { + return err + } + stats.TrackEvent(c.Context, "exec", commandName, currentCmd.Version) - executable = findAndAppendFlags(c, executable, "edgerc", "section") - return passthruCommand(executable) + + executable = prepareCommand(c, executable, c.Args().Slice(), "edgerc", "section", "accountkey") + + subCmd := createCommand(executable[0], executable[1:]) + return passthruCommand(c.Context, subCmd, langManager, cmdPackage.Requirements, fmt.Sprintf("cli-%s", cmdPackage.Commands[0].Name)) } } -func findAndAppendFlags(c *cli.Context, target []string, flags ...string) []string { +func prepareCommand(c *cli.Context, command, args []string, flags ...string) []string { + // dont search for flags is there are no args + if len(args) == 0 { + return command + } + additionalFlags := findFlags(c, args, flags...) + + if len(command) > 1 { + // for python or js append flags to the end + command = append(command, args...) + command = append(command, additionalFlags...) + } else { + command = append(command, additionalFlags...) + command = append(command, args...) + } + + return command +} + +func findFlags(c *cli.Context, target []string, flags ...string) []string { + var ret []string for _, flagName := range flags { if flagVal := c.String(flagName); flagVal != "" && !containsString(target, fmt.Sprintf("--%s", flagName)) { - target = append(target, fmt.Sprintf("--%s", flagName), flagVal) + ret = append(ret, fmt.Sprintf("--%s", flagName), flagVal) } } - return target + return ret } func containsString(s []string, item string) bool { diff --git a/pkg/commands/command_subcommand_test.go b/pkg/commands/command_subcommand_test.go index 5a75430..9f67f85 100644 --- a/pkg/commands/command_subcommand_test.go +++ b/pkg/commands/command_subcommand_test.go @@ -3,6 +3,7 @@ package commands import ( "flag" "os" + "os/exec" "testing" "github.com/akamai/cli/pkg/config" @@ -28,6 +29,8 @@ func TestCmdSubcommand(t *testing.T) { args: []string{"abc"}, init: func(t *testing.T, m *mocked) { m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Once() }, }, "run installed akamai echo command as binary with edgerc location": { @@ -35,6 +38,8 @@ func TestCmdSubcommand(t *testing.T) { args: []string{"abc"}, init: func(t *testing.T, m *mocked) { m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Once() }, }, "run installed akamai echo command as binary with alias": { @@ -44,13 +49,8 @@ func TestCmdSubcommand(t *testing.T) { section: "some_section", init: func(t *testing.T, m *mocked) { m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) - }, - }, - "run installed akamai echo command with python required": { - command: "echo-python", - args: []string{"abc"}, - init: func(t *testing.T, m *mocked) { - m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Once() }, }, "run installed akamai echo command as .cmd file": { @@ -60,6 +60,8 @@ func TestCmdSubcommand(t *testing.T) { m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) m.langManager.On("FindExec", packages.LanguageRequirements{Go: "1.14.0"}, "testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-cmd.cmd"). Return([]string{"testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-cmd.cmd"}, nil) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Once() }, }, "run installed python akamai echo command": { @@ -69,6 +71,8 @@ func TestCmdSubcommand(t *testing.T) { m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) m.langManager.On("FindExec", packages.LanguageRequirements{Go: "1.14.0"}, "testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-cmd.cmd"). Return([]string{"testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-cmd.cmd"}, nil) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Go: "1.14.0"}, "cli-echo").Once() }, }, "executable not found": { @@ -82,7 +86,7 @@ func TestCmdSubcommand(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) - m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}} + m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}, nil} command := &cli.Command{ Name: test.command, Action: cmdSubcommand(m.gitRepo, m.langManager), @@ -112,38 +116,167 @@ func TestCmdSubcommand(t *testing.T) { } } -func TestFindAndAppendFlag(t *testing.T) { +func TestPythonCmdSubcommand(t *testing.T) { + // run installed akamai echo command with python required + t.Run( + "run installed akamai echo command with python required", + func(t *testing.T) { + require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) + m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}, nil} + command := &cli.Command{ + Name: "echo-python", + Action: cmdSubcommand(m.gitRepo, m.langManager), + } + app, ctx := setupTestApp(command, m) + args := os.Args[0:1] + args = append(args, "echo-python") + args = append(args, "abc") + + // Using the system python avoids the need of shipping a python + // interpreter together with the test data. This wouldn't be a + // good option because of different OSes and CPU architectures. + if pythonBin, err := exec.LookPath("python"); err != nil { + // If python is not available, just skip the test + t.Skipf("We could not find any available Python binary, thus we skip this test. Details: \n%s", err.Error()) + } else { + m.cfg.On("GetValue", "cli", "enable-cli-statistics").Return("false", true) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Python: "3.0.0"}, "cli-echo-python").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Python: "3.0.0"}, "cli-echo-python").Return(nil).Once() + m.langManager.On("FindExec", packages.LanguageRequirements{Python: "3.0.0"}, "testdata/.akamai-cli/src/cli-echo-python"). + Return([]string{pythonBin}, nil).Once() + m.langManager.On("FileExists", "testdata/.akamai-cli/venv/cli-echo-python").Return(true, nil) + } + + err := app.RunContext(ctx, args) + + m.cfg.AssertExpectations(t) + require.NoError(t, err) + }) +} + +func TestPrepareCommand(t *testing.T) { tests := map[string]struct { - flagsInCtx map[string]string - givenSlice []string - givenFlags []string - expected []string + flagsInCtx map[string]string + givenCommand []string + givenArgs []string + givenFlags []string + expected []string }{ - "flags found on context and not in slice": { + "no flags and no args": { + givenCommand: []string{"command"}, + expected: []string{"command"}, + }, + "no flags and with args": { + givenCommand: []string{"command"}, + givenArgs: []string{"--flag_1", "existing_value"}, + expected: []string{"command", "--flag_1", "existing_value"}, + }, + "flags and no args": { + flagsInCtx: map[string]string{ + "flag_1": "some_value", + "flag_2": "other_value", + "flag_3": "abc", + }, + givenCommand: []string{"command"}, + givenFlags: []string{"flag_2"}, + expected: []string{"command"}, + }, + "flags and args not overlaping": { + flagsInCtx: map[string]string{ + "flag_1": "some_value", + "flag_2": "other_value", + "flag_3": "abc", + }, + givenCommand: []string{"command"}, + givenArgs: []string{"--flag_1", "existing_value"}, + givenFlags: []string{"flag_2"}, + expected: []string{"command", "--flag_2", "other_value", "--flag_1", "existing_value"}, + }, + "flags found in context but not in args": { flagsInCtx: map[string]string{ "flag_1": "some_value", "flag_2": "other_value", "flag_3": "abc", }, - givenSlice: []string{"some", "command"}, - givenFlags: []string{"flag_1", "flag_3"}, - expected: []string{"some", "command", "--flag_1", "some_value", "--flag_3", "abc"}, + givenCommand: []string{"command"}, + givenFlags: []string{"flag_1", "flag_3"}, + expected: []string{"command"}, }, - "flag found on context and in slice": { + "flag found in context and in args": { flagsInCtx: map[string]string{ "flag_1": "some_value", "flag_2": "other_value", "flag_3": "abc", }, - givenSlice: []string{"some", "command", "--flag_1", "existing_value"}, - givenFlags: []string{"flag_1"}, - expected: []string{"some", "command", "--flag_1", "existing_value"}, + givenCommand: []string{"command"}, + givenArgs: []string{"--flag_1", "existing_value"}, + givenFlags: []string{"flag_1"}, + expected: []string{"command", "--flag_1", "existing_value"}, }, "flag does not have value": { - flagsInCtx: map[string]string{}, - givenSlice: []string{"some", "command"}, - givenFlags: []string{"flag_1"}, - expected: []string{"some", "command"}, + flagsInCtx: map[string]string{}, + givenCommand: []string{"command"}, + givenFlags: []string{"flag_1"}, + expected: []string{"command"}, + }, + + // tests for script commands like python or js + "script - no flags and no args": { + givenCommand: []string{"some", "command"}, + expected: []string{"some", "command"}, + }, + "script - no flags and with args": { + givenCommand: []string{"some", "command"}, + givenArgs: []string{"--flag_1", "existing_value"}, + expected: []string{"some", "command", "--flag_1", "existing_value"}, + }, + "script - flags and no args": { + flagsInCtx: map[string]string{ + "flag_1": "some_value", + "flag_2": "other_value", + "flag_3": "abc", + }, + givenCommand: []string{"some", "command"}, + givenFlags: []string{"flag_2"}, + expected: []string{"some", "command"}, + }, + "script - flags and args not overlaping": { + flagsInCtx: map[string]string{ + "flag_1": "some_value", + "flag_2": "other_value", + "flag_3": "abc", + }, + givenCommand: []string{"some", "command"}, + givenArgs: []string{"--flag_1", "existing_value"}, + givenFlags: []string{"flag_2"}, + expected: []string{"some", "command", "--flag_1", "existing_value", "--flag_2", "other_value"}, + }, + "script - flags found in context but not in args": { + flagsInCtx: map[string]string{ + "flag_1": "some_value", + "flag_2": "other_value", + "flag_3": "abc", + }, + givenCommand: []string{"some", "command"}, + givenFlags: []string{"flag_1", "flag_3"}, + expected: []string{"some", "command"}, + }, + "script - flag found in context and in args": { + flagsInCtx: map[string]string{ + "flag_1": "some_value", + "flag_2": "other_value", + "flag_3": "abc", + }, + givenCommand: []string{"some", "command"}, + givenArgs: []string{"--flag_1", "existing_value"}, + givenFlags: []string{"flag_1"}, + expected: []string{"some", "command", "--flag_1", "existing_value"}, + }, + "script - flag does not have value": { + flagsInCtx: map[string]string{}, + givenCommand: []string{"some", "command"}, + givenFlags: []string{"flag_1"}, + expected: []string{"some", "command"}, }, } @@ -157,7 +290,7 @@ func TestFindAndAppendFlag(t *testing.T) { for name, value := range test.flagsInCtx { require.NoError(t, c.Set(name, value)) } - res := findAndAppendFlags(c, test.givenSlice, test.givenFlags...) + res := prepareCommand(c, test.givenCommand, test.givenArgs, test.givenFlags...) assert.Equal(t, test.expected, res) }) } diff --git a/pkg/commands/command_test.go b/pkg/commands/command_test.go index e9a18f1..19ff7f4 100644 --- a/pkg/commands/command_test.go +++ b/pkg/commands/command_test.go @@ -2,8 +2,11 @@ package commands import ( "context" + "errors" "fmt" + "io/fs" "os" + "os/exec" "strings" "testing" @@ -38,3 +41,116 @@ func TestSubcommandsToCliCommands_packagePrefix(t *testing.T) { assert.True(t, strings.HasPrefix(cmd.Aliases[0], fmt.Sprintf("%s/", from.Pkg)), "there should be an alias with the package prefix") } } + +func TestPassthruCommand(t *testing.T) { + tests := map[string]struct { + executable []string + init func(*mocked) + langRequirements packages.LanguageRequirements + dirName string + withError error + }{ + "golang binary": { + executable: []string{"./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo"}, + init: func(m *mocked) { + m.langManager.On( + "FinishExecution", packages.LanguageRequirements{Go: "1.15.0"}, + "./testdata/.akamai-cli/src/cli-echo/").Once() + m.cmd.On("Run").Return(nil).Once() + }, + langRequirements: packages.LanguageRequirements{Go: "1.15.0"}, + dirName: "./testdata/.akamai-cli/src/cli-echo/", + }, + "python 2": { + executable: []string{"./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-python"}, + init: func(m *mocked) { + m.langManager.On("FinishExecution", packages.LanguageRequirements{Python: "2.7.10"}, "./testdata/.akamai-cli/src/cli-echo-python/").Once() + m.cmd.On("Run").Return(nil).Once() + }, + langRequirements: packages.LanguageRequirements{Python: "2.7.10"}, + dirName: "./testdata/.akamai-cli/src/cli-echo-python/", + }, + "python 3, ve exists": { + executable: []string{"./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-python"}, + init: func(m *mocked) { + m.langManager.On("FileExists", "testdata/.akamai-cli/venv/cli-echo-python").Return(true, nil) + m.langManager.On("FinishExecution", packages.LanguageRequirements{Python: "3.0.0"}, "./testdata/.akamai-cli/src/cli-echo-python/").Once() + m.cmd.On("Run").Return(nil).Once() + }, + langRequirements: packages.LanguageRequirements{Python: "3.0.0"}, + dirName: "./testdata/.akamai-cli/src/cli-echo-python/", + }, + "python 3, ve does not exist": { + executable: []string{"./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-python"}, + init: func(m *mocked) { + m.langManager.On("FileExists", "testdata/.akamai-cli/venv/cli-echo-python").Return(false, nil) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Python: "3.0.0"}, "./testdata/.akamai-cli/src/cli-echo-python/").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Python: "3.0.0"}, "./testdata/.akamai-cli/src/cli-echo-python/").Once() + m.cmd.On("Run").Return(nil).Once() + }, + langRequirements: packages.LanguageRequirements{Python: "3.0.0"}, + dirName: "./testdata/.akamai-cli/src/cli-echo-python/", + }, + "python 3, ve does not exist - error running the external command": { + executable: []string{"./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-python"}, + init: func(m *mocked) { + m.langManager.On("FileExists", "testdata/.akamai-cli/venv/cli-echo-python").Return(false, nil) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Python: "3.0.0"}, "./testdata/.akamai-cli/src/cli-echo-python/").Return(nil).Once() + m.langManager.On("FinishExecution", packages.LanguageRequirements{Python: "3.0.0"}, "./testdata/.akamai-cli/src/cli-echo-python/").Return().Once() + m.cmd.On("Run").Return(&exec.ExitError{ProcessState: &os.ProcessState{}}).Once() + }, + langRequirements: packages.LanguageRequirements{Python: "3.0.0"}, + dirName: "./testdata/.akamai-cli/src/cli-echo-python/", + withError: fmt.Errorf("wanted"), + }, + "python 3, ve does not exist - error preparing execution": { + executable: []string{"./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-python"}, + init: func(m *mocked) { + m.langManager.On("FileExists", "testdata/.akamai-cli/venv/cli-echo-python").Return(false, nil) + m.langManager.On("PrepareExecution", packages.LanguageRequirements{Python: "3.0.0"}, "./testdata/.akamai-cli/src/cli-echo-python/").Return(packages.ErrPackageManagerExec).Once() + }, + langRequirements: packages.LanguageRequirements{Python: "3.0.0"}, + dirName: "./testdata/.akamai-cli/src/cli-echo-python/", + withError: packages.ErrPackageManagerExec, + }, + "python 3 - fs permission error to read VE": { + executable: []string{"./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-python"}, + init: func(m *mocked) { + m.langManager.On("FileExists", "testdata/.akamai-cli/venv/cli-echo-python").Return(false, fs.ErrPermission) + }, + langRequirements: packages.LanguageRequirements{Python: "3.0.0"}, + dirName: "./testdata/.akamai-cli/src/cli-echo-python/", + withError: fs.ErrPermission, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cliHome, cliHomeOK := os.LookupEnv("AKAMAI_CLI_HOME") + if err := os.Setenv("AKAMAI_CLI_HOME", "testdata"); err != nil { + return + } + defer func() { + if cliHomeOK { + _ = os.Setenv("AKAMAI_CLI_HOME", cliHome) + } else { + _ = os.Unsetenv("AKAMAI_CLI_HOME") + } + }() + + m := &mocked{langManager: &packages.Mock{}, cmd: &MockCmd{}} + test.init(m) + + err := passthruCommand(context.Background(), m.cmd, m.langManager, test.langRequirements, test.dirName) + + m.cmd.AssertExpectations(t) + m.langManager.AssertExpectations(t) + + if test.withError != nil { + assert.True(t, errors.As(err, &test.withError), "want: '%s'; got '%s'", test.withError.Error(), err.Error()) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/pkg/commands/command_uninstall.go b/pkg/commands/command_uninstall.go index 5129525..6f96c36 100644 --- a/pkg/commands/command_uninstall.go +++ b/pkg/commands/command_uninstall.go @@ -17,16 +17,15 @@ package commands import ( "context" "fmt" - "github.com/akamai/cli/pkg/log" - "github.com/akamai/cli/pkg/packages" "os" "path/filepath" "time" + "github.com/akamai/cli/pkg/log" + "github.com/akamai/cli/pkg/packages" "github.com/akamai/cli/pkg/stats" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/tools" - "github.com/fatih/color" "github.com/urfave/cli/v2" ) @@ -39,7 +38,7 @@ func cmdUninstall(langManager packages.LangManager) cli.ActionFunc { logger.Debug("UNINSTALL START") defer func() { if e == nil { - logger.Debugf("UNINSTALL FINISH: %v", time.Now().Sub(start)) + logger.Debugf("UNINSTALL FINISH: %v", time.Since(start)) } else { logger.Errorf("UNINSTALL ERROR: %v", e.Error()) } @@ -60,7 +59,7 @@ func cmdUninstall(langManager packages.LangManager) cli.ActionFunc { func uninstallPackage(ctx context.Context, langManager packages.LangManager, cmd string, logger log.Logger) error { term := terminal.Get(ctx) - exec, err := findExec(ctx, langManager, cmd) + exec, _, err := findExec(ctx, langManager, cmd) if err != nil { return fmt.Errorf("command \"%s\" not found. Try \"%s help\"", cmd, tools.Self()) } @@ -87,6 +86,19 @@ func uninstallPackage(ctx context.Context, langManager packages.LangManager, cmd return fmt.Errorf("unable to remove directory: %s", repoDir) } + venvPath, err := tools.GetPkgVenvPath(fmt.Sprintf("cli-%s", cmd)) + if err != nil { + return err + } + if _, err := os.Stat(venvPath); err == nil || !os.IsNotExist(err) { + logger.Debugf("Attempting to remove package virtualenv directory") + if err := os.RemoveAll(venvPath); err != nil { + term.Spinner().Fail() + logger.Errorf("unable to remove virtualenv directory: %s", venvPath) + return fmt.Errorf("unable to remove virtualenv directory: %s", repoDir) + } + } + term.Spinner().OK() return nil diff --git a/pkg/commands/command_uninstall_test.go b/pkg/commands/command_uninstall_test.go index 08519af..776ba7a 100644 --- a/pkg/commands/command_uninstall_test.go +++ b/pkg/commands/command_uninstall_test.go @@ -2,6 +2,9 @@ package commands import ( "fmt" + "os" + "testing" + "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/git" "github.com/akamai/cli/pkg/packages" @@ -11,8 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "os" - "testing" ) func TestCmdUninstall(t *testing.T) { @@ -24,8 +25,8 @@ func TestCmdUninstall(t *testing.T) { "uninstall command": { args: []string{"echo-uninstall"}, init: func(t *testing.T, m *mocked) { - copyFile(t, "./testdata/.akamai-cli/src/cli-echo/cli.json", "./testdata/.akamai-cli/src/cli-echo-uninstall") - copyFile(t, "./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo", "./testdata/.akamai-cli/src/cli-echo-uninstall/bin") + mustCopyFile(t, "./testdata/.akamai-cli/src/cli-echo/cli.json", "./testdata/.akamai-cli/src/cli-echo-uninstall") + mustCopyFile(t, "./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo", "./testdata/.akamai-cli/src/cli-echo-uninstall/bin") err := os.Rename("./testdata/.akamai-cli/src/cli-echo-uninstall/bin/akamai-echo", "./testdata/.akamai-cli/src/cli-echo-uninstall/bin/akamai-echo-uninstall") require.NoError(t, err) err = os.Chmod("./testdata/.akamai-cli/src/cli-echo-uninstall/bin/akamai-echo-uninstall", 0755) @@ -41,7 +42,7 @@ func TestCmdUninstall(t *testing.T) { "package does not contain cli.json": { args: []string{"echo-uninstall"}, init: func(t *testing.T, m *mocked) { - copyFile(t, "./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo", "./testdata/.akamai-cli/src/cli-echo-uninstall/bin") + mustCopyFile(t, "./testdata/.akamai-cli/src/cli-echo/bin/akamai-echo", "./testdata/.akamai-cli/src/cli-echo-uninstall/bin") err := os.Rename("./testdata/.akamai-cli/src/cli-echo-uninstall/bin/akamai-echo", "./testdata/.akamai-cli/src/cli-echo-uninstall/bin/akamai-echo-uninstall") require.NoError(t, err) err = os.Chmod("./testdata/.akamai-cli/src/cli-echo-uninstall/bin/akamai-echo-uninstall", 0755) @@ -67,7 +68,7 @@ func TestCmdUninstall(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) - m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}} + m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}, nil} command := &cli.Command{ Name: "uninstall", Action: cmdUninstall(m.langManager), diff --git a/pkg/commands/command_update.go b/pkg/commands/command_update.go index 33a7b48..a5dbc54 100644 --- a/pkg/commands/command_update.go +++ b/pkg/commands/command_update.go @@ -17,18 +17,17 @@ package commands import ( "context" "fmt" - "github.com/akamai/cli/pkg/packages" "path/filepath" "strings" "time" - "github.com/fatih/color" - "github.com/urfave/cli/v2" - "github.com/akamai/cli/pkg/git" "github.com/akamai/cli/pkg/log" + "github.com/akamai/cli/pkg/packages" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/tools" + "github.com/fatih/color" + "github.com/urfave/cli/v2" ) func cmdUpdate(gitRepo git.Repository, langManager packages.LangManager) cli.ActionFunc { @@ -39,7 +38,7 @@ func cmdUpdate(gitRepo git.Repository, langManager packages.LangManager) cli.Act logger.Debug("UPDATE START") defer func() { if e == nil { - logger.Debugf("UPDATE FINISH: %v", time.Now().Sub(start)) + logger.Debugf("UPDATE FINISH: %v", time.Since(start)) } else { logger.Errorf("UPDATE ERROR: %v", e.Error()) } @@ -75,7 +74,7 @@ func cmdUpdate(gitRepo git.Repository, langManager packages.LangManager) cli.Act func updatePackage(ctx context.Context, gitRepo git.Repository, langManager packages.LangManager, logger log.Logger, cmd string, forceBinary bool) error { term := terminal.Get(ctx) - exec, err := findExec(ctx, langManager, cmd) + exec, _, err := findExec(ctx, langManager, cmd) if err != nil { return cli.Exit(color.RedString("Command \"%s\" not found. Try \"%s help\".\n", cmd, tools.Self()), 1) } diff --git a/pkg/commands/command_update_test.go b/pkg/commands/command_update_test.go index f1e2495..1fe17ac 100644 --- a/pkg/commands/command_update_test.go +++ b/pkg/commands/command_update_test.go @@ -2,6 +2,11 @@ package commands import ( "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/git" "github.com/akamai/cli/pkg/packages" @@ -15,10 +20,6 @@ import ( gogit "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/object" - "net/http" - "net/http/httptest" - "os" - "testing" ) func TestCmdUpdate(t *testing.T) { @@ -234,7 +235,7 @@ func TestCmdUpdate(t *testing.T) { })) defer srv.Close() require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) - m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}} + m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.Mock{}, &packages.Mock{}, nil} command := &cli.Command{ Name: "update", Action: cmdUpdate(m.gitRepo, m.langManager), diff --git a/pkg/commands/command_upgrade.go b/pkg/commands/command_upgrade.go index 30634f3..c9e974b 100644 --- a/pkg/commands/command_upgrade.go +++ b/pkg/commands/command_upgrade.go @@ -15,14 +15,13 @@ package commands import ( - "github.com/akamai/cli/pkg/log" "os" "time" + "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/stats" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/version" - "github.com/fatih/color" "github.com/urfave/cli/v2" ) @@ -33,7 +32,7 @@ func cmdUpgrade(c *cli.Context) error { start := time.Now() logger.Debug("UPGRADE START") defer func() { - logger.Debugf("UPGRADE FINISH: %v", time.Now().Sub(start)) + logger.Debugf("UPGRADE FINISH: %v", time.Since(start)) }() term := terminal.Get(c.Context) diff --git a/pkg/commands/command_upgrade_test.go b/pkg/commands/command_upgrade_test.go index f6b96b3..3aa31ee 100644 --- a/pkg/commands/command_upgrade_test.go +++ b/pkg/commands/command_upgrade_test.go @@ -138,7 +138,7 @@ func TestCmdUpgrade(t *testing.T) { } })) require.NoError(t, os.Setenv("CLI_REPOSITORY", srv.URL)) - m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil} + m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "upgrade", Action: cmdUpgrade, diff --git a/pkg/commands/constants.go b/pkg/commands/constants.go index 34d8dda..e1ff0e2 100644 --- a/pkg/commands/constants.go +++ b/pkg/commands/constants.go @@ -6,6 +6,5 @@ import ( const ( alreadyUptoDate = "already up-to-date" - objectNotFound = "object not found" - sleepTime24Hours = time.Hour * 24 + sleep24HDuration = time.Hour * 24 ) diff --git a/pkg/commands/helpers_test.go b/pkg/commands/helpers_test.go index 8b553d5..1544145 100644 --- a/pkg/commands/helpers_test.go +++ b/pkg/commands/helpers_test.go @@ -2,23 +2,85 @@ package commands import ( "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/git" "github.com/akamai/cli/pkg/packages" "github.com/akamai/cli/pkg/terminal" + "github.com/apex/log" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" - "io" - "os" - "path/filepath" - "testing" ) +var testFiles = map[string][]string{ + "cli-echo": {"akamai-e", "akamai-echo", "akamai-echo-cmd.cmd"}, + "cli-echo-invalid-json": {"akamai-echo-invalid-json"}, +} + +// TestMain prepares test binary used as cli command in tests and copies it to each directory specified in 'testFiles' variable +// The reason why binaries are not included in the repository is to make the tests pass on different operating systems +// After tests are executed, all generated binaries are removed +func TestMain(m *testing.M) { + binaryPath, err := buildTestBinary() + if err != nil { + log.Fatal(err.Error()) + } + + for dir, files := range testFiles { + for _, file := range files { + targetDir := filepath.Join("testdata", ".akamai-cli", "src", dir, "bin") + if err := copyFile(binaryPath, targetDir); err != nil { + log.Fatal(err.Error()) + } + if err := os.Rename(filepath.Join(targetDir, filepath.Base(binaryPath)), filepath.Join(targetDir, file)); err != nil { + log.Fatal(err.Error()) + } + if err := os.Chmod(filepath.Join(targetDir, file), 0755); err != nil { + log.Fatal(err.Error()) + } + } + } + exitCode := m.Run() + if err := os.RemoveAll(binaryPath); err != nil { + log.Fatal(err.Error()) + } + for dir := range testFiles { + targetDir := filepath.Join("testdata", ".akamai-cli", "src", dir, "bin") + if err := os.RemoveAll(targetDir); err != nil { + log.Fatal(err.Error()) + } + } + os.Exit(exitCode) +} + +func buildTestBinary() (string, error) { + bin, err := exec.LookPath("go") + if err != nil { + return "", err + } + sourcePath := filepath.Join("testdata", "example-binary.go") + targetPath := filepath.Join("testdata", "example-binary") + if runtime.GOOS == "windows" { + targetPath = fmt.Sprintf("%s.exe", targetPath) + } + cmd := exec.Command(bin, "build", "-o", targetPath, "-ldflags", "-s -w", sourcePath) + _, err = cmd.Output() + return targetPath, err +} + type mocked struct { term *terminal.Mock cfg *config.Mock gitRepo *git.Mock langManager *packages.Mock + cmd *MockCmd } func setupTestApp(command *cli.Command, m *mocked) (*cli.App, context.Context) { @@ -42,21 +104,35 @@ func setupTestApp(command *cli.Command, m *mocked) (*cli.App, context.Context) { return app, ctx } -func copyFile(t *testing.T, src, dst string) { +func copyFile(src, dst string) error { err := os.MkdirAll(dst, 0755) - require.NoError(t, err) + if err != nil { + return err + } srcFile, err := os.Open(src) - require.NoError(t, err) + if err != nil { + return err + } defer func() { - require.NoError(t, srcFile.Close()) + if err := srcFile.Close(); err != nil { + log.Error(err.Error()) + } }() destFile, err := os.Create(filepath.Join(dst, filepath.Base(srcFile.Name()))) - require.NoError(t, err) + if err != nil { + return err + } defer func() { - require.NoError(t, destFile.Close()) + if err := destFile.Close(); err != nil { + log.Error(err.Error()) + } }() - _, err = io.Copy(destFile, srcFile) - require.NoError(t, err) - err = destFile.Sync() - require.NoError(t, err) + if _, err := io.Copy(destFile, srcFile); err != nil { + return err + } + return destFile.Sync() +} + +func mustCopyFile(t *testing.T, src, dst string) { + require.NoError(t, copyFile(src, dst)) } diff --git a/pkg/commands/mock.go b/pkg/commands/mock.go new file mode 100644 index 0000000..6f81d0a --- /dev/null +++ b/pkg/commands/mock.go @@ -0,0 +1,19 @@ +package commands + +import "github.com/stretchr/testify/mock" + +// MockCmd is used to track activity on command.Command +type MockCmd struct { + mock.Mock +} + +// String mimics the behavior or (*Command) String() +func (c *MockCmd) String() string { + return "MockCmd" +} + +// Run mimics the behavior of (*Command) Run() +func (c *MockCmd) Run() error { + args := c.Called() + return args.Error(0) +} diff --git a/pkg/commands/subcommands.go b/pkg/commands/subcommands.go index 4f802a6..5c269fb 100644 --- a/pkg/commands/subcommands.go +++ b/pkg/commands/subcommands.go @@ -28,12 +28,10 @@ import ( "strings" "text/template" - "github.com/akamai/cli/pkg/packages" - "github.com/akamai/cli/pkg/log" - "github.com/urfave/cli/v2" - + "github.com/akamai/cli/pkg/packages" "github.com/akamai/cli/pkg/tools" + "github.com/urfave/cli/v2" ) type subcommands struct { @@ -98,6 +96,7 @@ func findPackageDir(dir string) string { } } + // at this point, dir points to the package directory, with cli.json return dir } diff --git a/pkg/commands/testdata/.akamai-cli/src/cli-echo-invalid-json/bin/akamai-echo-invalid-json b/pkg/commands/testdata/.akamai-cli/src/cli-echo-invalid-json/bin/akamai-echo-invalid-json deleted file mode 100755 index 1c3df78..0000000 Binary files a/pkg/commands/testdata/.akamai-cli/src/cli-echo-invalid-json/bin/akamai-echo-invalid-json and /dev/null differ diff --git a/pkg/commands/testdata/.akamai-cli/src/cli-echo-python/bin/akamai-echo-python b/pkg/commands/testdata/.akamai-cli/src/cli-echo-python/bin/akamai-echo-python index 1c3df78..bfe214e 100755 Binary files a/pkg/commands/testdata/.akamai-cli/src/cli-echo-python/bin/akamai-echo-python and b/pkg/commands/testdata/.akamai-cli/src/cli-echo-python/bin/akamai-echo-python differ diff --git a/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-e b/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-e deleted file mode 100755 index 1c3df78..0000000 Binary files a/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-e and /dev/null differ diff --git a/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-echo b/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-echo deleted file mode 100755 index 1c3df78..0000000 Binary files a/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-echo and /dev/null differ diff --git a/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-cmd.cmd b/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-cmd.cmd deleted file mode 100755 index 1c3df78..0000000 Binary files a/pkg/commands/testdata/.akamai-cli/src/cli-echo/bin/akamai-echo-cmd.cmd and /dev/null differ diff --git a/pkg/commands/testdata/example-binary.go b/pkg/commands/testdata/example-binary.go new file mode 100644 index 0000000..e0b87a2 --- /dev/null +++ b/pkg/commands/testdata/example-binary.go @@ -0,0 +1,5 @@ +package main + +func main() { + return +} diff --git a/pkg/commands/upgrade.go b/pkg/commands/upgrade.go index ba62f19..41690d1 100644 --- a/pkg/commands/upgrade.go +++ b/pkg/commands/upgrade.go @@ -1,4 +1,5 @@ -//+build !noautoupgrade +//go:build !noautoupgrade +// +build !noautoupgrade // Copyright 2018. Akamai Technologies, Inc // @@ -30,14 +31,14 @@ import ( "text/template" "time" - "github.com/akamai/cli/pkg/log" - "github.com/urfave/cli/v2" - "github.com/akamai/cli/pkg/config" + "github.com/akamai/cli/pkg/log" + "github.com/akamai/cli/pkg/packages" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/version" "github.com/fatih/color" "github.com/inconshreveable/go-update" + "github.com/urfave/cli/v2" ) // CheckUpgradeVersion ... @@ -70,7 +71,7 @@ func CheckUpgradeVersion(ctx context.Context, force bool) string { } currentTime := time.Now() - if lastUpgrade.Add(sleepTime24Hours).Before(currentTime) { + if lastUpgrade.Add(sleep24HDuration).Before(currentTime) { checkForUpgrade = true } } @@ -84,7 +85,7 @@ func CheckUpgradeVersion(ctx context.Context, force bool) string { latestVersion := getLatestReleaseVersion(ctx) comp := version.Compare(version.Version, latestVersion) - if comp == 1 { + if comp == version.Smaller { term.Spinner().Stop(terminal.SpinnerStatusOK) _, _ = term.Writeln("You can find more details about the new version here: https://github.com/akamai/cli/releases") if answer, err := term.Confirm(fmt.Sprintf( @@ -96,7 +97,7 @@ func CheckUpgradeVersion(ctx context.Context, force bool) string { } return latestVersion } - if comp == 0 { + if comp == version.Equals { return version.Version } } @@ -230,8 +231,9 @@ func UpgradeCli(ctx context.Context, latestVersion string) bool { if err == nil { os.Args[0] = selfPath } - err = passthruCommand(os.Args) - if err != nil { + + subCmd := createCommand(os.Args[0], os.Args[1:]) + if err = passthruCommand(ctx, subCmd, packages.NewLangManager(), packages.LanguageRequirements{}, selfPath); err != nil { cli.OsExiter(1) return false } diff --git a/pkg/commands/upgrade_noop.go b/pkg/commands/upgrade_noop.go index 96a6d66..66947b8 100644 --- a/pkg/commands/upgrade_noop.go +++ b/pkg/commands/upgrade_noop.go @@ -1,4 +1,5 @@ -//+build noautoupgrade +//go:build noautoupgrade +// +build noautoupgrade // Copyright 2018. Akamai Technologies, Inc // @@ -18,6 +19,7 @@ package commands import ( "context" + "github.com/fatih/color" "github.com/urfave/cli/v2" ) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a5d8b8..429ccb9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,17 +17,16 @@ package config import ( "context" "errors" - "github.com/akamai/cli/pkg/log" "io/ioutil" "os" "path/filepath" "strings" "time" - "github.com/go-ini/ini" - + "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/tools" + "github.com/go-ini/ini" ) const ( diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index da6717d..4e05108 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2,14 +2,15 @@ package config import ( "context" - "github.com/akamai/cli/pkg/terminal" - "github.com/go-ini/ini" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "io/ioutil" "os" "path/filepath" "testing" + + "github.com/akamai/cli/pkg/terminal" + "github.com/go-ini/ini" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewIni(t *testing.T) { diff --git a/pkg/config/mock.go b/pkg/config/mock.go index 7825d95..4c229be 100644 --- a/pkg/config/mock.go +++ b/pkg/config/mock.go @@ -2,6 +2,7 @@ package config import ( "context" + "github.com/stretchr/testify/mock" ) @@ -11,7 +12,7 @@ type Mock struct { } // Save mock -func (m *Mock) Save(ctx context.Context) error { +func (m *Mock) Save(_ context.Context) error { args := m.Called() return args.Error(0) } diff --git a/pkg/git/mock.go b/pkg/git/mock.go index 6d4e28a..da24845 100644 --- a/pkg/git/mock.go +++ b/pkg/git/mock.go @@ -2,6 +2,7 @@ package git import ( "context" + "github.com/akamai/cli/pkg/terminal" "github.com/stretchr/testify/mock" "gopkg.in/src-d/go-git.v4" diff --git a/pkg/git/repository.go b/pkg/git/repository.go index f38631b..9478b2b 100644 --- a/pkg/git/repository.go +++ b/pkg/git/repository.go @@ -3,12 +3,11 @@ package git import ( "context" "fmt" - "gopkg.in/src-d/go-git.v4/plumbing" - "gopkg.in/src-d/go-git.v4/plumbing/object" - - "gopkg.in/src-d/go-git.v4" "github.com/akamai/cli/pkg/terminal" + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/object" ) const ( diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go index 0ae98de..dc8ea7c 100644 --- a/pkg/log/log_test.go +++ b/pkg/log/log_test.go @@ -3,13 +3,14 @@ package log import ( "bytes" "context" - "github.com/apex/log" - "github.com/stretchr/testify/require" - "github.com/tj/assert" "io/ioutil" "os" "regexp" "testing" + + "github.com/apex/log" + "github.com/stretchr/testify/require" + "github.com/tj/assert" ) func TestSetupContext(t *testing.T) { @@ -82,7 +83,7 @@ func TestWithCommand(t *testing.T) { }, "output to file": { logFile: "./testlogs.txt", - expected: regexp.MustCompile(`\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\+[0-9]{2}:[0-9]{2}] ERROR abc[ ]*command=test`), + expected: regexp.MustCompile(`\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\+[0-9]{2}:[0-9]{2})?[Z]*] ERROR abc[ ]*command=test`), }, } for name, test := range tests { diff --git a/pkg/packages/command_executor.go b/pkg/packages/command_executor.go index df6a533..e729472 100644 --- a/pkg/packages/command_executor.go +++ b/pkg/packages/command_executor.go @@ -3,13 +3,22 @@ package packages import ( "os" "os/exec" + "runtime" ) type ( executor interface { + // ExecCommand runs the given *exec.Cmd with combined output, if required ExecCommand(cmd *exec.Cmd, withCombinedOutput ...bool) ([]byte, error) + // LookPath searches for an executable named file in the directories named by the PATH environment variable. + // If file contains a slash, it is tried directly and the PATH is not consulted. + // The result may be an absolute path or a path relative to the current directory. + // Just a wrapper around exec.LookPath(string) LookPath(string) (string, error) + // FileExists checks if the given path exists. If there is an error, it will be of type *os.PathError. FileExists(string) (bool, error) + // GetOS is a wrapper around runtime.GOOS + GetOS() string } defaultExecutor struct{} @@ -22,6 +31,10 @@ func (d *defaultExecutor) ExecCommand(cmd *exec.Cmd, withCombinedOutput ...bool) return cmd.Output() } +func (d *defaultExecutor) GetOS() string { + return runtime.GOOS +} + func (d *defaultExecutor) LookPath(file string) (string, error) { return exec.LookPath(file) } diff --git a/pkg/packages/command_executor_test.go b/pkg/packages/command_executor_test.go index c7d0c89..afb8887 100644 --- a/pkg/packages/command_executor_test.go +++ b/pkg/packages/command_executor_test.go @@ -1,10 +1,11 @@ package packages import ( - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "os/exec" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestExecCommand(t *testing.T) { diff --git a/pkg/packages/golang.go b/pkg/packages/golang.go index f7bd6a2..50fb9ab 100644 --- a/pkg/packages/golang.go +++ b/pkg/packages/golang.go @@ -25,12 +25,11 @@ import ( "regexp" "strings" - "github.com/fatih/color" - "github.com/urfave/cli/v2" - "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/tools" "github.com/akamai/cli/pkg/version" + "github.com/fatih/color" + "github.com/urfave/cli/v2" ) func (l *langManager) installGolang(ctx context.Context, dir, ver string, commands []string) error { @@ -53,7 +52,7 @@ func (l *langManager) installGolang(ctx context.Context, dir, ver string, comman return fmt.Errorf("%w: %s:%s", ErrRuntimeNoVersionFound, "go", ver) } - if version.Compare(ver, matches[1]) == -1 { + if version.Compare(ver, matches[1]) == version.Greater { logger.Debugf("Go Version found: %s", matches[1]) return fmt.Errorf("%w: required: %s:%s, have: %s. Please upgrade your runtime", ErrRuntimeMinimumVersionRequired, "go", ver, matches[1]) } diff --git a/pkg/packages/golang_test.go b/pkg/packages/golang_test.go index 6418fc7..d97f04b 100644 --- a/pkg/packages/golang_test.go +++ b/pkg/packages/golang_test.go @@ -4,10 +4,11 @@ import ( "context" "errors" "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "os/exec" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestInstallGolang(t *testing.T) { diff --git a/pkg/packages/javascript.go b/pkg/packages/javascript.go index cdf5c32..75cb010 100644 --- a/pkg/packages/javascript.go +++ b/pkg/packages/javascript.go @@ -51,7 +51,7 @@ func (l *langManager) installJavaScript(ctx context.Context, dir, ver string) er return fmt.Errorf("%w: %s:%s", ErrRuntimeNoVersionFound, "Node.js", ver) } - if version.Compare(ver, matches[1]) == -1 { + if version.Compare(ver, matches[1]) == version.Greater { logger.Debugf("Node.js Version found: %s", matches[1]) return fmt.Errorf("%w: required: %s:%s, have: %s. Please upgrade your runtime", ErrRuntimeMinimumVersionRequired, "Node.js", ver, matches[1]) } @@ -61,11 +61,7 @@ func (l *langManager) installJavaScript(ctx context.Context, dir, ver string) er return err } - if err := installNodeDepsNpm(ctx, l.commandExecutor, dir); err != nil { - return err - } - - return nil + return installNodeDepsNpm(ctx, l.commandExecutor, dir) } func installNodeDepsYarn(ctx context.Context, cmdExecutor executor, dir string) error { diff --git a/pkg/packages/javascript_test.go b/pkg/packages/javascript_test.go index 976f1e9..8bade7c 100644 --- a/pkg/packages/javascript_test.go +++ b/pkg/packages/javascript_test.go @@ -4,10 +4,11 @@ import ( "context" "errors" "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "os/exec" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestInstallJavaScript(t *testing.T) { diff --git a/pkg/packages/mock.go b/pkg/packages/mock.go index 5d2d82b..e303f81 100644 --- a/pkg/packages/mock.go +++ b/pkg/packages/mock.go @@ -2,6 +2,7 @@ package packages import ( "context" + "github.com/stretchr/testify/mock" ) @@ -24,3 +25,32 @@ func (m *Mock) FindExec(_ context.Context, requirements LanguageRequirements, cm } return args.Get(0).([]string), args.Error(1) } + +// FinishExecution mocks behavior of (*langManager) FinishExecution() +func (m *Mock) FinishExecution(_ context.Context, languageRequirements LanguageRequirements, dirName string) { + m.Called(languageRequirements, dirName) +} + +// PrepareExecution mocks behavior of (*langManager) PrepareExecution() +func (m *Mock) PrepareExecution(_ context.Context, languageRequirements LanguageRequirements, dirName string) error { + args := m.Called(languageRequirements, dirName) + return args.Error(0) +} + +// GetShell mocks behavior of (*langManager) GetShell() +func (m *Mock) GetShell(goos string) (string, error) { + args := m.Called(goos) + return args.Get(0).(string), args.Error(0) +} + +// GetOS mocks behavior of (*langManager) GetOS() +func (m *Mock) GetOS() string { + m.Called() + return "GetOS()" +} + +// FileExists mocks behavior of (*langManager) FileExists() +func (m *Mock) FileExists(path string) (bool, error) { + args := m.Called(path) + return args.Bool(0), args.Error(1) +} diff --git a/pkg/packages/package.go b/pkg/packages/package.go index 06f1100..580b873 100644 --- a/pkg/packages/package.go +++ b/pkg/packages/package.go @@ -3,14 +3,33 @@ package packages import ( "context" "errors" + "os" + "path/filepath" + "strings" + "syscall" + "github.com/akamai/cli/pkg/log" + "github.com/akamai/cli/pkg/tools" + "github.com/akamai/cli/pkg/version" ) type ( // LangManager allows operations on different programming languages LangManager interface { + // Install builds and installs contents of a directory based on provided language requirements Install(ctx context.Context, dir string, requirements LanguageRequirements, commands []string) error + // FindExec locates language's CLI executable FindExec(ctx context.Context, requirements LanguageRequirements, cmdExec string) ([]string, error) + // PrepareExecution performs any operation required before the external command invocation + PrepareExecution(ctx context.Context, languageRequirements LanguageRequirements, dirName string) error + // FinishExecution perform any required tear down steps for external command invocation + FinishExecution(ctx context.Context, languageRequirements LanguageRequirements, dirName string) + // GetShell returns the available shell + GetShell(goos string) (string, error) + // GetOS is a wrapper around executor.GetOS() + GetOS() string + // FileExists checks if the given path exists + FileExists(path string) (bool, error) } // LanguageRequirements contains version requirements for all supported programming languages @@ -43,6 +62,19 @@ var ( ErrPackageManagerExec = errors.New("unable to execute package manager") ErrPackageNeedsReinstall = errors.New("you must reinstall this package to continue") ErrPackageCompileFailure = errors.New("unable to build binary") + ErrPackageExecutableNotFound = errors.New("package executable not found") + ErrVirtualEnvCreation = errors.New("unable to create virtual environment") + ErrVirtualEnvActivation = errors.New("unable to activate virtual environment") + ErrVenvNotFound = errors.New("venv python package not found. Please verify your setup") + ErrPipNotFound = errors.New("pip not found. Please verify your setup") + ErrRequirementsTxtNotFound = errors.New("requirements.txt not found in the subcommand") + ErrRequirementsInstall = errors.New("failed to install required python packages from requirements.txt") + ErrPipUpgrade = errors.New("unable to install/upgrade pip") + ErrPipSetuptoolsUpgrade = errors.New("unable to execute 'python3 -m pip install --user --no-cache --upgrade pip setuptools'") + ErrNoExeFound = errors.New("no executables found") + ErrOSNotSupported = errors.New("OS not supported") + ErrPythonVersionNotSupported = errors.New("python version not supported") + ErrDirectoryCreation = errors.New("unable to create directory") ) type langManager struct { @@ -56,25 +88,50 @@ func NewLangManager() LangManager { } } -// Install builds and installs contents of a directory based on provided language requirements -func (l *langManager) Install(ctx context.Context, dir string, reqs LanguageRequirements, commands []string) error { +func (l *langManager) GetShell(goos string) (string, error) { + var pathErr *os.PathError + + switch goos { + case "windows": + return "", nil + case "linux", "darwin": + sh, err := lookForBins(l.commandExecutor, "bash", "sh") + if err != nil && errors.As(err, &pathErr) && pathErr.Err != syscall.ENOENT { + return "", err + } + if err == nil { + return sh, nil + } + } + + return "", ErrOSNotSupported +} + +func (l *langManager) Install(ctx context.Context, pkgSrcPath string, reqs LanguageRequirements, commands []string) error { lang, requirements := determineLangAndRequirements(reqs) switch lang { case PHP: - return l.installPHP(ctx, dir, requirements) + return l.installPHP(ctx, pkgSrcPath, requirements) case Javascript: - return l.installJavaScript(ctx, dir, requirements) + return l.installJavaScript(ctx, pkgSrcPath, requirements) case Ruby: - return l.installRuby(ctx, dir, requirements) + return l.installRuby(ctx, pkgSrcPath, requirements) case Python: - return l.installPython(ctx, dir, requirements) + pkgVenvPath, err := tools.GetPkgVenvPath(filepath.Base(pkgSrcPath)) + if err != nil { + return err + } + err = l.installPython(ctx, pkgVenvPath, pkgSrcPath, requirements) + if err != nil { + _ = os.RemoveAll(pkgVenvPath) + } + return err case Go: - return l.installGolang(ctx, dir, requirements, commands) + return l.installGolang(ctx, pkgSrcPath, requirements, commands) } return ErrUnknownLang } -// FindExec locates language's CLI executable func (l *langManager) FindExec(ctx context.Context, reqs LanguageRequirements, cmdExec string) ([]string, error) { logger := log.FromContext(ctx) lang, requirements := determineLangAndRequirements(reqs) @@ -86,14 +143,18 @@ func (l *langManager) FindExec(ctx context.Context, reqs LanguageRequirements, c bin, err := l.commandExecutor.LookPath("node") if err != nil { bin, err = l.commandExecutor.LookPath("nodejs") + if err != nil { + return nil, err + } } return []string{bin, cmdExec}, nil case Python: - bin, err := findPythonBin(ctx, l.commandExecutor, requirements) + cmdName := filepath.Base(cmdExec) + pythonBin, err := findPythonBin(ctx, l.commandExecutor, requirements, cmdName) if err != nil { return nil, err } - return []string{bin, cmdExec}, nil + return []string{pythonBin, cmdExec}, nil case Undefined: logger.Debugf("command language is not defined") return []string{cmdExec}, nil @@ -102,6 +163,47 @@ func (l *langManager) FindExec(ctx context.Context, reqs LanguageRequirements, c } } +func (l *langManager) PrepareExecution(ctx context.Context, languageRequirements LanguageRequirements, dirName string) error { + if languageRequirements.Python != "" { + logger := log.FromContext(ctx) + + logger.Debugf("Validating python dependencies") + python34Bin, pipBin, err := l.validatePythonDeps(ctx, logger, languageRequirements.Python, dirName) + if err != nil { + return err + } + + srcPath, err := tools.GetAkamaiCliSrcPath() + if err != nil { + return err + } + + pkgSrcPath := filepath.Join(srcPath, dirName) + + pkgVePath, err := tools.GetPkgVenvPath(dirName) + if err != nil { + return err + } + + logger.Debugf("Adding any missing element to the virtual environment") + return l.setup(ctx, pkgVePath, pkgSrcPath, python34Bin, pipBin, languageRequirements.Python, true) + } + return nil +} + +func (l *langManager) FinishExecution(ctx context.Context, reqs LanguageRequirements, dirName string) { + if reqs.Python != "" { + if version.Compare(reqs.Python, "3.0.0") != version.Smaller { + venvPath, _ := tools.GetPkgVenvPath(dirName) + l.deactivateVirtualEnvironment(ctx, venvPath, reqs.Python) + } + } +} + +func (l *langManager) GetOS() string { + return l.commandExecutor.GetOS() +} + func determineLangAndRequirements(reqs LanguageRequirements) (string, string) { if reqs.Php != "" { return PHP, reqs.Php @@ -120,8 +222,21 @@ func determineLangAndRequirements(reqs LanguageRequirements) (string, string) { } if reqs.Python != "" { - return Python, reqs.Python + return Python, translateWildcards(reqs.Python) } return Undefined, "" } + +func translateWildcards(version string) string { + version = strings.ReplaceAll(version, "*", "0") + splitVersion := strings.Split(version, ".") + for len(splitVersion) < 3 { + splitVersion = append(splitVersion, "0") + } + return strings.Join(splitVersion, ".") +} + +func (l *langManager) FileExists(path string) (bool, error) { + return l.commandExecutor.FileExists(path) +} diff --git a/pkg/packages/package_test.go b/pkg/packages/package_test.go index da0a77c..d77e83a 100644 --- a/pkg/packages/package_test.go +++ b/pkg/packages/package_test.go @@ -3,12 +3,12 @@ package packages import ( "context" "fmt" + "os/exec" + "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "os/exec" - - "testing" ) type mocked struct { @@ -52,38 +52,13 @@ func TestLangManager_FindExec(t *testing.T) { }, expected: []string{"/test/nodejs", "test"}, }, - "python command, default version, python command found": { - givenReqs: LanguageRequirements{ - Python: "*", - }, - givenCmdExec: "test", - init: func(m *mocked) { - m.On("LookPath", "python3").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python2").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python").Return("/test/python", nil) - }, - expected: []string{"/test/python", "test"}, - }, - "python command, default version, not found": { - givenReqs: LanguageRequirements{ - Python: "*", - }, - givenCmdExec: "test", - init: func(m *mocked) { - m.On("LookPath", "python3").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python2").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python").Return("", fmt.Errorf("not found")) - }, - withError: true, - }, "python command, version 3, binary found": { givenReqs: LanguageRequirements{ Python: "3.0.0", }, givenCmdExec: "test", init: func(m *mocked) { - m.On("LookPath", "python3").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python").Return("/test/python", nil) + m.On("LookPath", "python3").Return("/test/python", nil) }, expected: []string{"/test/python", "test"}, }, @@ -94,7 +69,8 @@ func TestLangManager_FindExec(t *testing.T) { givenCmdExec: "test", init: func(m *mocked) { m.On("LookPath", "python3").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python").Return("", fmt.Errorf("not found")) + m.On("LookPath", "python3.exe").Return("", fmt.Errorf("not found")) + m.On("LookPath", "py.exe").Return("", fmt.Errorf("not found")) }, withError: true, }, @@ -108,15 +84,27 @@ func TestLangManager_FindExec(t *testing.T) { }, expected: []string{"/test/python2", "test"}, }, + "python command, version 2, python3 not found": { + givenReqs: LanguageRequirements{ + Python: "2.0.0", + }, + givenCmdExec: "test", + init: func(m *mocked) { + m.On("LookPath", "python2").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "python2.exe").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "py.exe").Return("", fmt.Errorf("not found")).Once() + }, + withError: true, + }, "python command, version 2, not found": { givenReqs: LanguageRequirements{ Python: "2.0.0", }, givenCmdExec: "test", init: func(m *mocked) { - m.On("LookPath", "python2").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python").Return("", fmt.Errorf("not found")) - m.On("LookPath", "python3").Return("", fmt.Errorf("not found")) + m.On("LookPath", "python2").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "python2.exe").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "py.exe").Return("", fmt.Errorf("not found")).Once() }, withError: true, }, @@ -175,3 +163,49 @@ func (m *mocked) FileExists(path string) (bool, error) { args := m.Called(path) return args.Bool(0), args.Error(1) } + +func (m *mocked) GetOS() string { + args := m.Called() + return args.String(0) +} + +func TestDetermineLangAndRequirements(t *testing.T) { + tests := map[string]struct { + reqs LanguageRequirements + language string + version string + }{ + "undefined": { + language: Undefined, + version: "", + }, + "concrete Python version": { + reqs: LanguageRequirements{Python: "2.7.10"}, + language: Python, + version: "2.7.10", + }, + "Python with wildcard": { + reqs: LanguageRequirements{Python: "3.0.*"}, + language: Python, + version: "3.0.0", + }, + "Python with short wildcard": { + reqs: LanguageRequirements{Python: "3.*"}, + language: Python, + version: "3.0.0", + }, + "Python pure wildcard": { + reqs: LanguageRequirements{Python: "*"}, + language: Python, + version: "0.0.0", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + language, version := determineLangAndRequirements(test.reqs) + assert.Equal(t, test.language, language) + assert.Equal(t, test.version, version) + }) + } +} diff --git a/pkg/packages/php.go b/pkg/packages/php.go index 91bc18c..92f7d9d 100644 --- a/pkg/packages/php.go +++ b/pkg/packages/php.go @@ -47,17 +47,13 @@ func (l *langManager) installPHP(ctx context.Context, dir, cmdReq string) error return fmt.Errorf("%w: %s:%s", ErrRuntimeNoVersionFound, "php", cmdReq) } - if version.Compare(cmdReq, matches[1]) == -1 { + if version.Compare(cmdReq, matches[1]) == version.Greater { logger.Debugf("PHP Version found: %s", matches[1]) return fmt.Errorf("%w: required: %s:%s, have: %s. Please upgrade your runtime", ErrRuntimeMinimumVersionRequired, "php", cmdReq, matches[1]) } } - if err := installPHPDepsComposer(ctx, l.commandExecutor, bin, dir); err != nil { - return err - } - - return nil + return installPHPDepsComposer(ctx, l.commandExecutor, bin, dir) } func installPHPDepsComposer(ctx context.Context, cmdExecutor executor, phpBin, dir string) error { diff --git a/pkg/packages/php_test.go b/pkg/packages/php_test.go index c620efb..9482e95 100644 --- a/pkg/packages/php_test.go +++ b/pkg/packages/php_test.go @@ -4,10 +4,11 @@ import ( "context" "errors" "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "os/exec" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestInstallPHP(t *testing.T) { diff --git a/pkg/packages/python.go b/pkg/packages/python.go index 204e925..d8d426c 100644 --- a/pkg/packages/python.go +++ b/pkg/packages/python.go @@ -26,46 +26,377 @@ import ( "strings" "github.com/akamai/cli/pkg/log" + "github.com/akamai/cli/pkg/terminal" + "github.com/akamai/cli/pkg/tools" "github.com/akamai/cli/pkg/version" ) -func (l *langManager) installPython(ctx context.Context, dir, cmdReq string) error { +var ( + pythonVersionPattern = `Python (\d+\.\d+\.\d+).*` + pythonVersionRegex = regexp.MustCompile(pythonVersionPattern) + pipVersionPattern = `^pip \d{1,2}\..+ \(python \d\.\d\)` + venvHelpPattern = `usage: venv ` + pipVersionRegex = regexp.MustCompile(pipVersionPattern) + venvHelpRegex = regexp.MustCompile(venvHelpPattern) +) + +func (l *langManager) installPython(ctx context.Context, venvPath, srcPath, requiredPy string) error { logger := log.FromContext(ctx) - pythonBin, err := findPythonBin(ctx, l.commandExecutor, cmdReq) + pythonBin, pipBin, err := l.validatePythonDeps(ctx, logger, requiredPy, filepath.Base(srcPath)) if err != nil { + logger.Errorf("%v", err) + return err + } + + if err = l.setup(ctx, venvPath, srcPath, pythonBin, pipBin, requiredPy, false); err != nil { + logger.Errorf("%v", err) return err } - pipBin, err := findPipBin(ctx, l.commandExecutor, cmdReq) + + return nil +} + +// setup does the python virtualenv set up for the given module. It may return an error. +func (l *langManager) setup(ctx context.Context, pkgVenvPath, srcPath, python3Bin, pipBin, requiredPy string, passthru bool) error { + switch version.Compare(requiredPy, "3.0.0") { + case version.Greater, version.Equals: + // Python 3.x required: build virtual environment + logger := log.FromContext(ctx) + + defer func() { + if !passthru { + l.deactivateVirtualEnvironment(ctx, pkgVenvPath, requiredPy) + } + logger.Debugf("All virtualenv dependencies successfully installed") + }() + + veExists, err := l.commandExecutor.FileExists(pkgVenvPath) + if err != nil { + return err + } + + if !passthru || !veExists { + logger.Debugf("the virtual environment %s does not exist yet - installing dependencies", pkgVenvPath) + + // upgrade pip and setuptools + if err := l.upgradePipAndSetuptools(ctx, python3Bin); err != nil { + return err + } + + // create virtual environment + if err := l.createVirtualEnvironment(ctx, python3Bin, pkgVenvPath); err != nil { + return err + } + } + + // activate virtual environment + if err := l.activateVirtualEnvironment(ctx, pkgVenvPath); err != nil { + return err + } + + // install packages from requirements.txt + vePy, err := l.getVePython(pkgVenvPath) + if err != nil { + return err + } + if err := l.installVeRequirements(ctx, srcPath, pkgVenvPath, vePy); err != nil { + return err + } + case version.Smaller: + // no virtualenv for python 2.x + return installPythonDepsPip(ctx, l.commandExecutor, pipBin, srcPath) + } + + return nil +} + +func (l *langManager) getVePython(vePath string) (string, error) { + switch l.GetOS() { + case "windows": + return filepath.Join(vePath, "System", "python.exe"), nil + case "linux", "darwin": + return filepath.Join(vePath, "bin", "python"), nil + default: + return "", ErrOSNotSupported + } +} + +func (l *langManager) installVeRequirements(ctx context.Context, srcPath, vePath, py3Bin string) error { + logger := log.FromContext(ctx) + + requirementsPath := filepath.Join(srcPath, "requirements.txt") + if ok, _ := l.commandExecutor.FileExists(requirementsPath); !ok { + return ErrRequirementsTxtNotFound + } + logger.Info("requirements.txt found, running pip package manager") + + shell, err := l.GetShell(l.GetOS()) if err != nil { return err } + if shell == "" { + // windows + pipPath := filepath.Join(vePath, "Scripts", "pip.exe") + if output, err := l.commandExecutor.ExecCommand(&exec.Cmd{ + Path: pipPath, + Args: []string{pipPath, "install", "--upgrade", "--ignore-installed", "-r", requirementsPath}, + }, true); err != nil { + term := terminal.Get(ctx) + _, _ = term.Writeln(string(output)) + logger.Errorf("failed to run pip install --upgrade --ignore-installed -r requirements.txt") + return fmt.Errorf("%v: %s", ErrRequirementsInstall, string(output)) + } + return nil + } - if cmdReq != "" && cmdReq != "*" { - cmd := exec.Command(pythonBin, "--version") - output, _ := l.commandExecutor.ExecCommand(cmd, true) - logger.Debugf("%s --version: %s", pythonBin, bytes.ReplaceAll(output, []byte("\n"), []byte(""))) - r := regexp.MustCompile(`Python (\d+\.\d+\.\d+).*`) - matches := r.FindStringSubmatch(string(output)) + if output, err := l.commandExecutor.ExecCommand(&exec.Cmd{ + Path: py3Bin, + Args: []string{py3Bin, "-m", "pip", "install", "--upgrade", "--ignore-installed", "-r", requirementsPath}, + }, true); err != nil { + logger.Errorf("failed to run pip install --upgrade --ignore-installed -r requirements.txt") + logger.Errorf(string(output)) + return fmt.Errorf("%v: %v", ErrRequirementsInstall, string(output)) + } + return nil +} + +/* +validatePythonDeps does system dependencies validation based on the required python version + +It returns: + +* route to required python executable + +* route to pip executable, for modules which require python < v3 + +* error, if any +*/ +func (l *langManager) validatePythonDeps(ctx context.Context, logger log.Logger, requiredPy, name string) (string, string, error) { + switch version.Compare(requiredPy, "3.0.0") { + case version.Smaller: + // v2 required -> no virtualenv + logger.Debugf("Validating dependencies for python 2.x module") + pythonBin, err := findPythonBin(ctx, l.commandExecutor, requiredPy, "") + if err != nil { + logger.Errorf("Python >= 2 (and < 3.0) not found in the system. Please verify your setup", requiredPy) + return "", "", err + } + + if err := l.resolveBinVersion(pythonBin, requiredPy, "--version", logger); err != nil { + return "", "", err + } + + pipBin, err := findPipBin(ctx, l.commandExecutor, requiredPy) + if err != nil { + return pythonBin, "", err + } + return pythonBin, pipBin, nil + case version.Greater, version.Equals: + // v3 required -> virtualenv + // requirements for setting up VE: python3, pip3, venv + logger.Debugf("Validating dependencies for python %s module", requiredPy) + pythonBin, err := findPythonBin(ctx, l.commandExecutor, requiredPy, name) + if err != nil { + logger.Errorf("Python >= %s not found in the system. Please verify your setup", requiredPy) + return "", "", err + } + + if err := l.resolveBinVersion(pythonBin, requiredPy, "--version", logger); err != nil { + return "", "", err + } + + // validate that the use has python pip package installed + if err = l.findPipPackage(ctx, requiredPy, pythonBin); err != nil { + logger.Errorf("Pip not found in the system. Please verify your setup") + return "", "", err + } + + // validate that venv module is present + if err = l.findVenvPackage(ctx, pythonBin); err != nil { + logger.Errorf("Python venv module not found in the system. Please verify your setup") + return "", "", err + } + + return pythonBin, "", nil + default: + // not supported + logger.Errorf("%s: %s", ErrPythonVersionNotSupported.Error(), requiredPy) + return "", "", fmt.Errorf("%v: %s", ErrPythonVersionNotSupported, requiredPy) + } +} + +func (l *langManager) findVenvPackage(ctx context.Context, pythonBin string) error { + logger := log.FromContext(ctx) + cmd := exec.Command(pythonBin, "-m", "venv", "--version") + output, _ := l.commandExecutor.ExecCommand(cmd, true) + logger.Debugf("%s %s %s: %s", pythonBin, "-m venv --version", bytes.ReplaceAll(output, []byte("\n"), []byte(""))) + matches := venvHelpRegex.FindStringSubmatch(string(output)) + if len(matches) == 0 { + return fmt.Errorf("%v: %s", ErrVenvNotFound, bytes.ReplaceAll(output, []byte("\n"), []byte(""))) + } + return nil +} + +func (l *langManager) findPipPackage(ctx context.Context, requiredPy string, pythonBin string) error { + compare := version.Compare(requiredPy, "3.0.0") + if compare == version.Greater || compare == version.Equals { + logger := log.FromContext(ctx) + + // find pip python package, not pip executable + cmd := exec.Command(pythonBin, "-m", "pip", "--version") + output, _ := l.commandExecutor.ExecCommand(cmd, true) + logger.Debugf("%s %s %s: %s", pythonBin, "-m pip --version", bytes.ReplaceAll(output, []byte("\n"), []byte(""))) + matches := pipVersionRegex.FindStringSubmatch(string(output)) if len(matches) == 0 { - return fmt.Errorf("%w: %s:%s", ErrRuntimeNoVersionFound, "python", cmdReq) + return fmt.Errorf("%v: %s", ErrPipNotFound, bytes.ReplaceAll(output, []byte("\n"), []byte(""))) } + } - if version.Compare(cmdReq, matches[1]) == -1 { - logger.Debugf("Python Version found: %s", matches[1]) - return fmt.Errorf("%w: required: %s:%s, have: %s. Please upgrade your runtime", ErrRuntimeMinimumVersionRequired, "python", cmdReq, matches[1]) + return nil +} + +func (l *langManager) activateVirtualEnvironment(ctx context.Context, pkgVenvPath string) error { + logger := log.FromContext(ctx) + logger.Debugf("Activating Python virtualenv: %s", pkgVenvPath) + oS := l.GetOS() + interpreter, err := l.GetShell(oS) + if err != nil { + logger.Errorf("cannot determine OS shell") + return err + } + cmd := &exec.Cmd{} + if oS == "windows" { + activate := filepath.Join(pkgVenvPath, "Scripts", "activate.bat") + cmd.Path = activate + cmd.Args = []string{} + } else { + cmd.Path = interpreter + cmd.Args = []string{"source", filepath.Join(pkgVenvPath, "bin", "activate")} + } + if output, err := l.commandExecutor.ExecCommand(cmd, true); err != nil { + logger.Errorf("%w: %v", ErrVirtualEnvActivation, string(output)) + return fmt.Errorf("%w: %s", ErrVirtualEnvActivation, string(output)) + } + logger.Debugf("Python virtualenv %s active", pkgVenvPath) + + return nil +} + +func (l *langManager) deactivateVirtualEnvironment(ctx context.Context, dir, pyVersion string) { + + compare := version.Compare(pyVersion, "3.0.0") + if compare == version.Equals || compare == version.Greater { + logger := log.FromContext(ctx) + logger.Debugf("Deactivating virtual environment %s", dir) + cmd := &exec.Cmd{} + oS := l.GetOS() + if oS == "windows" { + logger.Debugf("windows detected, executing deactivate.bat") + deactivate := filepath.Join(dir, "Scripts", "deactivate.bat") + cmd.Path = deactivate + cmd.Args = []string{deactivate} + } else { + cmd.Path, _ = l.GetShell(oS) + cmd.Args = []string{"deactivate"} + } + // errors are ignored at this step, as we may be trying to deactivate a non existent or not active virtual environment + if _, err := l.commandExecutor.ExecCommand(cmd, true); err == nil { + logger.Debugf("Python virtualenv deactivated") + } else { + logger.Errorf("Error deactivating VE %s: %v", dir, err) } } +} - if err := installPythonDepsPip(ctx, l.commandExecutor, pipBin, dir); err != nil { +func (l *langManager) resolveBinVersion(bin, cmdReq, arg string, logger log.Logger) error { + cmd := exec.Command(bin, arg) + output, err := l.commandExecutor.ExecCommand(cmd, true) + if err != nil { return err } + logger.Debugf("%s %s: %s", bin, arg, bytes.ReplaceAll(output, []byte("\n"), []byte(""))) + matches := pythonVersionRegex.FindStringSubmatch(string(output)) + if len(matches) < 2 { + return fmt.Errorf("%w: %s: %s", ErrRuntimeNoVersionFound, "python", cmd) + } + switch version.Compare(cmdReq, matches[1]) { + case version.Greater: + // python required > python installed + logger.Errorf("%s version found: %s", bin, matches[1]) + return fmt.Errorf("%v: required: %s:%s, have: %s. Please install the required Python branch", ErrRuntimeMinimumVersionRequired, bin, cmdReq, matches[1]) + case version.Smaller: + // python required < python installed + if version.Compare(cmdReq, "3.0.0") == 1 && version.Compare(matches[1], "3.0.0") <= 0 { + // required: py2; found: py3. The user still needs to install py2 + logger.Errorf("Python version %s found, %s version required. Please, install the %s Python branch.") + return fmt.Errorf("%v: Please install the following Python branch: %s", ErrRuntimeNotFound, cmdReq) + } + return nil + case version.Equals: + return nil + } + return ErrPythonVersionNotSupported +} + +func (l *langManager) upgradePipAndSetuptools(ctx context.Context, python3Bin string) error { + logger := log.FromContext(ctx) + + // if python3 > v3.4, ensure pip + if err := l.resolveBinVersion(python3Bin, "3.4.0", "--version", logger); err != nil && errors.As(err, &ErrRuntimeNotFound) { + return err + } + + logger.Debugf("Installing/upgrading pip") + + // ensure pip is present + cmdPip := exec.Command(python3Bin, "-m", "ensurepip", "--upgrade") + if output, err := l.commandExecutor.ExecCommand(cmdPip, true); err != nil { + logger.Warnf("%w: %s", ErrPipUpgrade, string(output)) + } + + // upgrade pip & setuptools + logger.Debugf("Installing/upgrading pip & setuptools") + cmdSetuptools := exec.Command(python3Bin, "-m", "pip", "install" /*, "--user"*/, "--no-cache", "--upgrade", "pip", "setuptools") + if output, err := l.commandExecutor.ExecCommand(cmdSetuptools, true); err != nil { + logger.Errorf("%v: %s", ErrPipSetuptoolsUpgrade, string(output)) + return fmt.Errorf("%v: %s", ErrPipSetuptoolsUpgrade, string(output)) + } + + return nil +} + +func (l *langManager) createVirtualEnvironment(ctx context.Context, python3Bin string, pkgVenvPath string) error { + logger := log.FromContext(ctx) + + // check if the .akamai-cli/venv directory exists - create it otherwise + venvPath := filepath.Dir(pkgVenvPath) + if exists, err := l.commandExecutor.FileExists(venvPath); err == nil && !exists { + logger.Debugf("%s does not exist; let's create it", venvPath) + if err := os.Mkdir(venvPath, 0755); err != nil { + logger.Errorf("%v %s: %v", ErrDirectoryCreation, venvPath, err) + return fmt.Errorf("%v %s: %v", ErrDirectoryCreation, venvPath, err) + } + logger.Debugf("%s directory created", venvPath) + } else { + if err != nil { + return err + } + } + + logger.Debugf("Creating python virtualenv: %s", pkgVenvPath) + cmdVenv := exec.Command(python3Bin, "-m", "venv", pkgVenvPath) + if output, err := l.commandExecutor.ExecCommand(cmdVenv, true); err != nil { + logger.Errorf("%v %s: %s", ErrVirtualEnvCreation, pkgVenvPath, string(output)) + return fmt.Errorf("%v %s: %s", ErrVirtualEnvCreation, pkgVenvPath, string(output)) + } + logger.Debugf("Python virtualenv successfully created: %s", pkgVenvPath) return nil } -func findPythonBin(ctx context.Context, cmdExecutor executor, ver string) (string, error) { +func findPythonBin(ctx context.Context, cmdExecutor executor, ver, name string) (string, error) { logger := log.FromContext(ctx) var err error @@ -76,28 +407,39 @@ func findPythonBin(ctx context.Context, cmdExecutor executor, ver string) (strin logger.Debugf("Python binary found: %s", bin) } }() - if ver == "" || ver == "*" { - bin, err = lookForBins(cmdExecutor, "python3", "python2", "python") + if version.Compare("3.0.0", ver) != version.Greater { + // looking for python3 or py (windows) + bin, err = lookForBins(cmdExecutor, "python3", "python3.exe", "py.exe") if err != nil { - return "", fmt.Errorf("%w: %s. Please verify if the executable is included in your PATH", ErrRuntimeNotFound, "python") + return "", fmt.Errorf("%w: %s. Please verify if the executable is included in your PATH", ErrRuntimeNotFound, "python 3") + } + vePath, _ := tools.GetPkgVenvPath(name) + if _, err := os.Stat(vePath); !os.IsNotExist(err) { + if cmdExecutor.GetOS() == "windows" { + bin = filepath.Join(vePath, "Scripts", "python.exe") + } else { + bin = filepath.Join(vePath, "bin", "python") + } } return bin, nil } - if version.Compare("3.0.0", ver) != -1 { - bin, err = lookForBins(cmdExecutor, "python3", "python") + if version.Compare("2.0.0", ver) != version.Greater { + // looking for python2 or py (windows) - no virtualenv + bin, err = lookForBins(cmdExecutor, "python2", "python2.exe", "py.exe") if err != nil { - return "", fmt.Errorf("%w: %s. Please verify if the executable is included in your PATH", ErrRuntimeNotFound, "python 3") + return "", fmt.Errorf("%w: %s. Please verify if the executable is included in your PATH", ErrRuntimeNotFound, "python 2") } return bin, nil } - bin, err = lookForBins(cmdExecutor, "python2", "python", "python3") + // looking for any version + bin, err = lookForBins(cmdExecutor, "python2", "python", "python3", "py.exe", "python.exe") if err != nil { return "", fmt.Errorf("%w: %s. Please verify if the executable is included in your PATH", ErrRuntimeNotFound, "python") } return bin, nil } -func findPipBin(ctx context.Context, cmdExecutor executor, ver string) (string, error) { +func findPipBin(ctx context.Context, cmdExecutor executor, requiredPy string) (string, error) { logger := log.FromContext(ctx) var bin string @@ -107,24 +449,19 @@ func findPipBin(ctx context.Context, cmdExecutor executor, ver string) (string, logger.Debugf("Pip binary found: %s", bin) } }() - if ver == "" || ver == "*" { - bin, err = lookForBins(cmdExecutor, "pip3", "pip2", "pip") + switch version.Compare(requiredPy, "3.0.0") { + case version.Greater, version.Equals: + bin, err = lookForBins(cmdExecutor, "pip3", "pip3.exe") if err != nil { - return "", fmt.Errorf("%w: %s", ErrPackageManagerNotFound, "pip") + return "", fmt.Errorf("%w: %s", ErrPackageManagerNotFound, "pip3") } - return bin, nil - } - if version.Compare("3.0.0", ver) != -1 { - bin, err = lookForBins(cmdExecutor, "pip3", "pip") + case version.Smaller: + bin, err = lookForBins(cmdExecutor, "pip2") if err != nil { - return "", fmt.Errorf("%w: %s", ErrPackageManagerNotFound, "pip3") + return "", fmt.Errorf("%v, %s", ErrPackageManagerNotFound, "pip2") } - return bin, nil - } - bin, err = lookForBins(cmdExecutor, "pip2", "pip", "pip3") - if err != nil { - return "", fmt.Errorf("%w: %s", ErrPackageManagerNotFound, "pip") } + return bin, nil } @@ -139,7 +476,7 @@ func installPythonDepsPip(ctx context.Context, cmdExecutor executor, bin, dir st if err := os.Setenv("PYTHONUSERBASE", dir); err != nil { return err } - args := []string{bin, "install", "--user", "--ignore-installed", "-r", "requirements.txt"} + args := []string{bin, "install", "--user", "--ignore-installed", "-r", filepath.Join(dir, "requirements.txt")} cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if _, err := cmdExecutor.ExecCommand(cmd); err != nil { diff --git a/pkg/packages/python_test.go b/pkg/packages/python_test.go index 3aa2fd2..98cfe0d 100644 --- a/pkg/packages/python_test.go +++ b/pkg/packages/python_test.go @@ -4,139 +4,368 @@ import ( "context" "errors" "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "os/exec" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestPythonVersionRegexp(t *testing.T) { + tests := map[string]struct { + input string + isMatch bool + }{ + "good input - python 3.4": { + input: "Python 3.4.0", + isMatch: true, + }, + "good input - python 2.7": { + input: "Python 2.7.10", + isMatch: true, + }, + "good input - trailing characters": { + input: "Python 3.4.0 ", + isMatch: true, + }, + "good input - trailing line break": { + input: "Python 3.4.0\n", + isMatch: true, + }, + "bad input - no version": { + input: "Python", + isMatch: false, + }, + "bad input - no python": { + input: "3.4.0", + isMatch: false, + }, + "bad input - no space": { + input: "Python3.4.0", + isMatch: false, + }, + "bad input - rubbish": { + input: "random string", + isMatch: false, + }, + "bad input - empty": { + input: "", + isMatch: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if test.isMatch { + assert.Regexp(t, pythonVersionRegex, test.input) + } else { + assert.NotRegexp(t, pythonVersionRegex, test.input) + } + }) + } +} + +func TestPipVersionRegexp(t *testing.T) { + tests := map[string]struct { + input string + isMatch bool + }{ + "good input - python 3.9": { + input: "pip 21.1.1 from /usr/local/lib/python3.9/site-packages/pip (python 3.9)", + isMatch: true, + }, + "good input - trailing line break": { + input: "pip 21.1.1 from /usr/local/lib/python3.9/site-packages/pip (python 3.9)\n", + isMatch: true, + }, + "bad input - no version": { + input: "pip from /usr/local/lib/python3.9/site-packages/pip (python 3.9)", + isMatch: false, + }, + "bad input - no python": { + input: "pip 21.1.1 from /usr/local/lib/python3.9/site-packages/pip", + isMatch: false, + }, + "bad input - no space": { + input: "pip21.1.1 from /usr/local/lib/python3.9/site-packages/pip (python 3.9)", + isMatch: false, + }, + "bad input - rubbish": { + input: "random string", + isMatch: false, + }, + "bad input - empty": { + input: "", + isMatch: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if test.isMatch { + assert.Regexp(t, pipVersionRegex, test.input) + } else { + assert.NotRegexp(t, pipVersionRegex, test.input) + } + }) + } +} + func TestInstallPython(t *testing.T) { + pip2Bin := "/test/pip2" + bashBin := "/test/bash" + py2Bin := "/test/python2" + py3Bin := "/test/python3" + py3VeBin := "veDir/bin/python" + py3PipVersion := "pip 21.0.1 from /usr/lib/python3.8/site-packages/pip (python 3.8)" + py3VenvHelp := ` +usage: venv [-h] [--system-site-packages] [--symlinks | --copies] [--clear] + [--upgrade] [--without-pip] [--prompt PROMPT] + ENV_DIR [ENV_DIR ...] +venv: error: the following arguments are required: ENV_DIR +` + py3BinWindows := "c:/Program files/Python/python3.exe" + py3WindowsPipVersion := "pip 20.1.3 from c:\\Program Files\\WindowsApps\\" + + "PythonSoftwareFoundation.Python.3.9_3.9.1264.0_x64__qbz5n2kfra8p0\\" + + "lib\\site-packages\\pip (python 3.4)" + py2Version := "Python 2.7.16" + py34Version := "Python 3.4.0" + ver2 := "2.0.0" + ver3 := "3.0.0" + ver355 := "3.5.5" + srcDir := "testDir" + veDir := "veDir" + requirementsFile := "testDir/requirements.txt" + winVePipPath := "veDir/Scripts/pip.exe" + winDeactivatePath := "veDir/Scripts/deactivate.bat" + tests := map[string]struct { - givenDir string - givenVer string - init func(*mocked) - withError error + givenDir string + veDir string + requiredPy string + goos string + init func(*mocked) + withError *error }{ - "with version 3 and pip": { - givenDir: "testDir", - givenVer: "3.0.0", + "without python 3, python 3 required": { + givenDir: "testDir", + requiredPy: ver3, + init: func(m *mocked) { + m.On("LookPath", "python3").Return("", errors.New("")).Once() + m.On("LookPath", "py.exe").Return("", errors.New("")).Once() + m.On("LookPath", "python3.exe").Return("", errors.New("")).Once() + }, + withError: &ErrRuntimeNotFound, + }, + "without python 2, python 2 required": { + givenDir: srcDir, + requiredPy: ver2, + init: func(m *mocked) { + m.On("LookPath", "python2").Return("", errors.New("")).Once() + m.On("LookPath", "py.exe").Return("", errors.New("")).Once() + m.On("LookPath", "python2.exe").Return("", errors.New("")).Once() + }, + withError: &ErrRuntimeNotFound, + }, + "with python 3.4 and pip, python 3 required": { + givenDir: srcDir, + veDir: veDir, + requiredPy: ver3, + goos: "linux", init: func(m *mocked) { - m.On("LookPath", "python3").Return("/test/python3", nil).Once() - m.On("LookPath", "pip3").Return("/test/pip3", nil).Once() + m.On("LookPath", "python3").Return(py3Bin, nil).Once() + m.On("LookPath", "bash").Return(bashBin, nil).Times(3) + m.On("ExecCommand", &exec.Cmd{ + Path: py3Bin, + Args: []string{py3Bin, "--version"}, + }, true).Return([]byte(py34Version), nil).Twice() + m.On("ExecCommand", &exec.Cmd{ + Path: py3Bin, + Args: []string{py3Bin, "-m", "pip", "--version"}, + }, true).Return([]byte(py3PipVersion), nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py3Bin, + Args: []string{py3Bin, "-m", "venv", "--version"}, + }, true).Return([]byte(py3VenvHelp), nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py3Bin, + Args: []string{py3Bin, "-m", "ensurepip", "--upgrade"}, + }, true).Return(nil, nil).Once() m.On("ExecCommand", &exec.Cmd{ - Path: "/test/python3", - Args: []string{"/test/python3", "--version"}, - }, true).Return([]byte("Python 3.1.0"), nil).Once() + Path: py3Bin, + Args: []string{py3Bin, "-m", "pip", "install", "--no-cache", "--upgrade", "pip", "setuptools"}, + }, true).Return(nil, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py3Bin, + Args: []string{py3Bin, "-m", "venv", "veDir"}, + Dir: "", + }, true).Return(nil, nil).Once() + m.On("GetOS").Return("linux").Times(4) + m.On("FileExists", veDir).Return(true, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: bashBin, + Args: []string{"source", "veDir/bin/activate"}, + Dir: "", + }, true).Return(nil, nil).Once() m.On("FileExists", "testDir/requirements.txt").Return(true, nil).Once() + m.On("FileExists", ".").Return(true, nil).Once() m.On("ExecCommand", &exec.Cmd{ - Path: "/test/pip3", - Args: []string{"/test/pip3", "install", "--user", "--ignore-installed", "-r", "requirements.txt"}, - Dir: "testDir", - }).Return(nil, nil).Once() + Path: py3VeBin, + Args: []string{py3VeBin, "-m", "pip", "install", "--upgrade", "--ignore-installed", "-r", "testDir/requirements.txt"}, + Dir: "", + }, true).Return(nil, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: bashBin, + Args: []string{"deactivate"}, + }, true).Return(nil, nil).Once() }, }, - "with version 2 and pip": { - givenDir: "testDir", - givenVer: "2.0.0", + "with py 3.4 (windows) and pip, python 3 required": { + givenDir: srcDir, + veDir: veDir, + requiredPy: ver3, + goos: "windows", init: func(m *mocked) { - m.On("LookPath", "python2").Return("/test/python2", nil).Once() - m.On("LookPath", "pip2").Return("/test/pip2", nil).Once() + m.On("LookPath", "python3").Return("", errors.New("")).Once() + m.On("LookPath", "python3.exe").Return(py3BinWindows, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py3BinWindows, + Args: []string{py3BinWindows, "-m", "pip", "--version"}, + }, true).Return([]byte(py3WindowsPipVersion), nil).Once() m.On("ExecCommand", &exec.Cmd{ - Path: "/test/python2", - Args: []string{"/test/python2", "--version"}, - }, true).Return([]byte("Python 2.1.0"), nil).Once() + Path: py3BinWindows, + Args: []string{py3BinWindows, "--version"}, + }, true).Return([]byte(py34Version), nil).Twice() + m.On("ExecCommand", &exec.Cmd{ + Path: py3BinWindows, + Args: []string{py3BinWindows, "-m", "venv", "--version"}, + }, true).Return([]byte(py3VenvHelp), nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py3BinWindows, + Args: []string{py3BinWindows, "-m", "ensurepip", "--upgrade"}, + }, true).Return(nil, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py3BinWindows, + Args: []string{py3BinWindows, "-m", "pip", "install", "--no-cache", "--upgrade", "pip", "setuptools"}, + }, true).Return(nil, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py3BinWindows, + Args: []string{py3BinWindows, "-m", "venv", "veDir"}, + Dir: "", + }, true).Return(nil, nil).Once() + m.On("FileExists", veDir).Return(true, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: "veDir/Scripts/activate.bat", + Args: []string{}, + Dir: "", + }, true).Return(nil, nil).Once() + m.On("GetOS").Return("windows").Times(4) m.On("FileExists", "testDir/requirements.txt").Return(true, nil).Once() + m.On("FileExists", ".").Return(true, nil).Once() m.On("ExecCommand", &exec.Cmd{ - Path: "/test/pip2", - Args: []string{"/test/pip2", "install", "--user", "--ignore-installed", "-r", "requirements.txt"}, - Dir: "testDir", - }).Return(nil, nil).Once() + Path: winVePipPath, + Args: []string{winVePipPath, "install", "--upgrade", "--ignore-installed", "-r", requirementsFile}, + Dir: "", + }, true).Return(nil, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: winDeactivatePath, + Args: []string{winDeactivatePath}, + }, true).Return(nil, nil).Once() }, }, - "with default version and pip": { - givenDir: "testDir", - givenVer: "*", + "with python 2, 3.9 and pip, python 2 required": { + givenDir: srcDir, + requiredPy: ver2, init: func(m *mocked) { - m.On("LookPath", "python3").Return("/test/python3", nil).Once() - m.On("LookPath", "pip3").Return("/test/pip3", nil).Once() - m.On("FileExists", "testDir/requirements.txt").Return(true, nil).Once() + m.On("LookPath", "python2").Return(py2Bin, nil).Once() + m.On("LookPath", "pip2").Return(pip2Bin, nil).Once() m.On("ExecCommand", &exec.Cmd{ - Path: "/test/pip3", - Args: []string{"/test/pip3", "install", "--user", "--ignore-installed", "-r", "requirements.txt"}, + Path: py2Bin, + Args: []string{py2Bin, "--version"}, + }, true).Return([]byte(py2Version), nil).Once() + m.On("FileExists", requirementsFile).Return(true, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: pip2Bin, + Args: []string{pip2Bin, "install", "--user", "--ignore-installed", "-r", requirementsFile}, Dir: "testDir", - }).Return(nil, nil).Once() + }).Return([]byte(py2Version), nil).Once() }, }, - "with default version and no pip": { - givenDir: "testDir", - givenVer: "*", + "with python 2, python 2 required": { + givenDir: srcDir, + requiredPy: ver2, + goos: "linux", init: func(m *mocked) { - m.On("LookPath", "python3").Return("/test/python3", nil).Once() - m.On("LookPath", "pip3").Return("/test/pip3", nil).Once() - m.On("FileExists", "testDir/requirements.txt").Return(false, nil).Once() + m.On("LookPath", "python2").Return(py2Bin, nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py2Bin, + Args: []string{py2Bin, "--version"}, + }, true).Return([]byte(py2Version), nil).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: pip2Bin, + Args: []string{pip2Bin, "install", "--user", "--ignore-installed", "-r", requirementsFile}, + Dir: srcDir, + }).Return([]byte(py2Version), nil).Once() + m.On("LookPath", "pip2").Return(pip2Bin, nil).Once() + m.On("FileExists", requirementsFile).Return(true, nil).Once() }, }, - "pip exec error": { - givenDir: "testDir", - givenVer: "*", + "with empty required version, error version not supported": { + givenDir: srcDir, + veDir: veDir, init: func(m *mocked) { - m.On("LookPath", "python3").Return("/test/python3", nil).Once() - m.On("LookPath", "pip3").Return("/test/pip3", nil).Once() - m.On("FileExists", "testDir/requirements.txt").Return(true, nil).Once() - m.On("ExecCommand", &exec.Cmd{ - Path: "/test/pip3", - Args: []string{"/test/pip3", "install", "--user", "--ignore-installed", "-r", "requirements.txt"}, - Dir: "testDir", - }).Return(nil, &exec.ExitError{}).Once() }, - withError: ErrPackageManagerExec, + withError: &ErrPythonVersionNotSupported, }, "version not found": { - givenDir: "testDir", - givenVer: "3.0.0", + givenDir: srcDir, + veDir: veDir, + requiredPy: ver3, init: func(m *mocked) { - m.On("LookPath", "python3").Return("/test/python3", nil).Once() - m.On("LookPath", "pip3").Return("/test/pip3", nil).Once() + m.On("LookPath", "python3").Return(py3Bin, nil).Once() m.On("ExecCommand", &exec.Cmd{ - Path: "/test/python3", - Args: []string{"/test/python3", "--version"}, - }, true).Return([]byte(""), nil).Once() + Path: py3Bin, + Args: []string{py3Bin, "--version"}, + }, true).Return([]byte{}, nil).Once() }, - withError: ErrRuntimeNoVersionFound, + withError: &ErrRuntimeNoVersionFound, }, "version too low": { - givenDir: "testDir", - givenVer: "3.0.5", + givenDir: srcDir, + veDir: veDir, + requiredPy: ver355, init: func(m *mocked) { - m.On("LookPath", "python3").Return("/test/python3", nil).Once() - m.On("LookPath", "pip3").Return("/test/pip3", nil).Once() + m.On("LookPath", "python3").Return(py3Bin, nil).Once() m.On("ExecCommand", &exec.Cmd{ - Path: "/test/python3", - Args: []string{"/test/python3", "--version"}, - }, true).Return([]byte("Python 3.0.1"), nil).Once() + Path: py3Bin, + Args: []string{py3Bin, "--version"}, + }, true).Return([]byte(py34Version), nil).Once() }, - withError: ErrRuntimeMinimumVersionRequired, + withError: &ErrRuntimeMinimumVersionRequired, }, - "python bin not found": { - givenDir: "testDir", - givenVer: "*", + "python 2 required, pip2 bin not found": { + givenDir: srcDir, + requiredPy: ver2, init: func(m *mocked) { - m.On("LookPath", "python3").Return("", fmt.Errorf("not found")).Once() - m.On("LookPath", "python2").Return("", fmt.Errorf("not found")).Once() - m.On("LookPath", "python").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "python2").Return(py2Bin, nil).Once() + m.On("LookPath", "pip2").Return("", fmt.Errorf("not found")).Once() + m.On("ExecCommand", &exec.Cmd{ + Path: py2Bin, + Args: []string{py2Bin, "--version"}, + }, true).Return([]byte(py2Version), nil).Once() }, - withError: ErrRuntimeNotFound, + withError: &ErrPackageManagerNotFound, }, - "pip bin not found": { - givenDir: "testDir", - givenVer: "*", + "python 2 required, just python 3 is installed": { + givenDir: srcDir, + requiredPy: ver2, init: func(m *mocked) { - m.On("LookPath", "python3").Return("/test/python3", nil).Once() - m.On("LookPath", "pip3").Return("", fmt.Errorf("not found")).Once() - m.On("LookPath", "pip2").Return("", fmt.Errorf("not found")).Once() - m.On("LookPath", "pip").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "python2").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "python2.exe").Return("", fmt.Errorf("not found")).Once() + m.On("LookPath", "py.exe").Return(py2Bin, nil).Once() + m.On("ExecCommand", &exec.Cmd{Path: py2Bin, Args: []string{"/test/python2", "--version"}}, true).Return([]byte(py34Version), nil).Once() }, - withError: ErrPackageManagerNotFound, + withError: &ErrRuntimeNotFound, }, } @@ -145,10 +374,10 @@ func TestInstallPython(t *testing.T) { m := new(mocked) test.init(m) l := langManager{m} - err := l.installPython(context.Background(), test.givenDir, test.givenVer) + err := l.installPython(context.Background(), test.veDir, test.givenDir, test.requiredPy) m.AssertExpectations(t) if test.withError != nil { - assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + assert.True(t, errors.As(err, test.withError), "want: %s; got: %s", test.withError, err) return } require.NoError(t, err) diff --git a/pkg/packages/ruby.go b/pkg/packages/ruby.go index cffd98c..20b7062 100644 --- a/pkg/packages/ruby.go +++ b/pkg/packages/ruby.go @@ -48,17 +48,13 @@ func (l *langManager) installRuby(ctx context.Context, dir, cmdReq string) error return fmt.Errorf("%w: %s:%s", ErrRuntimeNoVersionFound, "ruby", cmdReq) } - if version.Compare(cmdReq, matches[1]) == -1 { + if version.Compare(cmdReq, matches[1]) == version.Greater { logger.Debugf("Ruby Version found: %s", matches[1]) return fmt.Errorf("%w: required: %s:%s, have: %s. Please upgrade your runtime", ErrRuntimeMinimumVersionRequired, "ruby", cmdReq, matches[1]) } } - if err := installRubyDepsBundler(ctx, l.commandExecutor, dir); err != nil { - return err - } - - return nil + return installRubyDepsBundler(ctx, l.commandExecutor, dir) } func installRubyDepsBundler(ctx context.Context, cmdExecutor executor, dir string) error { diff --git a/pkg/packages/ruby_test.go b/pkg/packages/ruby_test.go index 825e487..02865b4 100644 --- a/pkg/packages/ruby_test.go +++ b/pkg/packages/ruby_test.go @@ -4,10 +4,11 @@ import ( "context" "errors" "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "os/exec" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestInstallRuby(t *testing.T) { diff --git a/pkg/stats/stats.go b/pkg/stats/stats.go index bd08105..4b2338c 100644 --- a/pkg/stats/stats.go +++ b/pkg/stats/stats.go @@ -17,7 +17,6 @@ package stats import ( "context" "fmt" - "github.com/akamai/cli/pkg/log" "io/ioutil" "net/http" "net/url" @@ -25,11 +24,11 @@ import ( "strings" "time" - "github.com/fatih/color" - "github.com/google/uuid" - "github.com/akamai/cli/pkg/config" + "github.com/akamai/cli/pkg/log" "github.com/akamai/cli/pkg/terminal" + "github.com/fatih/color" + "github.com/google/uuid" ) // Akamai CLI (optionally) tracks upgrades, package installs, and updates anonymously @@ -38,7 +37,7 @@ import ( const ( statsVersion string = "1.1" - sleepTime24Hours = time.Hour * 24 + sleep24HDuration = time.Hour * 24 ) // FirstRunCheckStats ... @@ -238,7 +237,7 @@ func CheckPing(ctx context.Context) error { } currentTime := time.Now() - if lastPing.Add(sleepTime24Hours).Before(currentTime) { + if lastPing.Add(sleep24HDuration).Before(currentTime) { doPing = true } } diff --git a/pkg/stats/stats_test.go b/pkg/stats/stats_test.go index 08b0ddd..d27fa01 100644 --- a/pkg/stats/stats_test.go +++ b/pkg/stats/stats_test.go @@ -3,6 +3,13 @@ package stats import ( "context" "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "github.com/akamai/cli/pkg/config" "github.com/akamai/cli/pkg/terminal" "github.com/akamai/cli/pkg/version" @@ -10,12 +17,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "io/ioutil" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" ) type mocked struct { diff --git a/pkg/terminal/mock.go b/pkg/terminal/mock.go index de780fd..fdcbb06 100644 --- a/pkg/terminal/mock.go +++ b/pkg/terminal/mock.go @@ -1,8 +1,9 @@ package terminal import ( - "github.com/stretchr/testify/mock" "io" + + "github.com/stretchr/testify/mock" ) // Mock terminal diff --git a/pkg/terminal/spinner.go b/pkg/terminal/spinner.go index 7cd7e7e..adb7c4c 100644 --- a/pkg/terminal/spinner.go +++ b/pkg/terminal/spinner.go @@ -2,11 +2,12 @@ package terminal import ( "fmt" - spnr "github.com/briandowns/spinner" - "github.com/fatih/color" "io" "strings" "time" + + spnr "github.com/briandowns/spinner" + "github.com/fatih/color" ) type ( diff --git a/pkg/terminal/spinner_test.go b/pkg/terminal/spinner_test.go index 4b4d7ba..e97529f 100644 --- a/pkg/terminal/spinner_test.go +++ b/pkg/terminal/spinner_test.go @@ -3,10 +3,11 @@ package terminal import ( "bytes" "fmt" - spnr "github.com/briandowns/spinner" - "github.com/stretchr/testify/assert" "testing" "time" + + spnr "github.com/briandowns/spinner" + "github.com/stretchr/testify/assert" ) func TestStart(t *testing.T) { @@ -70,7 +71,7 @@ func TestOK(t *testing.T) { } s.Start("spinner %s", "test") s.OK() - assert.Contains(t, wr.String(), fmt.Sprintf("spinner test ... [OK]")) + assert.Contains(t, wr.String(), "spinner test ... [OK]") } func TestWarn(t *testing.T) { @@ -80,7 +81,7 @@ func TestWarn(t *testing.T) { } s.Start("spinner %s", "test") s.Warn() - assert.Contains(t, wr.String(), fmt.Sprintf("spinner test ... [WARN]")) + assert.Contains(t, wr.String(), "spinner test ... [WARN]") } func TestWarnOK(t *testing.T) { @@ -90,7 +91,7 @@ func TestWarnOK(t *testing.T) { } s.Start("spinner %s", "test") s.WarnOK() - assert.Contains(t, wr.String(), fmt.Sprintf("spinner test ... [OK]")) + assert.Contains(t, wr.String(), "spinner test ... [OK]") } func TestFail(t *testing.T) { diff --git a/pkg/terminal/terminal.go b/pkg/terminal/terminal.go index b901783..bbae6c7 100644 --- a/pkg/terminal/terminal.go +++ b/pkg/terminal/terminal.go @@ -19,18 +19,15 @@ import ( "errors" "fmt" "io" + "os" "strings" "time" - "os" - + "github.com/AlecAivazis/survey/v2" "github.com/akamai/cli/pkg/version" "github.com/fatih/color" - "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" - - "github.com/AlecAivazis/survey/v2" ) type ( diff --git a/pkg/terminal/terminal_test.go b/pkg/terminal/terminal_test.go index 0481642..e98af58 100644 --- a/pkg/terminal/terminal_test.go +++ b/pkg/terminal/terminal_test.go @@ -16,11 +16,12 @@ package terminal import ( "context" - "github.com/stretchr/testify/require" - "github.com/tj/assert" "io/ioutil" "os" "testing" + + "github.com/stretchr/testify/require" + "github.com/tj/assert" ) func TestWrite(t *testing.T) { diff --git a/pkg/tools/util.go b/pkg/tools/util.go index 472449a..f205af4 100644 --- a/pkg/tools/util.go +++ b/pkg/tools/util.go @@ -28,7 +28,13 @@ func Self() string { return filepath.Base(os.Args[0]) } -// GetAkamaiCliPath ... +// GetAkamaiCliPath returns the "$AKAMAI_CLI_HOME/.akamai-cli" value and tries to create it if not existing. +// +// Errors out if: +// +// * $AKAMAI_CLI_HOME is not defined +// +// * $AKAMAI_CLI_HOME/.akamai-cli does not exist, and we cannot create it func GetAkamaiCliPath() (string, error) { cliHome := os.Getenv("AKAMAI_CLI_HOME") if cliHome == "" { @@ -48,14 +54,37 @@ func GetAkamaiCliPath() (string, error) { return cliPath, nil } -// GetAkamaiCliSrcPath ... +// GetAkamaiCliSrcPath returns $AKAMAI_CLI_HOME/.akamai-cli/src func GetAkamaiCliSrcPath() (string, error) { - cliHome, _ := GetAkamaiCliPath() + cliHome, err := GetAkamaiCliPath() + if err != nil { + return "", err + } return filepath.Join(cliHome, "src"), nil } -// Githubize .. +// GetAkamaiCliVenvPath - returns the .akamai-cli/venv path, for Python virtualenv +func GetAkamaiCliVenvPath() (string, error) { + cliHome, err := GetAkamaiCliPath() + if err != nil { + return "", err + } + + return filepath.Join(cliHome, "venv"), nil +} + +// GetPkgVenvPath - returns the package virtualenv path +func GetPkgVenvPath(pkgName string) (string, error) { + vePath, err := GetAkamaiCliVenvPath() + if err != nil { + return "", err + } + + return filepath.Join(vePath, pkgName), nil +} + +// Githubize returns the GitHub package repository URI func Githubize(repo string) string { if strings.HasPrefix(repo, "http") || strings.HasPrefix(repo, "ssh") || strings.HasSuffix(repo, ".git") { return strings.TrimPrefix(repo, "ssh://") diff --git a/pkg/tools/util_test.go b/pkg/tools/util_test.go index cbbd39f..d657f26 100644 --- a/pkg/tools/util_test.go +++ b/pkg/tools/util_test.go @@ -26,20 +26,20 @@ func TestVersionCompare(t *testing.T) { right string result int }{ - {"0.9.9", "1.0.0", 1}, - {"0.1.0", "0.2.0", 1}, - {"0.3.0", "0.3.1", 1}, - {"0.1.0", "0.1.0", 0}, - {"1.0.0", "0.9.9", -1}, - {"0.2.0", "0.1.0", -1}, - {"0.3.1", "0.3.0", -1}, - {"1", "2", 1}, - {"1.1", "1.2", 1}, - {"3.0.0", "3.1.4", 1}, - {"1.1.0", "1.1.1", 1}, - {"1.1.0", "1.1.1-dev", 1}, - {"1.0.4", "1.1.1-dev", 1}, - {"1.1.3", "1.1.4-dev", 1}, + {"0.9.9", "1.0.0", version.Smaller}, + {"0.1.0", "0.2.0", version.Smaller}, + {"0.3.0", "0.3.1", version.Smaller}, + {"0.1.0", "0.1.0", version.Equals}, + {"1.0.0", "0.9.9", version.Greater}, + {"0.2.0", "0.1.0", version.Greater}, + {"0.3.1", "0.3.0", version.Greater}, + {"1", "2", version.Smaller}, + {"1.1", "1.2", version.Smaller}, + {"3.0.0", "3.1.4", version.Smaller}, + {"1.1.0", "1.1.1", version.Smaller}, + {"1.1.0", "1.1.1-dev", version.Smaller}, + {"1.0.4", "1.1.1-dev", version.Smaller}, + {"1.1.3", "1.1.4-dev", version.Smaller}, } for _, tt := range versionTests { diff --git a/pkg/version/version.go b/pkg/version/version.go index dbd0bf4..cd97d00 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -4,26 +4,42 @@ import "github.com/Masterminds/semver" const ( // Version Application Version - Version = "1.3.1" + Version = "1.4.0" + // Equals p1==p2 in version.Compare(p1, p2) + Equals = 0 + // Error failure parsing one of the parameters in version.Compare(p1, p2) + Error = 2 + // Greater p1>p2 in version.Compare(p1, p2) + Greater = -1 + // Smaller p1 right: return -1 (version.Greater) +// +// * if left == right: return 0 (version.Equals) +// +// * if unable to parse left or right: return 2 (version.Error) func Compare(left, right string) int { leftVersion, err := semver.NewVersion(left) if err != nil { - return -2 + return Error } rightVersion, err := semver.NewVersion(right) if err != nil { - return 2 + return Error } if leftVersion.LessThan(rightVersion) { - return 1 + return Smaller } else if leftVersion.GreaterThan(rightVersion) { - return -1 + return Greater } - return 0 + return Equals } diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index 877278a..039b3df 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -1,8 +1,9 @@ package version import ( - "github.com/tj/assert" "testing" + + "github.com/tj/assert" ) func TestCompareVersion(t *testing.T) { @@ -10,11 +11,11 @@ func TestCompareVersion(t *testing.T) { left, right string expected int }{ - "left is greater than right": {"1.0.1", "1.0.0", -1}, - "left is less than right": {"0.9.0", "1.0.0", 1}, - "versions are equal": {"0.9.0", "0.9.0", 0}, - "left version does not match semver syntax": {"abc", "0.9.0", -2}, - "right version does not match semver syntax": {"1.0.0", "abc", 2}, + "left is greater than right": {"1.0.1", "1.0.0", Greater}, + "left is less than right": {"0.9.0", "1.0.0", Smaller}, + "versions are equal": {"0.9.0", "0.9.0", Equals}, + "left version does not match semver syntax": {"abc", "0.9.0", Error}, + "right version does not match semver syntax": {"1.0.0", "abc", Error}, } for name, test := range tests {