From c8f2df1fbe035a93f59c23b082c87a7b946cd012 Mon Sep 17 00:00:00 2001 From: themilchenko Date: Thu, 11 Jul 2024 12:53:45 +0300 Subject: [PATCH] install: fix update of tt master It was not possible to update tt using `tt install tt master` checked that a binary file existed for a branch and rejected to reinstall if so. After the patch `tt install` updates a tt if commit hashes of the installed tt and a last commit on the branch are different. Closes #854 --- cli/cmdcontext/cmdcontext.go | 24 +++++++- cli/cmdcontext/cmdcontext_test.go | 77 ++++++++++++++++++++++++ cli/install/install.go | 38 ++++++++++-- cli/version/version_tools.go | 47 +++++++++++++++ cli/version/version_tools_test.go | 68 +++++++++++++++++++++ test/integration/install/test_install.py | 37 ++++++++++++ 6 files changed, 285 insertions(+), 6 deletions(-) diff --git a/cli/cmdcontext/cmdcontext.go b/cli/cmdcontext/cmdcontext.go index d09af3d7b..bf7549a3c 100644 --- a/cli/cmdcontext/cmdcontext.go +++ b/cli/cmdcontext/cmdcontext.go @@ -5,6 +5,7 @@ import ( "os/exec" "strings" + "github.com/tarantool/tt/cli/util" "github.com/tarantool/tt/cli/version" "github.com/tarantool/tt/lib/integrity" ) @@ -61,6 +62,26 @@ func (tntCli *TarantoolCli) GetVersion() (version.Version, error) { return tntCli.version, nil } +// GetTtVersion returns version of Tt provided by its executable path. +func GetTtVersion(pathToBin string) (version.Version, error) { + if !util.IsRegularFile(pathToBin) { + return version.Version{}, fmt.Errorf("file %q not found", pathToBin) + } + + output, err := exec.Command(pathToBin, "--self", "version", + "--commit").Output() + if err != nil { + return version.Version{}, fmt.Errorf("failed to get tt version: %s", err) + } + + ttVersion, err := version.ParseTt(string(output)) + if err != nil { + return version.Version{}, err + } + + return ttVersion, nil +} + // CliCtx - CLI context. Contains flags passed when starting // Tarantool CLI and some other parameters. type CliCtx struct { @@ -85,7 +106,8 @@ type CliCtx struct { TarantoolCli TarantoolCli // IntegrityCheck is a public key used for integrity check. IntegrityCheck string - // This flag disables searching of other tt versions to run instead of the current one. + // This flag disables searching of other tt versions to run + // instead of the current one. IsSelfExec bool // NoPrompt flag needs to skip cli interaction using default behavior. NoPrompt bool diff --git a/cli/cmdcontext/cmdcontext_test.go b/cli/cmdcontext/cmdcontext_test.go index 80fc05668..3abd2bc15 100644 --- a/cli/cmdcontext/cmdcontext_test.go +++ b/cli/cmdcontext/cmdcontext_test.go @@ -1,6 +1,7 @@ package cmdcontext import ( + "fmt" "os" "path/filepath" "testing" @@ -79,3 +80,79 @@ exit 1`), assert.ErrorContains(t, err, "failed to get tarantool version: exit status 1") assert.Equal(t, version.Version{}, tntVersion) } + +func TestTtCli_GetVersion(t *testing.T) { + tmpDir := t.TempDir() + + type testCase struct { + name string + versionToCheck string + expectedVer version.Version + isErr bool + expectedErrMsg string + } + + cases := []testCase{ + { + name: "basic", + versionToCheck: "2.3.1.f7cc1de\n", + expectedVer: version.Version{ + Major: 2, + Minor: 3, + Patch: 1, + Hash: "f7cc1de", + Str: "2.3.1.f7cc1de", + }, + isErr: false, + expectedErrMsg: "", + }, + { + name: "parse error", + versionToCheck: "2.w.1.f7cc1de", + expectedVer: version.Version{}, + isErr: true, + expectedErrMsg: `strconv.ParseUint: parsing "w": invalid syntax`, + }, + { + name: "no dots in version", + versionToCheck: "2131f7cc1de", + expectedVer: version.Version{}, + isErr: true, + expectedErrMsg: fmt.Sprintf(`failed to parse version "2131f7cc1de\n":` + + ` format is not valid`), + }, + { + name: "version does not match", + versionToCheck: "2.1.3.1.f7cc1de", + expectedVer: version.Version{}, + isErr: true, + expectedErrMsg: fmt.Sprintf(`the version of "2.1.3.1" does not match` + + ` .. format`), + }, + { + name: "hash does not match", + versionToCheck: "2.1.3.f7cc1de_", + expectedVer: version.Version{}, + isErr: true, + expectedErrMsg: `hash "f7cc1de_" has a wrong format`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := os.WriteFile(filepath.Join(tmpDir, "tt.sh"), + []byte(fmt.Sprintf(`#!/bin/bash + echo "%s"`, tc.versionToCheck)), + 0755) + require.NoError(t, err) + + ttVersion, err := GetTtVersion(filepath.Join(tmpDir, "tt.sh")) + if tc.isErr { + require.EqualError(t, err, tc.expectedErrMsg) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.expectedVer, ttVersion) + }) + } +} diff --git a/cli/install/install.go b/cli/install/install.go index efbedfb1f..ec923cd10 100644 --- a/cli/install/install.go +++ b/cli/install/install.go @@ -549,16 +549,38 @@ func installTt(binDir string, installCtx InstallCtx, distfiles string) error { } // Check if that version is already installed. + // If it is installed, check if the newest version exists. if !installCtx.Reinstall { log.Infof("Checking existing...") - if util.IsRegularFile(filepath.Join(binDir, versionStr)) { - log.Infof("%s version of tt already exists, updating symlink...", versionStr) - err := util.CreateSymlink(versionStr, filepath.Join(binDir, "tt"), true) + pathToBin := filepath.Join(binDir, versionStr) + if util.IsRegularFile(pathToBin) { + isBinExecutable, err := util.IsExecOwner(pathToBin) if err != nil { return err } - log.Infof("Done") - return err + + isUpdatePossible, err := isUpdatePossible(installCtx, + pathToBin, + search.ProgramTt, + ttVersion, + distfiles, + isBinExecutable) + if err != nil { + return err + } + + if !isUpdatePossible { + log.Infof("%s version of tt already exists, updating symlink...", versionStr) + err := util.CreateSymlink(versionStr, filepath.Join(binDir, search.ProgramTt), true) + if err != nil { + return err + } + log.Infof("Done") + return nil + } + log.Info("Found newest commit of tt in master") + // Reduce the case to a reinstallation. + installCtx.Reinstall = true } } @@ -1234,6 +1256,12 @@ func isUpdatePossible(installCtx InstallCtx, "of an installed %s", program) } curBinHash = binVersion.Hash[1:] + } else if program == search.ProgramTt { + ttVer, err := cmdcontext.GetTtVersion(pathToBin) + if err != nil { + return false, err + } + curBinHash = ttVer.Hash } } } diff --git a/cli/version/version_tools.go b/cli/version/version_tools.go index d0ae21eed..e0276d296 100644 --- a/cli/version/version_tools.go +++ b/cli/version/version_tools.go @@ -3,6 +3,7 @@ package version import ( "fmt" "regexp" + "strings" "github.com/tarantool/tt/cli/util" ) @@ -135,6 +136,52 @@ func Parse(verStr string) (Version, error) { return version, nil } +// ParseTt parses a tt version string with format '...' +// and return the version value it represents. +func ParseTt(verStr string) (Version, error) { + verToParse := strings.Trim(verStr, "\n") + sepIndex := strings.LastIndex(verToParse, ".") + if sepIndex == -1 { + return Version{}, fmt.Errorf("failed to parse version %q: format is not valid", verStr) + } + + verStr = verToParse[:sepIndex] + numVersions := strings.Split(verStr, ".") + if len(numVersions) != 3 { + return Version{}, fmt.Errorf("the version of %q does not match"+ + " .. format", verStr) + } + + var err error + ttVersion := Version{} + + ttVersion.Major, err = util.AtoiUint64(numVersions[0]) + if err != nil { + return Version{}, err + } + ttVersion.Minor, err = util.AtoiUint64(numVersions[1]) + if err != nil { + return Version{}, err + } + ttVersion.Patch, err = util.AtoiUint64(numVersions[2]) + if err != nil { + return Version{}, err + } + + hashStr := verToParse[sepIndex+1:] + isHashValid, err := util.IsValidCommitHash(hashStr) + if err != nil { + return Version{}, err + } + if !isHashValid { + return Version{}, fmt.Errorf("hash %q has a wrong format", hashStr) + } + ttVersion.Hash = hashStr + ttVersion.Str = verToParse + + return ttVersion, nil +} + // VersionSlice attaches the methods of sort.Interface to []Version, sorting from oldest to newest. type VersionSlice []Version diff --git a/cli/version/version_tools_test.go b/cli/version/version_tools_test.go index 535b7aabb..92643b75b 100644 --- a/cli/version/version_tools_test.go +++ b/cli/version/version_tools_test.go @@ -217,3 +217,71 @@ func TestParseVersion(t *testing.T) { } } } + +func TestParseTt(t *testing.T) { + type testCase struct { + name string + inputVer string + expectedVer Version + isErr bool + expectedErrMsg string + } + + cases := []testCase{ + { + name: "basic", + inputVer: "2.3.1.f7cc1de\n", + expectedVer: Version{ + Major: 2, + Minor: 3, + Patch: 1, + Hash: "f7cc1de", + Str: "2.3.1.f7cc1de", + }, + isErr: false, + expectedErrMsg: "", + }, + { + name: "parse error", + inputVer: "2.w.1.f7cc1de", + expectedVer: Version{}, + isErr: true, + expectedErrMsg: `strconv.ParseUint: parsing "w": invalid syntax`, + }, + { + name: "no dots in version", + inputVer: "2131f7cc1de", + expectedVer: Version{}, + isErr: true, + expectedErrMsg: fmt.Sprintf(`failed to parse version "2131f7cc1de":` + + ` format is not valid`), + }, + { + name: "version does not match", + inputVer: "2.1.3.1.f7cc1de", + expectedVer: Version{}, + isErr: true, + expectedErrMsg: fmt.Sprintf(`the version of "2.1.3.1" does not match` + + ` .. format`), + }, + { + name: "hash does not match", + inputVer: "2.1.3.f7cc1de_", + expectedVer: Version{}, + isErr: true, + expectedErrMsg: `hash "f7cc1de_" has a wrong format`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resVer, err := ParseTt(tc.inputVer) + if tc.isErr { + assert.EqualError(t, err, tc.expectedErrMsg) + } else { + assert.NoError(t, err) + } + assert.Equal(t, resVer, tc.expectedVer) + }) + } +} diff --git a/test/integration/install/test_install.py b/test/integration/install/test_install.py index 9cf9bf5e3..100ef7b0a 100644 --- a/test/integration/install/test_install.py +++ b/test/integration/install/test_install.py @@ -700,3 +700,40 @@ def test_install_tarantool_fetch_latest_version( is True else None) assert instance_process_rc == 0 assert expected_install_msg in output + + +@pytest.mark.slow +@pytest.mark.parametrize("active_symlink, expected_install_msg, is_interactive", [ + ("tt_master", "Found newest commit of tt in master", True), + ("tt_master", "tt_master version of tt already exists, updating symlink...", False), + ("tt_v1.2.3", "Found newest commit of tt in master", True), +]) +def test_install_tt_fetch_latest_version(active_symlink, + expected_install_msg, + is_interactive, + tt_cmd, + tmp_path): + config_path = os.path.join(tmp_path, config_name) + # Create test config + with open(config_path, 'w') as f: + f.write(f"env:\n bin_dir: {tmp_path}\n inc_dir:\n") + + # Copying built executable to bin directory, + # renaming it to master and change symlink + # to emulate that this is master version. + shutil.copy(tt_cmd, os.path.join(tmp_path, "tt_master")) + shutil.copy(tt_cmd, os.path.join(tmp_path, "tt_v1.2.3")) + os.symlink(os.path.join(tmp_path, active_symlink), + os.path.join(tmp_path, "tt")) + + install_cmd = [tt_cmd, "--cfg", config_path] + if not is_interactive: + install_cmd.append("--no-prompt") + install_cmd.extend(["install", "tt", "master"]) + instance_process_rc, install_output = run_command_and_get_output( + install_cmd, + cwd=tmp_path, + input="y\n" if is_interactive + is True else None) + assert instance_process_rc == 0 + assert expected_install_msg in install_output