diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9c7f651..fe27be94 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,6 +60,26 @@ jobs: name: unit_coverage path: tests_coverage + unit_client: + name: Client unit tests + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: client/go.mod + + - name: Install Task + uses: go-task/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Test + run: task --yes client:test:unit + e2e_tests: name: End-to-end tests runs-on: ubuntu-22.04 @@ -136,6 +156,7 @@ jobs: if: always() needs: - unit_server + - unit_client - e2e_tests - upload_coverage uses: werf/common-ci/.github/workflows/notification.yml@main diff --git a/client/Taskfile.yaml b/client/Taskfile.yaml index b377bdfd..034b8bfd 100644 --- a/client/Taskfile.yaml +++ b/client/Taskfile.yaml @@ -162,6 +162,10 @@ tasks: outputBin: ../bin/coverage/trdl extraGoBuildArgs: '-ldflags="-s -w" -tags "test_coverage" -cover -covermode=atomic -coverpkg=./... -c cmd/trdl/*.go' + test:unit: + desc: "Run client unit tests." + cmd: go test ./... + verify:dist:binaries: desc: "Verify that the distributable binaries are built and have correct platform/arch." cmds: diff --git a/client/cmd/trdl/bin_path.go b/client/cmd/trdl/bin_path.go index d41ac194..7ee5bfad 100644 --- a/client/cmd/trdl/bin_path.go +++ b/client/cmd/trdl/bin_path.go @@ -11,23 +11,40 @@ import ( func binPathCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "bin-path REPO GROUP [CHANNEL]", + Use: "bin-path REPO GROUP [CHANNEL] | REPO VERSION", Short: "Get the directory with software binaries", DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { - if err := cobra.RangeArgs(2, 3)(cmd, args); err != nil { + if err := validateVersionArgs(cmd, args); err != nil { PrintHelp(cmd) return err } repoName := args[0] - group := args[1] if repoName == trdl.SelfUpdateDefaultRepo { PrintHelp(cmd) return fmt.Errorf("reserved repository name %q cannot be used", trdl.SelfUpdateDefaultRepo) } + c, err := trdlClient.NewClient(homeDir) + if err != nil { + return fmt.Errorf("unable to initialize trdl client: %w", err) + } + + if isVersionArg(args[1]) { + dir, err := c.GetRepoReleaseBinDir(repoName, args[1]) + if err != nil { + return err + } + + fmt.Println(dir) + + return nil + } + + group := args[1] + var optionalChannel string if len(args) == 3 { optionalChannel = args[2] @@ -37,11 +54,6 @@ func binPathCmd() *cobra.Command { } } - c, err := trdlClient.NewClient(homeDir) - if err != nil { - return fmt.Errorf("unable to initialize trdl client: %w", err) - } - dir, err := c.GetRepoChannelReleaseBinDir(repoName, group, optionalChannel) if err != nil { return err diff --git a/client/cmd/trdl/command/md_docs.go b/client/cmd/trdl/command/md_docs.go index ba9395d2..7eaf2d48 100644 --- a/client/cmd/trdl/command/md_docs.go +++ b/client/cmd/trdl/command/md_docs.go @@ -50,6 +50,11 @@ func GenMarkdownCustom(cmd *cobra.Command, w io.Writer) error { buf.WriteString(fmt.Sprintf("```shell\n%s\n```\n\n", UsageLine(cmd))) } + if len(cmd.Aliases) > 0 { + buf.WriteString("## Aliases\n\n") + buf.WriteString(fmt.Sprintf("```shell\n%s\n```\n\n", cmd.NameAndAliases())) + } + if len(cmd.Example) > 0 { buf.WriteString("## Examples\n\n") buf.WriteString(fmt.Sprintf("```shell\n%s\n```\n\n", cmd.Example)) diff --git a/client/cmd/trdl/command/template.go b/client/cmd/trdl/command/template.go index e52846e9..cc221ba3 100644 --- a/client/cmd/trdl/command/template.go +++ b/client/cmd/trdl/command/template.go @@ -16,7 +16,7 @@ const ( `{{$versionLine := versionLine .}}` // SectionAliases is the help template section that displays command aliases. - SectionAliases = `{{if gt .Aliases 0}}Aliases: + SectionAliases = `{{if gt (len .Aliases) 0}}Aliases: {{.NameAndAliases}} {{end}}` diff --git a/client/cmd/trdl/common.go b/client/cmd/trdl/common.go index acb959ac..39549f81 100644 --- a/client/cmd/trdl/common.go +++ b/client/cmd/trdl/common.go @@ -2,8 +2,10 @@ package main import ( "fmt" + "regexp" "strings" + "github.com/Masterminds/semver/v3" "github.com/asaskevich/govalidator" "github.com/spf13/cobra" @@ -21,6 +23,37 @@ func ValidateChannel(channel string) error { return nil } +// isVersionArg reports whether the given positional argument is an explicit +// release version. Versions are distinguished from a GROUP (a plain version +// number, e.g. "2") by the leading "v" prefix (e.g. "v2.72.0"). +func isVersionArg(arg string) bool { + if len(arg) < 2 || !regexp.MustCompile(`^[<>=~^v]`).MatchString(arg) { + return false + } + + return true +} + +// validateVersionArgs enforces that an explicit VERSION argument is mutually +// exclusive with the positional GROUP/CHANNEL arguments. When VERSION is given +// (args[1] has a "v" prefix), only REPO and VERSION are allowed; otherwise the +// default RangeArgs(2,3) behavior applies. +func validateVersionArgs(cmd *cobra.Command, args []string) error { + if len(args) >= 2 && isVersionArg(args[1]) { + if len(args) > 2 { + return fmt.Errorf("VERSION is mutually exclusive with GROUP and CHANNEL arguments") + } + + if _, err := semver.NewConstraint(args[1]); err != nil { + return fmt.Errorf("validate version: %w", err) + } + + return nil + } + + return cobra.RangeArgs(2, 3)(cmd, args) +} + func SetupNoSelfUpdate(cmd *cobra.Command, noSelfUpdate *bool) { envKey := "TRDL_NO_SELF_UPDATE" diff --git a/client/cmd/trdl/dir_path.go b/client/cmd/trdl/dir_path.go index 099c1b5d..2d02f2c7 100644 --- a/client/cmd/trdl/dir_path.go +++ b/client/cmd/trdl/dir_path.go @@ -11,23 +11,40 @@ import ( func dirPathCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "dir-path REPO GROUP [CHANNEL]", + Use: "dir-path REPO GROUP [CHANNEL] | REPO VERSION", Short: "Get the directory with software artifacts", DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { - if err := cobra.RangeArgs(2, 3)(cmd, args); err != nil { + if err := validateVersionArgs(cmd, args); err != nil { PrintHelp(cmd) return err } repoName := args[0] - group := args[1] if repoName == trdl.SelfUpdateDefaultRepo { PrintHelp(cmd) return fmt.Errorf("reserved repository name %q cannot be used", trdl.SelfUpdateDefaultRepo) } + c, err := trdlClient.NewClient(homeDir) + if err != nil { + return fmt.Errorf("unable to initialize trdl client: %w", err) + } + + if isVersionArg(args[1]) { + dir, err := c.GetRepoReleaseDir(repoName, args[1]) + if err != nil { + return err + } + + fmt.Println(dir) + + return nil + } + + group := args[1] + var optionalChannel string if len(args) == 3 { optionalChannel = args[2] @@ -37,11 +54,6 @@ func dirPathCmd() *cobra.Command { } } - c, err := trdlClient.NewClient(homeDir) - if err != nil { - return fmt.Errorf("unable to initialize trdl client: %w", err) - } - dir, err := c.GetRepoChannelReleaseDir(repoName, group, optionalChannel) if err != nil { return err diff --git a/client/cmd/trdl/exec.go b/client/cmd/trdl/exec.go index 4a8f3b5e..7bbdb156 100644 --- a/client/cmd/trdl/exec.go +++ b/client/cmd/trdl/exec.go @@ -3,6 +3,7 @@ package main import ( "fmt" + "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" trdlClient "github.com/werf/trdl/client/pkg/client" @@ -11,6 +12,7 @@ import ( type execCmdData struct { repoName string + version string group string optionalChannel string optionalBinaryName string @@ -19,7 +21,7 @@ type execCmdData struct { func execCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "exec REPO GROUP [CHANNEL] [BINARY_NAME] [--] [ARGS]", + Use: "exec REPO GROUP [CHANNEL] [BINARY_NAME] [--] [ARGS] | REPO VERSION [BINARY_NAME] [--] [ARGS]", Short: "Exec a software binary", DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -39,6 +41,17 @@ func execCmd() *cobra.Command { return fmt.Errorf("unable to initialize trdl client: %w", err) } + if cmdData.version != "" { + if err := c.ExecRepoReleaseBin( + cmdData.repoName, cmdData.version, + cmdData.optionalBinaryName, cmdData.optionalBinaryArgs, + ); err != nil { + return err + } + + return nil + } + if err := c.ExecRepoChannelReleaseBin( cmdData.repoName, cmdData.group, cmdData.optionalChannel, cmdData.optionalBinaryName, cmdData.optionalBinaryArgs, @@ -57,7 +70,6 @@ func processExecArgs(cmd *cobra.Command, args []string) (*execCmdData, error) { data := &execCmdData{} data.repoName = args[0] - data.group = args[1] if data.repoName == trdl.SelfUpdateDefaultRepo { return nil, fmt.Errorf("reserved repository name %q cannot be used", trdl.SelfUpdateDefaultRepo) @@ -66,6 +78,32 @@ func processExecArgs(cmd *cobra.Command, args []string) (*execCmdData, error) { doubleDashInd := cmd.ArgsLenAtDash() doubleDashExist := cmd.ArgsLenAtDash() != -1 + if isVersionArg(args[1]) { + if _, err := semver.NewConstraint(args[1]); err != nil { + return nil, fmt.Errorf("validate version: %w", err) + } + + data.version = args[1] + + restArgs := args[2:] + if doubleDashExist { + data.optionalBinaryArgs = args[doubleDashInd:] + restArgs = args[2:doubleDashInd] + } + + switch len(restArgs) { + case 0: + return data, nil + case 1: + data.optionalBinaryName = restArgs[0] + return data, nil + default: + return nil, fmt.Errorf("VERSION is mutually exclusive with GROUP and CHANNEL arguments") + } + } + + data.group = args[1] + restArgs := args[2:] if doubleDashExist { data.optionalBinaryArgs = args[doubleDashInd:] diff --git a/client/cmd/trdl/update.go b/client/cmd/trdl/update.go index 9a29965d..a1657698 100644 --- a/client/cmd/trdl/update.go +++ b/client/cmd/trdl/update.go @@ -21,29 +21,35 @@ func updateCmd() *cobra.Command { var backgroundStderrFile string cmd := &cobra.Command{ - Use: "update REPO GROUP [CHANNEL]", + Use: "update REPO GROUP [CHANNEL] | REPO VERSION", + Aliases: []string{"download"}, Short: "Update the software", DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { - if err := cobra.RangeArgs(2, 3)(cmd, args); err != nil { + if err := validateVersionArgs(cmd, args); err != nil { PrintHelp(cmd) return err } repoName := args[0] - group := args[1] if repoName == trdl.SelfUpdateDefaultRepo { PrintHelp(cmd) return fmt.Errorf("reserved repository name %q cannot be used", trdl.SelfUpdateDefaultRepo) } + isVersion := isVersionArg(args[1]) + + var group string var optionalChannel string - if len(args) == 3 { - optionalChannel = args[2] - if err := ValidateChannel(optionalChannel); err != nil { - PrintHelp(cmd) - return err + if !isVersion { + group = args[1] + if len(args) == 3 { + optionalChannel = args[2] + if err := ValidateChannel(optionalChannel); err != nil { + PrintHelp(cmd) + return err + } } } @@ -80,6 +86,14 @@ func updateCmd() *cobra.Command { } } + if isVersion { + if err := c.UpdateRepoToVersion(repoName, args[1], autoclean); err != nil { + return err + } + + return nil + } + if err := c.UpdateRepoChannel(repoName, group, optionalChannel, autoclean); err != nil { return err } diff --git a/client/cmd/trdl/use.go b/client/cmd/trdl/use.go index a4aa6e13..e6b32844 100644 --- a/client/cmd/trdl/use.go +++ b/client/cmd/trdl/use.go @@ -17,39 +17,36 @@ func useCmd() *cobra.Command { var shell string cmd := &cobra.Command{ - Use: "use REPO GROUP [CHANNEL]", + Use: "use REPO GROUP [CHANNEL] | REPO VERSION", Short: "Generate a script to use the software binaries within a shell session", Long: `Generate a script to update the software binaries in the background and use local ones within a shell session`, Example: ` # Source script in a shell $ . $(trdl use repo_name 1.2 ea) + # Pin an explicit version instead of a group/channel + # (an exact version must be prefixed with "v", otherwise it is treated as a group) + $ . $(trdl use repo_name v1.2.3) + + # Pin a semver constraint (resolves to the greatest matching release) + $ . $(trdl use repo_name '>=1.2.0') + # Force script generation for a Unix shell on Windows $ trdl use repo_name 1.2 ea --shell unix `, DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { - if err := cobra.RangeArgs(2, 3)(cmd, args); err != nil { + if err := validateVersionArgs(cmd, args); err != nil { PrintHelp(cmd) return err } repoName := args[0] - group := args[1] if repoName == trdl.SelfUpdateDefaultRepo { PrintHelp(cmd) return fmt.Errorf("reserved repository name %q cannot be used", trdl.SelfUpdateDefaultRepo) } - var optionalChannel string - if len(args) == 3 { - optionalChannel = args[2] - if err := ValidateChannel(optionalChannel); err != nil { - PrintHelp(cmd) - return err - } - } - switch shell { case trdl.ShellUnix, trdl.ShellPowerShell: default: @@ -62,6 +59,33 @@ func useCmd() *cobra.Command { return fmt.Errorf("unable to initialize trdl client: %w", err) } + if isVersionArg(args[1]) { + scriptPath, err := c.UseRepoReleaseBinDir( + repoName, + args[1], + shell, + repo.UseSourceOptions{NoSelfUpdate: noSelfUpdate}, + ) + if err != nil { + return err + } + + fmt.Println(scriptPath) + + return nil + } + + group := args[1] + + var optionalChannel string + if len(args) == 3 { + optionalChannel = args[2] + if err := ValidateChannel(optionalChannel); err != nil { + PrintHelp(cmd) + return err + } + } + scriptPath, err := c.UseRepoChannelReleaseBinDir( repoName, group, diff --git a/client/cmd/trdl/version_flag_test.go b/client/cmd/trdl/version_flag_test.go new file mode 100644 index 00000000..47c756bd --- /dev/null +++ b/client/cmd/trdl/version_flag_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func newTestCmd() *cobra.Command { + return &cobra.Command{Use: "test", RunE: func(*cobra.Command, []string) error { return nil }} +} + +func TestValidateVersionArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + {name: "channel: repo+group ok", args: []string{"repo", "1.2"}, wantErr: false}, + {name: "channel: repo+group+channel ok", args: []string{"repo", "1.2", "stable"}, wantErr: false}, + {name: "channel: only repo errors", args: []string{"repo"}, wantErr: true}, + {name: "channel: too many args errors", args: []string{"repo", "1.2", "stable", "extra"}, wantErr: true}, + {name: "version: repo+version ok", args: []string{"repo", "v1.2.3"}, wantErr: false}, + {name: "version: with channel errors (mutual excl)", args: []string{"repo", "v1.2.3", "stable"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateVersionArgs(newTestCmd(), tt.args) + if (err != nil) != tt.wantErr { + t.Fatalf("validateVersionArgs(%v) error = %v, wantErr %v", tt.args, err, tt.wantErr) + } + }) + } +} + +func TestProcessExecArgs_versionFlow(t *testing.T) { + cmd := newTestCmd() + + data, err := processExecArgs(cmd, []string{"repo", "v1.2.3"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.version != "v1.2.3" || data.group != "" || data.optionalChannel != "" { + t.Fatalf("unexpected data: %+v", data) + } + if data.optionalBinaryName != "" { + t.Fatalf("expected no binary name, got %q", data.optionalBinaryName) + } + + data, err = processExecArgs(cmd, []string{"repo", "v1.2.3", "mybin"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.version != "v1.2.3" || data.optionalBinaryName != "mybin" { + t.Fatalf("unexpected data: %+v", data) + } + + if _, err := processExecArgs(cmd, []string{"repo", "v1.2.3", "a", "b"}); err == nil { + t.Fatalf("expected mutual-exclusivity error for extra positional args") + } +} + +func TestProcessExecArgs_channelFlow(t *testing.T) { + cmd := newTestCmd() + + data, err := processExecArgs(cmd, []string{"repo", "1.2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.group != "1.2" || data.version != "" { + t.Fatalf("unexpected data: %+v", data) + } + + data, err = processExecArgs(cmd, []string{"repo", "1.2", "stable", "mybin"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.group != "1.2" || data.optionalChannel != "stable" || data.optionalBinaryName != "mybin" { + t.Fatalf("unexpected data: %+v", data) + } +} diff --git a/client/go.mod b/client/go.mod index c84e5350..53873295 100644 --- a/client/go.mod +++ b/client/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( bou.ke/monkey v1.0.2 + github.com/Masterminds/semver/v3 v3.3.1 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/gookit/color v1.5.4 github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf diff --git a/client/go.sum b/client/go.sum index bb16d32a..36f52699 100644 --- a/client/go.sum +++ b/client/go.sum @@ -1,5 +1,7 @@ bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI= bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9EBooHsakQ256ueojP7QuG32K71X/U= diff --git a/client/pkg/client/client.go b/client/pkg/client/client.go index e687229f..de805f67 100644 --- a/client/pkg/client/client.go +++ b/client/pkg/client/client.go @@ -282,6 +282,106 @@ func (c Client) UpdateRepoChannel(repoName, group, optionalChannel string, autoc return nil } +func (c Client) UpdateRepoToVersion(repoName, version string, autocleanReleases bool) error { + repoClient, err := c.GetRepoClient(repoName) + if err != nil { + return err + } + + if err := repoClient.UpdateToVersion(version); err != nil { + return err + } + + if autocleanReleases { + if err := repoClient.CleanReleases(); err != nil { + return fmt.Errorf("unable to clean old releases: %w", err) + } + } + + return nil +} + +func (c Client) UseRepoReleaseBinDir(repoName, version, shell string, opts repo.UseSourceOptions) (string, error) { + repoClient, err := c.GetRepoClient(repoName) + if err != nil { + return "", err + } + + scriptPath, err := repoClient.UseReleaseBinDir(version, shell, opts) + if err != nil { + return "", err + } + + return scriptPath, nil +} + +func (c Client) ExecRepoReleaseBin(repoName, version, optionalBinName string, args []string) error { + repoClient, err := c.GetRepoClient(repoName) + if err != nil { + return err + } + + release, err := repoClient.FindLocalReleaseByVersion(version) + if err != nil { + if e, ok := err.(repo.ReleaseNotFoundLocallyError); ok { + return prepareReleaseNotFoundLocallyErr(e) + } + + return err + } + + // Pass version env to the binary be executed. + err = os.Setenv(repo.FormatRepoVersionEnvName(repoName), release) + if err != nil { + return err + } + + err = os.Setenv(repo.FormatRepoVersionConstraintEnvName(repoName), version) + if err != nil { + return err + } + + if err := repoClient.ExecReleaseBin(release, optionalBinName, args); err != nil { + switch e := err.(type) { + case repo.ReleaseNotFoundLocallyError: + return prepareReleaseNotFoundLocallyErr(e) + case repo.ReleaseBinSeveralFilesFoundError: + return prepareReleaseBinSeveralFilesFoundErr(e) + } + + return err + } + + return nil +} + +func (c Client) GetRepoReleaseBinDir(repoName, version string) (string, error) { + repoClient, err := c.GetRepoClient(repoName) + if err != nil { + return "", err + } + + release, err := repoClient.FindLocalReleaseByVersion(version) + if err != nil { + if e, ok := err.(repo.ReleaseNotFoundLocallyError); ok { + return "", prepareReleaseNotFoundLocallyErr(e) + } + + return "", err + } + + dir, err := repoClient.GetReleaseBinDir(release) + if err != nil { + if e, ok := err.(repo.ReleaseNotFoundLocallyError); ok { + return "", prepareReleaseNotFoundLocallyErr(e) + } + + return "", err + } + + return dir, nil +} + func (c Client) UseRepoChannelReleaseBinDir(repoName, group, optionalChannel, shell string, opts repo.UseSourceOptions) (string, error) { channel, err := c.processRepoOptionalChannel(repoName, optionalChannel) if err != nil { @@ -360,6 +460,33 @@ func (c Client) GetRepoChannelReleaseDir(repoName, group, optionalChannel string return dir, nil } +func (c Client) GetRepoReleaseDir(repoName, version string) (string, error) { + repoClient, err := c.GetRepoClient(repoName) + if err != nil { + return "", err + } + + release, err := repoClient.FindLocalReleaseByVersion(version) + if err != nil { + if e, ok := err.(repo.ReleaseNotFoundLocallyError); ok { + return "", prepareReleaseNotFoundLocallyErr(e) + } + + return "", err + } + + dir, err := repoClient.GetReleaseDir(release) + if err != nil { + if e, ok := err.(repo.ReleaseNotFoundLocallyError); ok { + return "", prepareReleaseNotFoundLocallyErr(e) + } + + return "", err + } + + return dir, nil +} + func (c Client) GetRepoChannelReleaseBinDir(repoName, group, optionalChannel string) (string, error) { channel, err := c.processRepoOptionalChannel(repoName, optionalChannel) if err != nil { @@ -406,6 +533,23 @@ func prepareChannelReleaseNotFoundLocallyErr(e repo.ChannelReleaseNotFoundLocall ) } +func prepareReleaseNotFoundLocallyErr(e repo.ReleaseNotFoundLocallyError) error { + return fmt.Errorf( + "%w, update version with \"trdl update %s %s\" command", + e, + e.RepoName, + e.Version, + ) +} + +func prepareReleaseBinSeveralFilesFoundErr(e repo.ReleaseBinSeveralFilesFoundError) error { + return fmt.Errorf( + "%w: it is necessary to specify the certain name:\n - %s", + e, + strings.Join(e.Names, "\n - "), + ) +} + func prepareChannelReleaseBinSeveralFilesFoundErr(e repo.ChannelReleaseBinSeveralFilesFoundError) error { return fmt.Errorf( "%w: it is necessary to specify the certain name:\n - %s", diff --git a/client/pkg/client/interface.go b/client/pkg/client/interface.go index 08a0b8db..62674a19 100644 --- a/client/pkg/client/interface.go +++ b/client/pkg/client/interface.go @@ -12,6 +12,11 @@ type Interface interface { ExecRepoChannelReleaseBin(repoName, group, optionalChannel, optionalBinName string, args []string) error GetRepoChannelReleaseDir(repoName, group, optionalChannel string) (string, error) GetRepoChannelReleaseBinDir(repoName, group, optionalChannel string) (string, error) + UpdateRepoToVersion(repoName, version string, autocleanReleases bool) error + UseRepoReleaseBinDir(repoName, version, shell string, opts repo.UseSourceOptions) (string, error) + ExecRepoReleaseBin(repoName, version, optionalBinName string, args []string) error + GetRepoReleaseBinDir(repoName, version string) (string, error) + GetRepoReleaseDir(repoName, version string) (string, error) GetRepoList() []*RepoConfiguration GetRepoClient(repoName string) (RepoInterface, error) } @@ -25,7 +30,14 @@ type RepoInterface interface { GetChannelReleaseDir(group, channel string) (string, error) GetChannelReleaseBinDir(group, channel string) (string, error) GetChannelReleaseBinPath(group, channel, optionalBinName string) (string, error) + UpdateToVersion(version string) error + UseReleaseBinDir(version, shell string, opts repo.UseSourceOptions) (string, error) + ExecReleaseBin(version, optionalBinName string, args []string) error + GetReleaseDir(version string) (string, error) + GetReleaseBinDir(version string) (string, error) + GetReleaseBinPath(version, optionalBinName string) (string, error) CleanReleases() error + FindLocalReleaseByVersion(version string) (string, error) } type configurationInterface interface { diff --git a/client/pkg/repo/bin_path.go b/client/pkg/repo/bin_path.go index 8363aa1a..fc444605 100644 --- a/client/pkg/repo/bin_path.go +++ b/client/pkg/repo/bin_path.go @@ -22,3 +22,33 @@ func (c Client) GetChannelReleaseBinPath(group, channel, optionalBinName string) return } + +func (c Client) GetReleaseBinDir(version string) (dir string, err error) { + err = lockgate.WithAcquire(c.locker, c.updateReleaseLockName(version), lockgate.AcquireOptions{Shared: true, Timeout: trdl.DefaultLockerTimeout}, func(_ bool) error { + dir, err = c.findReleaseBinDir(version) + return err + }) + + return +} + +func (c Client) GetReleaseBinPath(version, optionalBinName string) (path string, err error) { + err = lockgate.WithAcquire(c.locker, c.updateReleaseLockName(version), lockgate.AcquireOptions{Shared: true, Timeout: trdl.DefaultLockerTimeout}, func(_ bool) error { + binDir, err := c.findReleaseBinDir(version) + if err != nil { + return err + } + + path, err = c.findBinPathInDir(binDir, optionalBinName) + if err != nil { + if e, ok := err.(ReleaseBinSeveralFilesFoundError); ok { + return NewReleaseBinSeveralFilesFoundError(c.repoName, version, e.Names) + } + return err + } + + return nil + }) + + return +} diff --git a/client/pkg/repo/clean_releases.go b/client/pkg/repo/clean_releases.go index d230a29f..57c474fc 100644 --- a/client/pkg/repo/clean_releases.go +++ b/client/pkg/repo/clean_releases.go @@ -39,7 +39,12 @@ func (c Client) CleanReleases() error { return err } - if isRecentlyUsed { + pinnedVersion, err := c.versionMetafile(releaseName).Exists(c.locker) + if err != nil { + return fmt.Errorf("ensure version metafile: %w", err) + } + + if isRecentlyUsed || pinnedVersion { continue } diff --git a/client/pkg/repo/client.go b/client/pkg/repo/client.go index 5ae6249a..30f10171 100644 --- a/client/pkg/repo/client.go +++ b/client/pkg/repo/client.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" + "github.com/Masterminds/semver/v3" + "github.com/werf/lockgate" "github.com/werf/lockgate/pkg/file_locker" "github.com/werf/trdl/client/pkg/tuf" @@ -107,6 +109,10 @@ func (c Client) channelScriptsDir(group, channel string) string { return filepath.Join(c.dir, scriptsDir, strings.Join([]string{group, channel}, "-")) } +func (c Client) versionScriptsDir(version string) string { + return filepath.Join(c.dir, scriptsDir, slugifyConstraint(version)) +} + func (c Client) channelTmpPath(group, channel string) string { return filepath.Join(c.tmpDir, channelsDir, group, channel) } @@ -119,12 +125,70 @@ func (c Client) channelScriptsTmpDir(group, channel string) string { return filepath.Join(c.tmpDir, scriptsDir, strings.Join([]string{group, channel}, "-")) } +func (c Client) versionScriptsTmpDir(version string) string { + return filepath.Join(c.tmpDir, scriptsDir, slugifyConstraint(version)) +} + func (c Client) findChannelReleaseBinPath(group, channel, optionalBinName string) (string, error) { - dir, releaseName, err := c.findChannelReleaseBinDir(group, channel) + release, err := c.GetChannelRelease(group, channel) if err != nil { return "", err } + dir, err := c.findReleaseBinDir(release) + if err != nil { + if _, ok := err.(ReleaseNotFoundLocallyError); ok { + return "", NewChannelReleaseNotFoundLocallyError(c.repoName, group, channel, release) + } + return "", err + } + + path, err := c.findBinPathInDir(dir, optionalBinName) + if err != nil { + if e, ok := err.(ReleaseBinSeveralFilesFoundError); ok { + return "", NewChannelReleaseSeveralFilesFoundError(c.repoName, group, channel, release, e.Names) + } + return "", err + } + + return path, nil +} + +func (c Client) findChannelReleaseBinDir(group, channel string) (dir, release string, err error) { + release, err = c.GetChannelRelease(group, channel) + if err != nil { + return "", "", err + } + + binDir, err := c.findReleaseBinDir(release) + if err != nil { + if _, ok := err.(ReleaseNotFoundLocallyError); ok { + return "", "", NewChannelReleaseNotFoundLocallyError(c.repoName, group, channel, release) + } + return "", "", err + } + + return binDir, release, nil +} + +func (c Client) findChannelReleaseDir(group, channel string) (dir, release string, err error) { + release, err = c.GetChannelRelease(group, channel) + if err != nil { + return "", "", err + } + + dir, err = c.findReleaseDir(release) + if err != nil { + if _, ok := err.(ReleaseNotFoundLocallyError); ok { + return "", "", NewChannelReleaseNotFoundLocallyError(c.repoName, group, channel, release) + } + return "", "", err + } + + return dir, release, nil +} + +func (c Client) findBinPathInDir(dir, optionalBinName string) (string, error) { var glob string if optionalBinName == "" { glob = filepath.Join(dir, "*") @@ -143,7 +207,7 @@ func (c Client) findChannelReleaseBinPath(group, channel, optionalBinName string names = append(names, strings.TrimPrefix(m, dir+string(os.PathSeparator))) } - return "", NewChannelReleaseSeveralFilesFoundError(c.repoName, group, channel, releaseName, names) + return "", ReleaseBinSeveralFilesFoundError{Names: names} } else if len(matches) == 0 { if optionalBinName == "" { return "", fmt.Errorf("binary file not found in release") @@ -155,45 +219,40 @@ func (c Client) findChannelReleaseBinPath(group, channel, optionalBinName string return matches[0], nil } -func (c Client) findChannelReleaseBinDir(group, channel string) (dir, release string, err error) { - releaseDir, releaseName, err := c.findChannelReleaseDir(group, channel) +func (c Client) findReleaseBinDir(release string) (dir string, err error) { + releaseDir, err := c.findReleaseDir(release) if err != nil { - return "", "", err + return "", err } binDir := filepath.Join(releaseDir, "bin") exist, err := util.IsDirExist(binDir) if err != nil { - return "", "", fmt.Errorf("unable to check existence of directory %q: %w", binDir, err) + return "", fmt.Errorf("unable to check existence of directory %q: %w", binDir, err) } if !exist { - return "", "", fmt.Errorf("bin directory not found in the release %q directory (group: %q, channel: %q)", releaseName, group, channel) + return "", fmt.Errorf("bin directory not found in the version %q directory", release) } - return binDir, releaseName, nil + return binDir, nil } -func (c Client) findChannelReleaseDir(group, channel string) (dir, release string, err error) { - release, err = c.GetChannelRelease(group, channel) - if err != nil { - return "", "", err - } - +func (c Client) findReleaseDir(release string) (dir string, err error) { dirGlob := filepath.Join(c.dir, releasesDir, release, "*") matches, err := filepath.Glob(dirGlob) if err != nil { - return "", "", fmt.Errorf("unable to glob files: %w", err) + return "", fmt.Errorf("unable to glob files: %w", err) } if len(matches) > 1 { - return "", "", fmt.Errorf("unexpected files in release directory:\n - %s", strings.Join(matches, "\n - ")) + return "", fmt.Errorf("unexpected files in release directory:\n - %s", strings.Join(matches, "\n - ")) } else if len(matches) == 0 { - return "", "", NewChannelReleaseNotFoundLocallyError(c.repoName, group, channel, release) + return "", NewReleaseNotFoundLocallyError(c.repoName, release) } - return matches[0], release, nil + return matches[0], nil } func (c Client) GetChannelRelease(group, channel string) (string, error) { @@ -249,3 +308,45 @@ func (c Client) releaseMetafile(release string) util.Metafile { filePath := filepath.Join(c.metafileDir, "releases", release) return util.NewMetafile(filePath) } + +func (c Client) versionMetafile(version string) util.Metafile { + filePath := filepath.Join(c.metafileDir, "versions", version) + return util.NewMetafile(filePath) +} + +func (c Client) FindLocalReleaseByVersion(version string) (string, error) { + dirGlob := filepath.Join(c.metafileDir, "versions", "*") + + matches, err := filepath.Glob(dirGlob) + if err != nil { + return "", fmt.Errorf("glob files: %w", err) + } + + constraint, err := semver.NewConstraint(version) + if err != nil { + return "", fmt.Errorf("parse semver constraint %q: %w", version, err) + } + + var lastValid *semver.Version + var lastValidName string + for _, file := range matches { + releaseName := strings.TrimPrefix(file, filepath.Join(c.metafileDir, "versions")+string(os.PathSeparator)) + releaseVersion, err := semver.NewVersion(releaseName) + if err != nil { + return "", fmt.Errorf("parse semver version %q: %w", releaseName, err) + } + + if constraint.Check(releaseVersion) { + if lastValid == nil || releaseVersion.GreaterThan(lastValid) { + lastValid = releaseVersion + lastValidName = releaseName + } + } + } + + if lastValid == nil { + return "", NewReleaseNotFoundLocallyError(c.repoName, version) + } + + return lastValidName, nil +} diff --git a/client/pkg/repo/dir_path.go b/client/pkg/repo/dir_path.go index 4002a479..c1185e4a 100644 --- a/client/pkg/repo/dir_path.go +++ b/client/pkg/repo/dir_path.go @@ -13,3 +13,12 @@ func (c Client) GetChannelReleaseDir(group, channel string) (dir string, err err return } + +func (c Client) GetReleaseDir(version string) (dir string, err error) { + err = lockgate.WithAcquire(c.locker, c.updateReleaseLockName(version), lockgate.AcquireOptions{Shared: true, Timeout: trdl.DefaultLockerTimeout}, func(_ bool) error { + dir, err = c.findReleaseDir(version) + return err + }) + + return +} diff --git a/client/pkg/repo/errors.go b/client/pkg/repo/errors.go index bf1d626c..b3201104 100644 --- a/client/pkg/repo/errors.go +++ b/client/pkg/repo/errors.go @@ -61,3 +61,37 @@ func NewChannelReleaseSeveralFilesFoundError(repoName, group, channel, release s func (e ChannelReleaseBinSeveralFilesFoundError) Error() string { return fmt.Sprintf("several binary files found in release %q (group: %q, channel: %q)", e.Release, e.Group, e.Channel) } + +type ReleaseNotFoundLocallyError struct { + RepoName string + Version string +} + +func NewReleaseNotFoundLocallyError(repoName, version string) error { + return ReleaseNotFoundLocallyError{ + RepoName: repoName, + Version: version, + } +} + +func (e ReleaseNotFoundLocallyError) Error() string { + return fmt.Sprintf("version %q not found locally", e.Version) +} + +type ReleaseBinSeveralFilesFoundError struct { + RepoName string + Version string + Names []string +} + +func NewReleaseBinSeveralFilesFoundError(repoName, version string, names []string) error { + return ReleaseBinSeveralFilesFoundError{ + RepoName: repoName, + Version: version, + Names: names, + } +} + +func (e ReleaseBinSeveralFilesFoundError) Error() string { + return fmt.Sprintf("several binary files found in version %q", e.Version) +} diff --git a/client/pkg/repo/exec_path.go b/client/pkg/repo/exec_path.go index cf4424b3..eae2b6b8 100644 --- a/client/pkg/repo/exec_path.go +++ b/client/pkg/repo/exec_path.go @@ -16,3 +16,22 @@ func (c Client) ExecChannelReleaseBin(group, channel, optionalBinName string, ar return util.Exec(path, args) }) } + +func (c Client) ExecReleaseBin(version, optionalBinName string, args []string) error { + return lockgate.WithAcquire(c.locker, c.updateReleaseLockName(version), lockgate.AcquireOptions{Shared: true, Timeout: trdl.DefaultLockerTimeout}, func(_ bool) error { + binDir, err := c.findReleaseBinDir(version) + if err != nil { + return err + } + + path, err := c.findBinPathInDir(binDir, optionalBinName) + if err != nil { + if e, ok := err.(ReleaseBinSeveralFilesFoundError); ok { + return NewReleaseBinSeveralFilesFoundError(c.repoName, version, e.Names) + } + return err + } + + return util.Exec(path, args) + }) +} diff --git a/client/pkg/repo/update.go b/client/pkg/repo/update.go index 08dc9645..79a24a5f 100644 --- a/client/pkg/repo/update.go +++ b/client/pkg/repo/update.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" "github.com/theupdateframework/go-tuf/data" util2 "github.com/theupdateframework/go-tuf/util" @@ -98,6 +99,27 @@ func (c Client) UpdateChannel(group, channel string) error { }) } +func (c Client) UpdateToVersion(version string) error { + if err := c.tufClient.Update(); err != nil { + return err + } + + release, err := c.findRelease(version) + if err != nil { + return fmt.Errorf("find release: %w", err) + } + + if err := c.syncChannelReleaseWithLock(release); err != nil { + return fmt.Errorf("sync channel release: %w", err) + } + + if err := c.versionMetafile(release).Reset(c.locker); err != nil { + return fmt.Errorf("reset version metafile: %w", err) + } + + return nil +} + func (c Client) syncChannelReleaseWithLock(release string) error { return lockgate.WithAcquire(c.locker, c.updateReleaseLockName(release), lockgate.AcquireOptions{Shared: false, Timeout: time.Minute * 5}, func(_ bool) error { return c.syncChannelRelease(release) @@ -199,7 +221,7 @@ func (c Client) selectAppropriateReleaseTargets(release string) (targets data.Ta if len(targets) == 0 { return nil, "", fmt.Errorf( - "channel release %q not found in the repository (os: %q, arch: %q)", + "version %q not found in the repository (os: %q, arch: %q)", release, runtime.GOOS, runtime.GOARCH, ) } @@ -237,6 +259,46 @@ func (c Client) filterTargets(prefix string) (data.TargetFiles, error) { return result, nil } +func (c Client) findRelease(version string) (string, error) { + targets, err := c.tufClient.GetTargets() + if err != nil { + return "", fmt.Errorf("get targets: %w", err) + } + + constraint, err := semver.NewConstraint(version) + if err != nil { + return "", fmt.Errorf("parse semver constraint %q: %w", version, err) + } + + var latestValid *semver.Version + var latestValidName string + for name := range targets { + if !strings.HasPrefix(name, releasesDir+"/") { + continue + } + + versionPart := strings.Split(name, "/")[1] + + releaseVersion, err := semver.NewVersion(versionPart) + if err != nil { + return "", fmt.Errorf("parse semver version %q: %w", name, err) + } + + if constraint.Check(releaseVersion) { + if latestValid == nil || releaseVersion.GreaterThan(latestValid) { + latestValid = releaseVersion + latestValidName = versionPart + } + } + } + + if latestValid == nil { + return "", fmt.Errorf("unable to find release for version %q", version) + } + + return latestValidName, nil +} + func isLocalFileUpToDate(path string, targetMeta data.TargetFileMeta) (bool, error) { exist, err := util.IsRegularFileExist(path) if err != nil { diff --git a/client/pkg/repo/use.go b/client/pkg/repo/use.go index 9ebc0679..2a449fee 100644 --- a/client/pkg/repo/use.go +++ b/client/pkg/repo/use.go @@ -14,11 +14,43 @@ import ( ) func (c Client) UseChannelReleaseBinDir(group, channel, shell string, opts UseSourceOptions) (string, error) { - name, data, err := c.prepareSourceScriptFileNameAndData(group, channel, shell, opts) + commonArgs := []string{c.repoName, group, channel} + basename := c.prepareSourceScriptBasename(fmt.Sprintf("%s_%s", group, channel), shell, opts) + envs := []sourceScriptEnv{ + {Name: FormatRepoChannelGroupEnvName(c.repoName), Value: fmt.Sprintf("%s %s", group, channel)}, + } + + name, data, err := c.prepareSourceScriptFileNameAndData(commonArgs, basename, shell, envs, opts) + if err != nil { + return "", err + } + sourceScriptPath, err := c.syncSourceScriptFile(c.channelScriptsDir(group, channel), c.channelScriptsTmpDir(group, channel), name, data) + if err != nil { + return "", err + } + + return sourceScriptPath, nil +} + +func (c Client) UseReleaseBinDir(version, shell string, opts UseSourceOptions) (string, error) { + commonArgs := []string{c.repoName, fmt.Sprintf("'%s'", version)} + basename := c.prepareSourceScriptBasename(slugifyConstraint(version), shell, opts) + + resolvedVersion, err := c.prepareVersionExtractor(shell, version) + if err != nil { + return "", fmt.Errorf("prepare version extractor: %w", err) + } + + envs := []sourceScriptEnv{ + {Name: FormatRepoVersionEnvName(c.repoName), Value: resolvedVersion}, + {Name: FormatRepoVersionConstraintEnvName(c.repoName), Value: version}, + } + + name, data, err := c.prepareSourceScriptFileNameAndData(commonArgs, basename, shell, envs, opts) if err != nil { return "", err } - sourceScriptPath, err := c.syncSourceScriptFile(group, channel, name, data) + sourceScriptPath, err := c.syncSourceScriptFile(c.versionScriptsDir(version), c.versionScriptsTmpDir(version), name, data) if err != nil { return "", err } @@ -30,12 +62,15 @@ type UseSourceOptions struct { NoSelfUpdate bool } -func (c Client) prepareSourceScriptFileNameAndData(group, channel, shell string, opts UseSourceOptions) (string, []byte, error) { - basename := c.prepareSourceScriptBasename(group, channel, shell, opts) +type sourceScriptEnv struct { + Name string + Value string +} + +func (c Client) prepareSourceScriptFileNameAndData(commonArgs []string, basename, shell string, envs []sourceScriptEnv, opts UseSourceOptions) (string, []byte, error) { logPathBackgroundUpdateStdout := filepath.Join(c.logsDir, basename+"_background_update_stdout.log") logPathBackgroundUpdateStderr := filepath.Join(c.logsDir, basename+"_background_update_stderr.log") - commonArgs := []string{c.repoName, group, channel} foregroundUpdateArgs := commonArgs[0:] backgroundUpdateArgs := append( append([]string{}, commonArgs[0:]...), @@ -52,13 +87,11 @@ func (c Client) prepareSourceScriptFileNameAndData(group, channel, shell string, commonArgsString := strings.Join(commonArgs, " ") foregroundUpdateArgsString := strings.Join(foregroundUpdateArgs, " ") backgroundUpdateArgsString := strings.Join(backgroundUpdateArgs, " ") - _ = logPathBackgroundUpdateStderr + trdlBinaryPath, err := trdl.GetTrdlBinaryPath() if err != nil { return "", nil, err } - trdlUseRepoGroupChannelEnvName := FormatRepoChannelGroupEnvName(c.repoName) - trdlUseRepoGroupChannelEnvValue := fmt.Sprintf("%s %s", group, channel) var tmpl string var ext string @@ -81,8 +114,7 @@ if ((Invoke-Expression -Command "%[5]s bin-path %[1]s" 2> $null | Out-String -Ou $trdlRepoBinPath = %[5]s bin-path %[1]s } -[System.Environment]::SetEnvironmentVariable('%[6]s','%[7]s',[System.EnvironmentVariableTarget]::Process); - +%[6]s $trdlRepoBinPath = $trdlRepoBinPath.Trim() $oldPath = [System.Environment]::GetEnvironmentVariable('PATH',[System.EnvironmentVariableTarget]::Process) $newPath = "$trdlRepoBinPath;$oldPath" @@ -103,20 +135,18 @@ else trdl_repo_bin_path="$(%[5]q bin-path %[1]s)" fi -export %[6]s="%[7]s" - +%[6]s export PATH="$trdl_repo_bin_path${PATH:+:${PATH}}" ` } script := fmt.Sprintf(tmpl, - commonArgsString, // %[1]s: REPO GROUP CHANNEL (common args string) - foregroundUpdateArgsString, // %[2]s: REPO GROUP CHANNEL [flag ...] (foreground update args string) - backgroundUpdateArgsString, // %[3]s: REPO GROUP CHANNEL [flag ...] (background update args string) - logPathBackgroundUpdateStderr, // %[4]s: (background update error file path) - trdlBinaryPath, // %[5]s: (trdl binary path) - trdlUseRepoGroupChannelEnvName, // %[6]s: (TRDL_USE__GROUP_CHANNEL) - trdlUseRepoGroupChannelEnvValue, // %[7]s: (TRDL_USE__GROUP_CHANNEL value) + commonArgsString, // %[1]s: REPO GROUP CHANNEL (common args string) + foregroundUpdateArgsString, // %[2]s: REPO GROUP CHANNEL [flag ...] (foreground update args string) + backgroundUpdateArgsString, // %[3]s: REPO GROUP CHANNEL [flag ...] (background update args string) + logPathBackgroundUpdateStderr, // %[4]s: (background update error file path) + trdlBinaryPath, // %[5]s: (trdl binary path) + formatSourceScriptEnvExports(shell, envs), // %[6]s: ) name := "source_script" @@ -129,8 +159,22 @@ export PATH="$trdl_repo_bin_path${PATH:+:${PATH}}" return name, data, nil } -func (c Client) prepareSourceScriptBasename(group, channel, shell string, opts UseSourceOptions) string { - basename := fmt.Sprintf("use_%s_%s_%s", group, channel, shell) +func formatSourceScriptEnvExports(shell string, envs []sourceScriptEnv) string { + lines := make([]string, 0, len(envs)) + for _, env := range envs { + switch shell { + case "pwsh": + lines = append(lines, fmt.Sprintf("[System.Environment]::SetEnvironmentVariable('%s',\"%s\",[System.EnvironmentVariableTarget]::Process);", env.Name, env.Value)) + default: + lines = append(lines, fmt.Sprintf("export %s=\"%s\"", env.Name, env.Value)) + } + } + + return strings.Join(lines, "\n") +} + +func (c Client) prepareSourceScriptBasename(selector, shell string, opts UseSourceOptions) string { + basename := fmt.Sprintf("use_%s_%s", selector, shell) if opts.NoSelfUpdate { basename += "_" + util.MurmurHash(fmt.Sprintf("%+v", opts)) @@ -139,8 +183,8 @@ func (c Client) prepareSourceScriptBasename(group, channel, shell string, opts U return basename } -func (c Client) syncSourceScriptFile(group, channel, name string, data []byte) (string, error) { - scriptPath := filepath.Join(c.channelScriptsDir(group, channel), name) +func (c Client) syncSourceScriptFile(scriptsDir, scriptsTmpDir, name string, data []byte) (string, error) { + scriptPath := filepath.Join(scriptsDir, name) exist, err := util.IsRegularFileExist(scriptPath) if err != nil { @@ -158,18 +202,44 @@ func (c Client) syncSourceScriptFile(group, channel, name string, data []byte) ( } } - if err := util.AtomicWriteFile(scriptPath, data, os.ModePerm, c.channelScriptsTmpDir(group, channel)); err != nil { + if err := util.AtomicWriteFile(scriptPath, data, os.ModePerm, scriptsTmpDir); err != nil { return "", fmt.Errorf("write source script %q: %w", scriptPath, err) } return scriptPath, nil } +func (c Client) prepareVersionExtractor(shell, version string) (string, error) { + trdlBinaryPath, err := trdl.GetTrdlBinaryPath() + if err != nil { + return "", err + } + + switch shell { + case "pwsh": + return fmt.Sprintf("$(((%s dir-path %s '%s') -split '[\\\\/]')[-2])", trdlBinaryPath, c.repoName, version), nil + default: + return fmt.Sprintf("$(%q dir-path %s '%s' | awk -F'/' '{print $(NF-1)}')", trdlBinaryPath, c.repoName, version), nil + } +} + // FormatRepoChannelGroupEnvName returns a formatted repo channel group env name func FormatRepoChannelGroupEnvName(repoName string) string { return fmt.Sprintf("TRDL_USE_%s_GROUP_CHANNEL", formatRepoName(repoName)) } +// FormatRepoVersionEnvName returns a formatted repo version env name +// It carries the resolved release name (e.g. "v0.0.2"). +func FormatRepoVersionEnvName(repoName string) string { + return fmt.Sprintf("TRDL_USE_%s_VERSION", formatRepoName(repoName)) +} + +// FormatRepoVersionConstraintEnvName returns a formatted repo version constraint env name. +// It carries the original version selector requested by the user (e.g. ">=0.0.1"). +func FormatRepoVersionConstraintEnvName(repoName string) string { + return fmt.Sprintf("TRDL_USE_%s_VERSION_CONSTRAINT", formatRepoName(repoName)) +} + // formatRepoName returns a formatted repository name. // It replaces all non-alphanumeric characters with underscores and converts the result to uppercase. func formatRepoName(repoName string) string { @@ -177,3 +247,19 @@ func formatRepoName(repoName string) string { formattedName := re.ReplaceAllString(repoName, "_") return strings.ToUpper(formattedName) } + +// slugifyConstraint replaces semver constraint symbols by strings for file paths +func slugifyConstraint(constraint string) string { + replacer := strings.NewReplacer( + ">=", "gte_", + "<=", "lte_", + ">", "gt_", + "<", "lt_", + "^", "caret_", + "~", "tilde_", + "=", "eq_", + " ", "", + ) + + return replacer.Replace(constraint) +} diff --git a/client/pkg/util/metafile.go b/client/pkg/util/metafile.go index dc2d1b29..e3c75f0e 100644 --- a/client/pkg/util/metafile.go +++ b/client/pkg/util/metafile.go @@ -88,3 +88,13 @@ func (f Metafile) delete() error { return nil } + +func (f Metafile) Exists(locker lockgate.Locker) (exists bool, err error) { + err = lockgate.WithAcquire(locker, f.filePath, lockgate.AcquireOptions{Shared: true, Timeout: metafileLockTimeout}, func(_ bool) error { + exists, err = IsRegularFileExist(f.filePath) + + return err + }) + + return +} diff --git a/docs/_includes/reference/cli/trdl_bin_path.md b/docs/_includes/reference/cli/trdl_bin_path.md index 764e8c61..6497b9f8 100644 --- a/docs/_includes/reference/cli/trdl_bin_path.md +++ b/docs/_includes/reference/cli/trdl_bin_path.md @@ -3,7 +3,7 @@ Get the directory with software binaries ## Syntax ```shell -trdl bin-path REPO GROUP [CHANNEL] +trdl bin-path REPO GROUP [CHANNEL] | REPO VERSION ``` ## Options inherited from parent commands diff --git a/docs/_includes/reference/cli/trdl_dir_path.md b/docs/_includes/reference/cli/trdl_dir_path.md index 5d5ce767..48ee08f1 100644 --- a/docs/_includes/reference/cli/trdl_dir_path.md +++ b/docs/_includes/reference/cli/trdl_dir_path.md @@ -3,7 +3,7 @@ Get the directory with software artifacts ## Syntax ```shell -trdl dir-path REPO GROUP [CHANNEL] +trdl dir-path REPO GROUP [CHANNEL] | REPO VERSION ``` ## Options inherited from parent commands diff --git a/docs/_includes/reference/cli/trdl_exec.md b/docs/_includes/reference/cli/trdl_exec.md index 6484dd42..adbc5d62 100644 --- a/docs/_includes/reference/cli/trdl_exec.md +++ b/docs/_includes/reference/cli/trdl_exec.md @@ -3,7 +3,7 @@ Exec a software binary ## Syntax ```shell -trdl exec REPO GROUP [CHANNEL] [BINARY_NAME] [--] [ARGS] +trdl exec REPO GROUP [CHANNEL] [BINARY_NAME] [--] [ARGS] | REPO VERSION [BINARY_NAME] [--] [ARGS] ``` ## Options inherited from parent commands diff --git a/docs/_includes/reference/cli/trdl_update.md b/docs/_includes/reference/cli/trdl_update.md index 3e6c8532..d185b9fe 100644 --- a/docs/_includes/reference/cli/trdl_update.md +++ b/docs/_includes/reference/cli/trdl_update.md @@ -3,7 +3,13 @@ Update the software ## Syntax ```shell -trdl update REPO GROUP [CHANNEL] [options] +trdl update REPO GROUP [CHANNEL] | REPO VERSION [options] +``` + +## Aliases + +```shell +update, download ``` ## Options diff --git a/docs/_includes/reference/cli/trdl_use.md b/docs/_includes/reference/cli/trdl_use.md index 78a37e40..98b4d4d0 100644 --- a/docs/_includes/reference/cli/trdl_use.md +++ b/docs/_includes/reference/cli/trdl_use.md @@ -3,7 +3,7 @@ Generate a script to update the software binaries in the background and use loca ## Syntax ```shell -trdl use REPO GROUP [CHANNEL] [options] +trdl use REPO GROUP [CHANNEL] | REPO VERSION [options] ``` ## Examples @@ -12,6 +12,13 @@ trdl use REPO GROUP [CHANNEL] [options] # Source script in a shell $ . $(trdl use repo_name 1.2 ea) + # Pin an explicit version instead of a group/channel + # (an exact version must be prefixed with "v", otherwise it is treated as a group) + $ . $(trdl use repo_name v1.2.3) + + # Pin a semver constraint (resolves to the greatest matching release) + $ . $(trdl use repo_name '>=1.2.0') + # Force script generation for a Unix shell on Windows $ trdl use repo_name 1.2 ea --shell unix diff --git a/e2e/tests/client/version_test.go b/e2e/tests/client/version_test.go new file mode 100644 index 00000000..b2ac8169 --- /dev/null +++ b/e2e/tests/client/version_test.go @@ -0,0 +1,149 @@ +package client + +import ( + "path/filepath" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/trdl/server/pkg/testutil" +) + +var _ = Describe("Version pinning", func() { + When("repo added", func() { + BeforeEach(func() { + testutil.RunSucceedCommand( + "", + trdlBinPath, + "add", "-d", testRepoName, validRepoUrl, validRootVersion, validRootSHA512, + ) + }) + + DescribeTable("should update to a version matching the selector", + func(selector, expectedRelease string) { + testutil.RunSucceedCommand( + "", + trdlBinPath, + "update", testRepoName, selector, + ) + + output := testutil.SucceedCommandOutputString( + "", + trdlBinPath, + "bin-path", testRepoName, selector, + ) + Expect(output).Should(Equal(releaseBinDir(expectedRelease) + "\n")) + }, + Entry("exact version", "v0.0.1", "v0.0.1"), + Entry("exact latest version", "v0.0.2", "v0.0.2"), + Entry("range >= resolves to the greatest match", ">=0.0.1", "v0.0.2"), + Entry("range < excludes the greatest", "<0.0.2", "v0.0.1"), + Entry("tilde constraint", "~0.0.1", "v0.0.2"), + Entry("caret constraint", "^0.0.1", "v0.0.1"), + ) + + When("updated to a pinned version", func() { + BeforeEach(func() { + testutil.RunSucceedCommand( + "", + trdlBinPath, + "update", testRepoName, "v0.0.1", + ) + }) + + It("bin-path", func() { + output := testutil.SucceedCommandOutputString( + "", + trdlBinPath, + "bin-path", testRepoName, "v0.0.1", + ) + Expect(output).Should(Equal(releaseBinDir("v0.0.1") + "\n")) + }) + + It("dir-path", func() { + output := testutil.SucceedCommandOutputString( + "", + trdlBinPath, + "dir-path", testRepoName, "v0.0.1", + ) + Expect(output).Should(Equal(releaseDir("v0.0.1") + "\n")) + }) + + It("exec", func() { + args := []string{"exec", testRepoName, "v0.0.1"} + if runtime.GOOS == "windows" { + args = append(args, "script.bat") + } + + output := testutil.SucceedCommandOutputString( + "", + trdlBinPath, + args..., + ) + + if runtime.GOOS == "windows" { + Expect(output).Should(Equal("\"v0.0.1\"\r\n")) + } else { + Expect(output).Should(Equal("v0.0.1\n")) + } + }) + }) + + It("should reject an invalid version selector on exec", func() { + _, err := testutil.RunCommandWithOptions( + "", + trdlBinPath, + []string{"exec", testRepoName, "v#bad"}, + testutil.RunCommandOptions{ShouldSucceed: false}, + ) + Expect(err).Should(HaveOccurred()) + }) + + It("should fail to exec a version that has not been pinned locally", func() { + output, err := testutil.RunCommandWithOptions( + "", + trdlBinPath, + []string{"exec", testRepoName, "v0.0.2"}, + testutil.RunCommandOptions{ShouldSucceed: false}, + ) + Expect(err).Should(HaveOccurred()) + Expect(string(output)).Should(ContainSubstring("not found locally")) + }) + + It("should preserve a pinned version across an autoclean update", func() { + testutil.RunSucceedCommand( + "", + trdlBinPath, + "update", testRepoName, "v0.0.1", + ) + + testutil.RunSucceedCommand( + "", + trdlBinPath, + "update", testRepoName, "v0.0.2", "--autoclean", + ) + + By("the older pinned version is still resolvable") + output := testutil.SucceedCommandOutputString( + "", + trdlBinPath, + "bin-path", testRepoName, "v0.0.1", + ) + Expect(output).Should(Equal(releaseBinDir("v0.0.1") + "\n")) + }) + }) +}) + +func releaseDir(release string) string { + osArch := "any-any" + if runtime.GOOS == "windows" { + osArch = "windows-any" + } + + return filepath.Join(trdlHomeDir, "repositories/test/releases", release, osArch) +} + +func releaseBinDir(release string) string { + return filepath.Join(releaseDir(release), "bin") +}