diff --git a/.golangci.yml b/.golangci.yml index ae043e3..ec8d01c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -40,7 +40,7 @@ linters: - dogsled - dupl - errcheck - - exportloopref + - copyloopvar - funlen - goconst - gocritic diff --git a/README.md b/README.md index 8aef526..5360d8c 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,27 @@ might change, but I like `dist` for the binary name. ## Overview -Without a doubt, [homebrew](https://brew.sh) has had a major impact on the macOS ecosystem. It has made it easy to -install software and keep it up to date. It has been around for 15 years and while it has evolved over time, its core -technology hasn't changed, and 15 year is an eternity in the tech world. I love homebrew, but I think there's room for -another tool. +Without a doubt, [homebrew](https://brew.sh) has had a major impact on the macOS and even the linux ecosystem. It has made it easy +to install software and keep it up to date. It has been around for 15+ years and while it has evolved over time, its core +technology hasn't changed, and 15+ years is an eternity in the tech world. + +I love homebrew, but I think there's room for another tool. + +Distillery is a tool that is designed to make it easy to install binaries on your system from multiple sources. It is +designed to be simple and easy to use. It is **NOT** designed to be a package manager or handle complex dependencies, +that's where homebrew shines. The goal of this project is to leverage the collective power of all the developers out there that are using tools like -[goreleaser](https://goreleaser.com/) and [cargo-dist](https://github.com/axodotdev/cargo-dist) and many others to -pre-compile their software and put their binaries up on GitHub or GitLab and install the binaries. +[goreleaser](https://goreleaser.com/) and [cargo-dist](https://github.com/axodotdev/cargo-dist) and many others to pre-compile their software and put their binaries up on +GitHub or GitLab and install the binaries. + +## Features + +- Make it simple to install binaries on your system from multiple sources +- Do not rely on a centralized repository of metadata like package managers +- Support binary verifications and signatures if they exist +- Support multiple platforms and architectures +- Support private repositories (this was a feature removed from homebrew) ## Needed Before 1.0 @@ -25,18 +38,46 @@ pre-compile their software and put their binaries up on GitHub or GitLab and ins ## Install +## MacOS/Linux + 1. Set your path `export PATH=$HOME/.distillery/bin:$PATH` 2. Download the latest release from the [releases page](https://github.com/ekristen/distillery/releases) 3. Extract and Run `./dist install ekristen/distillery` 4. Delete `./dist` and the .tar.gz, now use `dist` normally 5. Run `dist install owner/repo` to install a binary from GitHub Repository +## Windows + +1. [Set Your Path](#set-your-path) +2. Download the latest release from the [releases page](https://github.com/ekristen/distillery/releases) +3. Extract and Run `.\dist.exe install ekristen/distillery` +4. Delete `.\dist.exe` and the .zip, now use `dist` normally +5. Run `dist install owner/repo` to install a binary from GitHub Repository + +### Set Your Path + +#### For Current Session + +```powershell +$env:Path = "C:\Users\\.distillery\bin;" + $env:Path +``` + +#### For Current User + +```powershell +[Environment]::SetEnvironmentVariable("Path", "C:\Users\\.distillery\bin;" + $env:Path, [EnvironmentVariableTarget]::User) +``` + +### For System + +```powershell +[Environment]::SetEnvironmentVariable("Path", "C:\Users\\.distillery\bin;" + $env:Path, [EnvironmentVariableTarget]::Machine) +``` + ### Uninstall -1. Simply remove `$HOME/.distillery/bin` from your path -2. Remove `$HOME/.distillery` directory -3. Optionally remove cache directory (varies by OS, viewable by the `info` command) -4. Done +1. Run `dist info` +2. Remove the directories listed under the cleanup section ### Examples @@ -62,25 +103,24 @@ dist install gitlab/gitlab-org/gitlab-runner Often times installing from GitHub or GitLab is sufficient, but if you are on a MacOS system and Homebrew has the binary you want, you can install it using the `homebrew` scope. I would generally still recommend just -installing from GitHub. +installing from GitHub or GitLab directly. ```console dist install homebrew/opentofu ``` -## Goals - -- Make it simple to install binaries on your system from multiple sources -- Do not rely on a centralized repository of metadata like package managers -- Support binary verifications and signatures if they exist -- Support multiple platforms and architectures - ## Supported Platforms - GitHub - GitLab - Homebrew (binaries only, if anything has a dependency, it will not work at this time) -- Hashicorp +- Hashicorp (special handling for their releases, pointing to github repos will automatically pass through) + +### Authentication + +Distillery supports authentication for GitHub and GitLab. There are CLI options to pass in a token, but the preferred +method is to set the `DISTILLERY_GITHUB_TOKEN` or `DISTILLERY_GITLAB_TOKEN` environment variables using a tool like +(direnv)[https://direnv.net/]. ## Behaviors @@ -101,3 +141,10 @@ dist install homebrew/opentofu - MacOS `$HOME/Library/Caches/distillery` - Linux `$HOME/.cache/distillery` - Windows `$HOME/AppData/Local/distillery` + +### Caching + +At the moment there are two discrete caches. One for HTTP requests and one for downloads. The HTTP cache is used to +store the ETag and Last-Modified headers from the server to determine if the file has changed. The download cache is +used to store the downloaded file. The download cache is not used to determine if the file has changed, that is done +by the HTTP cache. diff --git a/go.mod b/go.mod index f0280e2..4d3375f 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,8 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 // indirect + github.com/nicksnyder/go-i18n v1.10.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index 16c8dbf..c9c8230 100644 --- a/go.sum +++ b/go.sum @@ -60,8 +60,13 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/nicksnyder/go-i18n v1.10.3 h1:0U60fnLBNrLBVt8vb8Q67yKNs+gykbQuLsIkiesJL+w= +github.com/nicksnyder/go-i18n v1.10.3/go.mod h1:hvLG5HTlZ4UfSuVLSRuX7JRUomIaoKQM19hm6f+no7o= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= diff --git a/pkg/asset/asset.go b/pkg/asset/asset.go index 62d3f68..4759993 100644 --- a/pkg/asset/asset.go +++ b/pkg/asset/asset.go @@ -6,8 +6,10 @@ import ( "compress/bzip2" "compress/gzip" "context" + "errors" "fmt" "io" + "math" "os" "path/filepath" "runtime" @@ -22,6 +24,7 @@ import ( "github.com/xi2/xz" "github.com/ekristen/distillery/pkg/common" + "github.com/ekristen/distillery/pkg/osconfig" ) var ( @@ -55,6 +58,10 @@ var ( // Type is the type of asset type Type int +func (t Type) String() string { + return [...]string{"unknown", "archive", "binary", "installer", "checksum", "signature", "key", "sbom", "data"}[t] +} + const ( Unknown Type = iota Archive @@ -111,6 +118,7 @@ type Asset struct { func (a *Asset) ID() string { return "not-implemented" } +func (a *Asset) Path() string { return "not-implemented" } func (a *Asset) GetName() string { return a.Name @@ -218,11 +226,8 @@ func (a *Asset) copyFile(srcFile, dstFile string) error { return nil } -// Install installs the asset -// TODO(ek): simplify this function -func (a *Asset) Install(id, binDir string) error { //nolint:funlen - found := false - +// determineInstallable determines if the file is installable or not based on the mimetype +func (a *Asset) determineInstallable() { logrus.Tracef("files to process: %d", len(a.Files)) for _, file := range a.Files { // Actual path to the downloaded/extracted file @@ -231,7 +236,7 @@ func (a *Asset) Install(id, binDir string) error { //nolint:funlen logrus.Debug("checking file for installable: ", file.Name) m, err := mimetype.DetectFile(fullPath) if err != nil { - return err + logrus.WithError(err).Warn("unable to determine mimetype") } logrus.Debug("found mimetype: ", m.String()) @@ -246,6 +251,18 @@ func (a *Asset) Install(id, binDir string) error { //nolint:funlen file.Installable = true } } +} + +// Install installs the asset +// TODO(ek): simplify this function +func (a *Asset) Install(id, binDir, optDir string) error { + found := false + + if err := os.MkdirAll(optDir, 0755); err != nil { + return err + } + + a.determineInstallable() for _, file := range a.Files { if !file.Installable { @@ -272,14 +289,23 @@ func (a *Asset) Install(id, binDir string) error { //nolint:funlen dstFilename = strings.ReplaceAll(dstFilename, fmt.Sprintf("v%s", a.Version), "") dstFilename = strings.ReplaceAll(dstFilename, a.Version, "") + if a.OS == osconfig.Windows || strings.HasSuffix(dstFilename, ".exe") { + dstFilename = strings.TrimSuffix(dstFilename, ".exe") + } + dstFilename = strings.TrimSpace(dstFilename) dstFilename = strings.TrimRight(dstFilename, "-") dstFilename = strings.TrimRight(dstFilename, "_") + if a.OS == osconfig.Windows { + dstFilename = fmt.Sprintf("%s.exe", dstFilename) + } + logrus.Tracef("post-dstFilename: %s", dstFilename) - destBinaryName := fmt.Sprintf("%s-%s", id, dstFilename) - destBinFilename := filepath.Join(binDir, destBinaryName) + destBinaryName := fmt.Sprintf("%s-%s", dstFilename, id) + // Note: copy to the opt dir for organization + destBinFilename := filepath.Join(optDir, destBinaryName) defaultBinFilename := filepath.Join(binDir, dstFilename) versionedBinFilename := fmt.Sprintf("%s@%s", defaultBinFilename, strings.TrimLeft(a.Version, "v")) @@ -465,7 +491,11 @@ func (a *Asset) processTar(in io.Reader) (io.Reader, error) { // TODO(ek): do we need to somehow check the location in the tar file? - target := filepath.Join(a.TempDir, header.Name) //nolint:gosec + target, err := sanitizeArchivePath(a.TempDir, header.Name) + if err != nil { + return nil, err + } + logrus.Tracef("tar > target %s", target) switch header.Typeflag { @@ -487,7 +517,12 @@ func (a *Asset) processTar(in io.Reader) (io.Reader, error) { logrus.Tracef("tar > create directory %s", baseDir) } - f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) + convertedMode, err := int64ToUint32(header.Mode) + if err != nil { + return nil, err + } + + f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(convertedMode)) if err != nil { return nil, err } @@ -535,3 +570,21 @@ func (a *Asset) processBz2(in io.Reader) (io.Reader, error) { br := bzip2.NewReader(in) return br, nil } + +func int64ToUint32(value int64) (uint32, error) { + if value < 0 || value > math.MaxUint32 { + return 0, errors.New("value out of range for uint32") + } + return uint32(value), nil +} + +// sanitizeArchivePath ensures that the path is not tainted +// thanks https://github.com/securego/gosec/issues/324#issuecomment-935927967 +func sanitizeArchivePath(d, t string) (v string, err error) { + v = filepath.Join(d, t) + if strings.HasPrefix(v, filepath.Clean(d)) { + return v, nil + } + + return "", fmt.Errorf("%s: %s", "content filepath is tainted", t) +} diff --git a/pkg/asset/asset_test.go b/pkg/asset/asset_test.go index 146a0ac..f152787 100644 --- a/pkg/asset/asset_test.go +++ b/pkg/asset/asset_test.go @@ -472,6 +472,10 @@ func TestAssetInstall(t *testing.T) { assert.NoError(t, err) defer os.RemoveAll(binDir) + optDir, err := os.MkdirTemp("", "opt") + assert.NoError(t, err) + defer os.RemoveAll(optDir) + version := c.version if version == "" { version = "1.0.0" @@ -483,7 +487,7 @@ func TestAssetInstall(t *testing.T) { err = asset.Extract() assert.NoError(t, err) - err = asset.Install("test-id", binDir) + err = asset.Install("test-id", binDir, optDir) assert.NoError(t, err) for _, fileName := range c.expectedFiles { diff --git a/pkg/asset/interface.go b/pkg/asset/interface.go index cc07be5..feeee2f 100644 --- a/pkg/asset/interface.go +++ b/pkg/asset/interface.go @@ -12,7 +12,8 @@ type IAsset interface { GetFilePath() string Download(context.Context) error Extract() error - Install(string, string) error + Install(string, string, string) error Cleanup() error ID() string + Path() string } diff --git a/pkg/checksum/compare.go b/pkg/checksum/compare.go index bd18feb..5dc57bf 100644 --- a/pkg/checksum/compare.go +++ b/pkg/checksum/compare.go @@ -6,7 +6,10 @@ import ( "hash" "io" "os" + "path/filepath" "strings" + + "github.com/sirupsen/logrus" ) func ComputeFileHash(filePath string, hashFunc func() hash.Hash) (string, error) { @@ -26,6 +29,8 @@ func ComputeFileHash(filePath string, hashFunc func() hash.Hash) (string, error) // CompareHashWithChecksumFile compares the computed hash of a file with the hashes in a checksum file. func CompareHashWithChecksumFile(fileName, filePath, checksumFilePath string, hashFunc func() hash.Hash) (bool, error) { + log := logrus.WithField("handler", "compare-hash-with-checksum-file") + // Compute the hash of the file computedHash, err := ComputeFileHash(filePath, hashFunc) if err != nil { @@ -45,14 +50,17 @@ func CompareHashWithChecksumFile(fileName, filePath, checksumFilePath string, ha line := scanner.Text() parts := strings.Fields(line) if len(parts) < 2 { + log.Trace("skipping line: ", line) continue } fileHash := parts[0] filename := parts[1] + log.Trace("fileHash: ", fileHash) + log.Trace("filename: ", filename) // Rust does *(binary) for the binary name filename = strings.TrimPrefix(filename, "*") - if filename == fileName && fileHash == computedHash { + if (filename == fileName || filepath.Base(filename) == fileName) && fileHash == computedHash { return true, nil } } diff --git a/pkg/clients/gitlab/gitlab_test.go b/pkg/clients/gitlab/client_test.go similarity index 97% rename from pkg/clients/gitlab/gitlab_test.go rename to pkg/clients/gitlab/client_test.go index 93c8bb3..f3d82ec 100644 --- a/pkg/clients/gitlab/gitlab_test.go +++ b/pkg/clients/gitlab/client_test.go @@ -164,12 +164,11 @@ func TestGitlabClientErrors(t *testing.T) { }, } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := tt.testFunc() - if tt.shouldFail { + err := tc.testFunc() + if tc.shouldFail { assert.Error(t, err) } else { assert.NoError(t, err) diff --git a/pkg/clients/hashicorp/hashicorp_test.go b/pkg/clients/hashicorp/client_test.go similarity index 97% rename from pkg/clients/hashicorp/hashicorp_test.go rename to pkg/clients/hashicorp/client_test.go index acc572d..7fdf991 100644 --- a/pkg/clients/hashicorp/hashicorp_test.go +++ b/pkg/clients/hashicorp/client_test.go @@ -155,12 +155,11 @@ func TestHashicorpClient(t *testing.T) { }, } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := tt.testFunc() - if tt.shouldFail { + err := tc.testFunc() + if tc.shouldFail { assert.Error(t, err) } else { assert.NoError(t, err) diff --git a/pkg/clients/homebrew/homebrew_test.go b/pkg/clients/homebrew/client_test.go similarity index 93% rename from pkg/clients/homebrew/homebrew_test.go rename to pkg/clients/homebrew/client_test.go index 7368c2d..c1acb1f 100644 --- a/pkg/clients/homebrew/homebrew_test.go +++ b/pkg/clients/homebrew/client_test.go @@ -73,12 +73,11 @@ func TestHomebrewClient(t *testing.T) { }, } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := tt.testFunc() - if tt.shouldFail { + err := tc.testFunc() + if tc.shouldFail { assert.Error(t, err) } else { assert.NoError(t, err) diff --git a/pkg/commands/clean/clean.go b/pkg/commands/clean/clean.go index 1c3bc7f..8e694fe 100644 --- a/pkg/commands/clean/clean.go +++ b/pkg/commands/clean/clean.go @@ -6,12 +6,13 @@ import ( "path/filepath" "strings" + "github.com/apex/log" "github.com/urfave/cli/v2" "github.com/ekristen/distillery/pkg/common" ) -func Execute(c *cli.Context) error { +func Execute(c *cli.Context) error { //nolint:gocyclo homeDir, err := os.UserHomeDir() if err != nil { return err @@ -23,6 +24,10 @@ func Execute(c *cli.Context) error { targets := make([]string, 0) bins := make([]string, 0) + if !c.Bool("no-dry-run") { + log.Warn("dry-run enabled, no changes will be made, use --no-dry-run to perform actions") + } + _ = filepath.Walk(binDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -66,7 +71,7 @@ func Execute(c *cli.Context) error { return nil }) - fmt.Println("orphaned binaries:") + log.Warn("orphaned binaries:") for _, path := range bins { found := false @@ -81,7 +86,7 @@ func Execute(c *cli.Context) error { continue } - fmt.Println(" - ", path) + log.Warnf(" - %s", path) if c.Bool("no-dry-run") { if err := os.Remove(path); err != nil { diff --git a/pkg/commands/info/info.go b/pkg/commands/info/info.go index 77274f7..8cafc76 100644 --- a/pkg/commands/info/info.go +++ b/pkg/commands/info/info.go @@ -2,41 +2,37 @@ package info import ( "fmt" - "os" - "path/filepath" "runtime" "github.com/apex/log" "github.com/urfave/cli/v2" "github.com/ekristen/distillery/pkg/common" + "github.com/ekristen/distillery/pkg/config" ) func Execute(c *cli.Context) error { - homeDir, err := os.UserHomeDir() + cfg, err := config.New(c.String("config")) if err != nil { return err } - cacheDir, err := os.UserCacheDir() - if err != nil { - return err - } - - binDir := filepath.Join(homeDir, fmt.Sprintf(".%s", common.NAME), "bin") - optDir := filepath.Join(homeDir, fmt.Sprintf(".%s", common.NAME), "opt") - log.Infof("distillery/%s", common.AppVersion.Summary) + fmt.Println("") + log.Infof("system information") log.Infof(" os: %s", runtime.GOOS) log.Infof(" arch: %s", runtime.GOARCH) - log.Infof(" home: %s", homeDir) - log.Infof(" bin: %s", binDir) - log.Infof(" opt: %s", optDir) - log.Infof(" cache: %s", filepath.Join(cacheDir, common.NAME)) - + fmt.Println("") + log.Infof("configuration") + log.Infof(" home: %s", cfg.HomePath) + log.Infof(" bin: %s", cfg.BinPath) + log.Infof(" opt: %s", cfg.OptPath) + log.Infof(" cache: %s", cfg.CachePath) + fmt.Println("") log.Warnf("To cleanup all of distillery, remove the following directories:") - log.Warnf(" - %s", filepath.Join(cacheDir, common.NAME)) - log.Warnf(" - %s", filepath.Join(homeDir, fmt.Sprintf(".%s", common.NAME))) + log.Warnf(" - %s", cfg.GetCachePath()) + log.Warnf(" - %s", cfg.BinPath) + log.Warnf(" - %s", cfg.OptPath) return nil } diff --git a/pkg/commands/install/install.go b/pkg/commands/install/install.go index 5854812..3873c5c 100644 --- a/pkg/commands/install/install.go +++ b/pkg/commands/install/install.go @@ -11,48 +11,35 @@ import ( "github.com/urfave/cli/v2" "github.com/ekristen/distillery/pkg/common" - "github.com/ekristen/distillery/pkg/source" + "github.com/ekristen/distillery/pkg/config" + "github.com/ekristen/distillery/pkg/provider" ) func Execute(c *cli.Context) error { - homeDir, err := os.UserHomeDir() + cfg, err := config.New(c.String("config")) if err != nil { return err } - cacheDir, err := os.UserCacheDir() - if err != nil { + if err := cfg.MkdirAll(); err != nil { return err } - binDir := filepath.Join(homeDir, fmt.Sprintf(".%s", common.NAME), "bin") - optDir := filepath.Join(homeDir, fmt.Sprintf(".%s", common.NAME), "opt") - metadataDir := filepath.Join(cacheDir, common.NAME, "metadata") - downloadsDir := filepath.Join(cacheDir, common.NAME, "downloads") - _ = os.MkdirAll(binDir, 0755) - _ = os.MkdirAll(metadataDir, 0755) - _ = os.MkdirAll(downloadsDir, 0755) - if c.Args().First() == "ekristen/distillery" { _ = c.Set("include-pre-releases", "true") } - src, err := source.New(c.Args().First(), &source.Options{ - OS: c.String("os"), - Arch: c.String("arch"), - HomeDir: homeDir, - CacheDir: cacheDir, - BinDir: binDir, - OptDir: optDir, - MetadataDir: metadataDir, - DownloadsDir: downloadsDir, + src, err := NewSource(c.Args().First(), &provider.Options{ + OS: c.String("os"), + Arch: c.String("arch"), + Config: cfg, Settings: map[string]interface{}{ "version": c.String("version"), "github-token": c.String("github-token"), "gitlab-token": c.String("gitlab-token"), "no-checksum-verify": c.Bool("no-checksum-verify"), - "include-pre-releases": c.Bool("include-pre-releases"), "no-score-check": c.Bool("no-score-check"), + "include-pre-releases": c.Bool("include-pre-releases"), }, }) if err != nil { @@ -85,7 +72,11 @@ func Before(c *cli.Context) error { } if c.NArg() > 1 { - return fmt.Errorf("only one binary can be specified") + for _, arg := range c.Args().Slice() { + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("flags must be specified before the binary(ies)") + } + } } parts := strings.Split(c.Args().First(), "@") @@ -105,6 +96,8 @@ func Before(c *cli.Context) error { } func Flags() []cli.Flag { + cfgDir, _ := os.UserConfigDir() + return []cli.Flag{ &cli.StringFlag{ Name: "version", @@ -142,6 +135,13 @@ func Flags() []cli.Flag { Usage: "Specify the architecture to install", Value: runtime.GOARCH, }, + &cli.PathFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Specify the configuration file to use", + EnvVars: []string{"DISTILLERY_CONFIG"}, + Value: filepath.Join(cfgDir, fmt.Sprintf("%s.yaml", common.NAME)), + }, &cli.StringFlag{ Name: "github-token", Usage: "GitHub token to use for GitHub API requests", diff --git a/pkg/commands/install/source.go b/pkg/commands/install/source.go new file mode 100644 index 0000000..cb2e8ec --- /dev/null +++ b/pkg/commands/install/source.go @@ -0,0 +1,81 @@ +package install + +import ( + "fmt" + "strings" + + "github.com/ekristen/distillery/pkg/osconfig" + "github.com/ekristen/distillery/pkg/provider" + "github.com/ekristen/distillery/pkg/source" +) + +func NewSource(src string, opts *provider.Options) (provider.ISource, error) { + detectedOS := osconfig.New(opts.OS, opts.Arch) + + version := "latest" + versionParts := strings.Split(src, "@") + if len(versionParts) > 1 { + src = versionParts[0] + version = versionParts[1] + } + + parts := strings.Split(src, "/") + + if len(parts) == 1 { + return nil, fmt.Errorf("invalid install source, expect format of owner/repo or owner/repo@version") + } + + if len(parts) == 2 { + // could be GitHub or Homebrew or Hashicorp + if parts[0] == source.HomebrewSource { + return &source.Homebrew{ + Provider: provider.Provider{Options: opts, OSConfig: detectedOS}, + Formula: parts[1], + Version: version, + }, nil + } else if parts[0] == source.HashicorpSource { + return &source.Hashicorp{ + Provider: provider.Provider{Options: opts, OSConfig: detectedOS}, + Owner: parts[1], + Repo: parts[1], + Version: version, + }, nil + } + + return &source.GitHub{ + Provider: provider.Provider{Options: opts, OSConfig: detectedOS}, + Owner: parts[0], + Repo: parts[1], + Version: version, + }, nil + } else if len(parts) >= 3 { + if strings.HasPrefix(parts[0], "github") { + if parts[1] == source.HashicorpSource { + return &source.Hashicorp{ + Provider: provider.Provider{Options: opts, OSConfig: detectedOS}, + Owner: parts[1], + Repo: parts[2], + Version: version, + }, nil + } + + return &source.GitHub{ + Provider: provider.Provider{Options: opts, OSConfig: detectedOS}, + Owner: parts[1], + Repo: parts[2], + Version: version, + }, nil + } else if strings.HasPrefix(parts[0], "gitlab") { + return &source.GitLab{ + Provider: provider.Provider{Options: opts, OSConfig: detectedOS}, + Owner: parts[1], + Repo: parts[2], + Version: version, + }, nil + } + + return nil, nil + } + + return nil, nil +} diff --git a/pkg/commands/list/list.go b/pkg/commands/list/list.go index bc09d03..ed6b6e2 100644 --- a/pkg/commands/list/list.go +++ b/pkg/commands/list/list.go @@ -70,7 +70,7 @@ func init() { cmd := &cli.Command{ Name: "list", Usage: "list", - Description: `list installed binaries`, + Description: `list installed binaries and versions`, Before: common.Before, Flags: common.Flags(), Action: Execute, diff --git a/pkg/commands/uninstall/uninstall.go b/pkg/commands/uninstall/uninstall.go new file mode 100644 index 0000000..502bd1f --- /dev/null +++ b/pkg/commands/uninstall/uninstall.go @@ -0,0 +1,24 @@ +package uninstall + +import ( + "github.com/urfave/cli/v2" + + "github.com/ekristen/distillery/pkg/common" +) + +func Execute(c *cli.Context) error { + return nil +} + +func init() { + cmd := &cli.Command{ + Name: "uninstall", + Usage: "uninstall binaries", + Description: `uninstall binaries and all versions`, + Before: common.Before, + Flags: common.Flags(), + Action: Execute, + } + + common.RegisterCommand(cmd) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 809bc99..778c5d3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,4 +1,130 @@ package config +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/pelletier/go-toml/v2" + + "github.com/ekristen/distillery/pkg/common" +) + type Config struct { + // HomePath - path to store the configuration files, this path is set by default based on the operating system type + // and your user's home directory. Typically, this is set to $HOME/.distillery + HomePath string `yaml:"home_path" toml:"home_path"` + + // BinPath - path to create symlinks for your binaries, this path is set by default based on the operating system type + // This is the path that is added to your PATH environment variable. Typically, this is set to $HOME/.distillery/bin + BinPath string `yaml:"bin_path" toml:"bin_path"` + + // OptPath - path to store the binaries that are installed, this path is set by default based on the operating + // system type. This is where the symlinks in the BinPath point to. Typically, this is set to $HOME/.distillery/opt + OptPath string `yaml:"opt_path" toml:"opt_path"` + + // CachePath - path to store cache files, this path is set by default based on the operating system type + CachePath string `yaml:"cache_path" toml:"cache_path"` + + // DefaultSource - the default source to use when installing binaries, this defaults to github + DefaultSource string `yaml:"default_source" toml:"default_source"` + + // AutomaticAliases - automatically create aliases for any binary that is installed + AutomaticAliases bool `yaml:"automatic_aliases" toml:"automatic_aliases"` + + // Aliases - Allow for creating shorthand aliases for source locations that you use frequently. A good example + // of this is `distillery` -> `ekristen/distillery` + Aliases map[string]string `yaml:"aliases" toml:"aliases"` + + // Language - the language to use for the output of the application + Language string `yaml:"language" toml:"language"` +} + +func (c *Config) GetCachePath() string { + return filepath.Join(c.CachePath, common.NAME) +} + +func (c *Config) GetMetadataPath() string { + return filepath.Join(c.CachePath, common.NAME, "metadata") +} + +func (c *Config) GetDownloadsPath() string { + return filepath.Join(c.CachePath, common.NAME, "downloads") +} + +func (c *Config) MkdirAll() error { + paths := []string{c.BinPath, c.OptPath, c.CachePath, c.GetMetadataPath(), c.GetDownloadsPath()} + + for _, path := range paths { + err := os.MkdirAll(path, 0755) + if err != nil { + return err + } + } + + return nil +} + +// Load - load the configuration file +func (c *Config) Load(path string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + if strings.HasSuffix(path, ".yaml") { + return yaml.Unmarshal(data, c) + } else if strings.HasSuffix(path, ".toml") { + return toml.Unmarshal(data, c) + } + + return nil +} + +// New - create a new configuration object +func New(path string) (*Config, error) { + cfg := &Config{} + if err := cfg.Load(path); err != nil { + return cfg, err + } + + if cfg.Language == "" { + cfg.Language = "en" + } + + if cfg.DefaultSource == "" { + cfg.DefaultSource = "github" + } + + if cfg.HomePath == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return cfg, err + } + cfg.HomePath = filepath.Join(homeDir, fmt.Sprintf(".%s", common.NAME)) + } + + if cfg.CachePath == "" { + cacheDir, err := os.UserCacheDir() + if err != nil { + return cfg, err + } + cfg.CachePath = cacheDir + } + + if cfg.BinPath == "" { + cfg.BinPath = filepath.Join(cfg.HomePath, "bin") + } + + if cfg.OptPath == "" { + cfg.OptPath = filepath.Join(cfg.HomePath, "opt") + } + + return cfg, nil } diff --git a/pkg/osconfig/os.go b/pkg/osconfig/osconfig.go similarity index 95% rename from pkg/osconfig/os.go rename to pkg/osconfig/osconfig.go index 53ddd2e..8e943d3 100644 --- a/pkg/osconfig/os.go +++ b/pkg/osconfig/osconfig.go @@ -6,6 +6,7 @@ const ( Windows = "windows" Linux = "linux" Darwin = "darwin" + FreeBSD = "freebsd" AMD64 = "amd64" ARM64 = "arm64" @@ -43,11 +44,11 @@ func (o *OS) GetExtensions() []string { func (o *OS) InvalidOS() []string { switch o.Name { case Windows: - return []string{Linux, Darwin} + return []string{Linux, Darwin, FreeBSD} case Linux: return []string{Windows, Darwin} case Darwin: - return []string{Windows, Linux} + return []string{Windows, Linux, FreeBSD} } return []string{} diff --git a/pkg/osconfig/osconfig_test.go b/pkg/osconfig/osconfig_test.go index 1ef30d1..b0d0180 100644 --- a/pkg/osconfig/osconfig_test.go +++ b/pkg/osconfig/osconfig_test.go @@ -1,7 +1,6 @@ package osconfig_test import ( - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -32,11 +31,9 @@ func TestOS_GetOS(t *testing.T) { }, } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - fmt.Println(tt.os.GetOS()) - assert.ElementsMatch(t, tt.expected, tt.os.GetOS()) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.ElementsMatch(t, tc.expected, tc.os.GetOS()) }) } } @@ -94,10 +91,9 @@ func TestOS_GetArchitectures(t *testing.T) { }, } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - assert.ElementsMatch(t, tt.expected, tt.os.GetArchitectures()) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.ElementsMatch(t, tc.expected, tc.os.GetArchitectures()) }) } } @@ -125,10 +121,9 @@ func TestOS_GetExtensions(t *testing.T) { }, } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - assert.ElementsMatch(t, tt.expected, tt.os.GetExtensions()) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.ElementsMatch(t, tc.expected, tc.os.GetExtensions()) }) } } diff --git a/pkg/provider/interface.go b/pkg/provider/interface.go new file mode 100644 index 0000000..095500b --- /dev/null +++ b/pkg/provider/interface.go @@ -0,0 +1,13 @@ +package provider + +import "context" + +type ISource interface { + GetSource() string + GetOwner() string + GetRepo() string + GetApp() string + GetID() string + GetDownloadsDir() string + Run(context.Context) error +} diff --git a/pkg/source/source.go b/pkg/provider/provider.go similarity index 52% rename from pkg/source/source.go rename to pkg/provider/provider.go index c062104..708b17b 100644 --- a/pkg/source/source.go +++ b/pkg/provider/provider.go @@ -1,4 +1,4 @@ -package source +package provider import ( "context" @@ -6,7 +6,6 @@ import ( "encoding/base64" "errors" "fmt" - "os" "path/filepath" "strings" @@ -16,6 +15,7 @@ import ( "github.com/ekristen/distillery/pkg/asset" "github.com/ekristen/distillery/pkg/checksum" + "github.com/ekristen/distillery/pkg/config" "github.com/ekristen/distillery/pkg/cosign" "github.com/ekristen/distillery/pkg/osconfig" "github.com/ekristen/distillery/pkg/score" @@ -25,30 +25,14 @@ const ( VersionLatest = "latest" ) -type ISource interface { - GetSource() string - GetOwner() string - GetRepo() string - GetApp() string - GetID() string - GetDownloadsDir() string - Run(context.Context) error -} - type Options struct { - OS string - Arch string - HomeDir string - CacheDir string - BinDir string - OptDir string - MetadataDir string - DownloadsDir string - + OS string + Arch string + Config *config.Config Settings map[string]interface{} } -type Source struct { +type Provider struct { Options *Options OSConfig *osconfig.OS Assets []asset.IAsset @@ -58,32 +42,36 @@ type Source struct { Key asset.IAsset } -func (s *Source) GetOS() string { - return s.Options.OS +func (p *Provider) GetOS() string { + return p.Options.OS } -func (s *Source) GetArch() string { - return s.Options.Arch +func (p *Provider) GetArch() string { + return p.Options.Arch } -// commonRun - common run logic for all sources that includes download, extract, install and cleanup -func (s *Source) commonRun(ctx context.Context) error { - if err := s.Download(ctx); err != nil { +// CommonRun - common run logic for all sources that includes download, extract, install and cleanup +func (p *Provider) CommonRun(ctx context.Context) error { + if err := p.Download(ctx); err != nil { return err } - defer func(s *Source) { + defer func(s *Provider) { err := s.Cleanup() if err != nil { log.WithError(err).Error("unable to cleanup") } - }(s) + }(p) - if err := s.Extract(); err != nil { + if err := p.Verify(); err != nil { return err } - if err := s.Install(); err != nil { + if err := p.Extract(); err != nil { + return err + } + + if err := p.Install(); err != nil { return err } @@ -92,13 +80,13 @@ func (s *Source) commonRun(ctx context.Context) error { // Discover will attempt to discover and categorize the assets provided // TODO(ek): split up and refactor this function as it's way too complex -func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo +func (p *Provider) Discover(names []string) error { //nolint:funlen,gocyclo fileScoring := map[asset.Type][]string{} fileScored := map[asset.Type][]score.Sorted{} - logrus.Tracef("discover: starting - %d", len(s.Assets)) + logrus.Tracef("discover: starting - %d", len(p.Assets)) - for _, a := range s.Assets { + for _, a := range p.Assets { if _, ok := fileScoring[a.GetType()]; !ok { fileScoring[a.GetType()] = []string{} } @@ -117,9 +105,9 @@ func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo continue } - detectedOS := s.OSConfig.GetOS() - arch := s.OSConfig.GetArchitectures() - ext := s.OSConfig.GetExtensions() + detectedOS := p.OSConfig.GetOS() + arch := p.OSConfig.GetArchitectures() + ext := p.OSConfig.GetExtensions() if _, ok := fileScored[k]; !ok { fileScored[k] = []score.Sorted{} @@ -130,8 +118,8 @@ func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo Arch: arch, Extensions: ext, Names: names, - InvalidOS: s.OSConfig.InvalidOS(), - InvalidArch: s.OSConfig.InvalidArchitectures(), + InvalidOS: p.OSConfig.InvalidOS(), + InvalidArch: p.OSConfig.InvalidArchitectures(), }) if len(fileScored[k]) > 0 { @@ -144,12 +132,12 @@ func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo } } - if !highEnoughScore && !s.Options.Settings["no-score-check"].(bool) { + if !highEnoughScore && !p.Options.Settings["no-score-check"].(bool) { log.Error("no matching asset found, score too low") for _, t := range []asset.Type{asset.Binary, asset.Unknown, asset.Archive} { for _, v := range fileScored[t] { if v.Value < 40 { - log.Errorf("closest matching: %s (%d) (threshold: 40) -- override with --no-score-check", v.Key, v.Value) + log.Errorf("closest matching: %p (%d) (threshold: 40) -- override with --no-score-check", v.Key, v.Value) return errors.New("no matching asset found, score too low") } } @@ -168,20 +156,20 @@ func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo logrus.Tracef("skipped > (%d) too low: %s (%d)", t, topScored.Key, topScored.Value) continue } - for _, a := range s.Assets { + for _, a := range p.Assets { if topScored.Key == a.GetName() { - s.Binary = a + p.Binary = a break } } } - if s.Binary != nil { + if p.Binary != nil { break } } - if s.Binary == nil { + if p.Binary == nil { return errors.New("no binary found") } @@ -192,9 +180,9 @@ func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo continue } - detectedOS := s.OSConfig.GetOS() - arch := s.OSConfig.GetArchitectures() - ext := s.OSConfig.GetExtensions() + detectedOS := p.OSConfig.GetOS() + arch := p.OSConfig.GetArchitectures() + ext := p.OSConfig.GetExtensions() if k == asset.Key { ext = []string{"key", "pub", "pem"} @@ -218,9 +206,9 @@ func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo OS: detectedOS, Arch: arch, Extensions: ext, - Names: []string{strings.ReplaceAll(s.Binary.GetName(), filepath.Ext(s.Binary.GetName()), "")}, - InvalidOS: s.OSConfig.InvalidOS(), - InvalidArch: s.OSConfig.InvalidArchitectures(), + Names: []string{strings.ReplaceAll(p.Binary.GetName(), filepath.Ext(p.Binary.GetName()), "")}, + InvalidOS: p.OSConfig.InvalidOS(), + InvalidArch: p.OSConfig.InvalidArchitectures(), }) if len(fileScored[k]) > 0 { @@ -228,60 +216,60 @@ func (s *Source) Discover(names []string) error { //nolint:funlen,gocyclo } } - for _, a := range s.Assets { + for _, a := range p.Assets { for k, v := range fileScored { vv := v[0] if a.GetType() == asset.Checksum && a.GetType() == k && a.GetName() == vv.Key { //nolint:gocritic - s.Checksum = a + p.Checksum = a } if a.GetType() == asset.Signature && a.GetType() == k && a.GetName() == vv.Key { //nolint:gocritic - s.Signature = a + p.Signature = a } if a.GetType() == asset.Key && a.GetType() == k && a.GetName() == vv.Key { //nolint:gocritic - s.Key = a + p.Key = a } } } - if s.Binary != nil { - logrus.Tracef("best binary: %s", s.Binary.GetName()) + if p.Binary != nil { + logrus.Tracef("best binary: %s", p.Binary.GetName()) } - if s.Checksum != nil { - logrus.Tracef("best checksum: %s", s.Checksum.GetName()) + if p.Checksum != nil { + logrus.Tracef("best checksum: %s", p.Checksum.GetName()) } - if s.Signature != nil { - logrus.Tracef("best signature: %s", s.Signature.GetName()) + if p.Signature != nil { + logrus.Tracef("best signature: %s", p.Signature.GetName()) } - if s.Key != nil { - logrus.Tracef("best key: %s", s.Key.GetName()) + if p.Key != nil { + logrus.Tracef("best key: %s", p.Key.GetName()) } return nil } -func (s *Source) Download(ctx context.Context) error { +func (p *Provider) Download(ctx context.Context) error { log.Info("downloading assets") - if s.Binary != nil { - if err := s.Binary.Download(ctx); err != nil { + if p.Binary != nil { + if err := p.Binary.Download(ctx); err != nil { return err } } - if s.Signature != nil { - if err := s.Signature.Download(ctx); err != nil { + if p.Signature != nil { + if err := p.Signature.Download(ctx); err != nil { return err } } - if s.Checksum != nil { - if err := s.Checksum.Download(ctx); err != nil { + if p.Checksum != nil { + if err := p.Checksum.Download(ctx); err != nil { return err } } - if s.Key != nil { - if err := s.Key.Download(ctx); err != nil { + if p.Key != nil { + if err := p.Key.Download(ctx); err != nil { return err } } @@ -289,28 +277,28 @@ func (s *Source) Download(ctx context.Context) error { return nil } -func (s *Source) Verify() error { - if err := s.verifyChecksum(); err != nil { +func (p *Provider) Verify() error { + if err := p.verifyChecksum(); err != nil { return err } - return s.verifySignature() + return p.verifySignature() } -func (s *Source) verifySignature() error { +func (p *Provider) verifySignature() error { if true { - logrus.Debug("skipping signature verification") + log.Debug("skipping signature verification") return nil } logrus.Info("verifying signature") - cosignFileContent, err := os.ReadFile(s.Checksum.GetFilePath()) + cosignFileContent, err := os.ReadFile(p.Checksum.GetFilePath()) if err != nil { return err } - publicKeyContentEncoded, err := os.ReadFile(s.Key.GetFilePath()) + publicKeyContentEncoded, err := os.ReadFile(p.Key.GetFilePath()) if err != nil { return err } @@ -327,7 +315,7 @@ func (s *Source) verifySignature() error { fmt.Printf("Public Key: %+v\n", pubKey) - sigData, err := os.ReadFile(s.Signature.GetFilePath()) + sigData, err := os.ReadFile(p.Signature.GetFilePath()) if err != nil { return err } @@ -344,22 +332,23 @@ func (s *Source) verifySignature() error { return nil } -func (s *Source) verifyChecksum() error { - if v, ok := s.Options.Settings["no-checksum-verify"]; ok && v.(bool) { +// verifyChecksum - verify the checksum of the binary +func (p *Provider) verifyChecksum() error { + if v, ok := p.Options.Settings["no-checksum-verify"]; ok && v.(bool) { log.Warn("skipping checksum verification") return nil } - if s.Checksum == nil { + if p.Checksum == nil { log.Warn("skipping checksum verification (no checksum)") return nil } logrus.Debug("verifying checksum") - logrus.Tracef("binary: %s", s.Binary.GetName()) + logrus.Tracef("binary: %s", p.Binary.GetName()) - match, err := checksum.CompareHashWithChecksumFile(s.Binary.GetName(), - s.Binary.GetFilePath(), s.Checksum.GetFilePath(), sha256.New) + match, err := checksum.CompareHashWithChecksumFile(p.Binary.GetName(), + p.Binary.GetFilePath(), p.Checksum.GetFilePath(), sha256.New) if err != nil { return err } @@ -375,85 +364,15 @@ func (s *Source) verifyChecksum() error { return nil } -func (s *Source) Extract() error { - return s.Binary.Extract() -} - -func (s *Source) Install() error { - return s.Binary.Install(s.Binary.ID(), s.Options.BinDir) +func (p *Provider) Extract() error { + return p.Binary.Extract() } -func (s *Source) Cleanup() error { - return s.Binary.Cleanup() +func (p *Provider) Install() error { + return p.Binary.Install( + p.Binary.ID(), p.Options.Config.BinPath, filepath.Join(p.Options.Config.OptPath, p.Binary.Path())) } -func New(source string, opts *Options) (ISource, error) { - detectedOS := osconfig.New(opts.OS, opts.Arch) - - version := VersionLatest - versionParts := strings.Split(source, "@") - if len(versionParts) > 1 { - source = versionParts[0] - version = versionParts[1] - } - - parts := strings.Split(source, "/") - - if len(parts) == 1 { - return nil, fmt.Errorf("invalid install source, expect format of owner/repo or owner/repo@version") - } - - if len(parts) == 2 { - // could be GitHub or Homebrew or Hashicorp - if parts[0] == HomebrewSource { - return &Homebrew{ - Source: Source{Options: opts, OSConfig: detectedOS}, - Formula: parts[1], - Version: version, - }, nil - } else if parts[0] == HashicorpSource { - return &Hashicorp{ - Source: Source{Options: opts, OSConfig: detectedOS}, - Owner: parts[1], - Repo: parts[1], - Version: version, - }, nil - } - - return &GitHub{ - Source: Source{Options: opts, OSConfig: detectedOS}, - Owner: parts[0], - Repo: parts[1], - Version: version, - }, nil - } else if len(parts) >= 3 { - if strings.HasPrefix(parts[0], "github") { - if parts[1] == HashicorpSource { - return &Hashicorp{ - Source: Source{Options: opts, OSConfig: detectedOS}, - Owner: parts[1], - Repo: parts[2], - Version: version, - }, nil - } - - return &GitHub{ - Source: Source{Options: opts, OSConfig: detectedOS}, - Owner: parts[1], - Repo: parts[2], - Version: version, - }, nil - } else if strings.HasPrefix(parts[0], "gitlab") { - return &GitLab{ - Source: Source{Options: opts, OSConfig: detectedOS}, - Owner: parts[1], - Repo: parts[2], - Version: version, - }, nil - } - - return nil, nil - } - - return nil, nil +func (p *Provider) Cleanup() error { + return p.Binary.Cleanup() } diff --git a/pkg/source/source_test.go b/pkg/provider/provider_test.go similarity index 89% rename from pkg/source/source_test.go rename to pkg/provider/provider_test.go index ad8f0b6..08db764 100644 --- a/pkg/source/source_test.go +++ b/pkg/provider/provider_test.go @@ -1,4 +1,4 @@ -package source_test +package provider_test import ( "fmt" @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/ekristen/distillery/pkg/asset" + "github.com/ekristen/distillery/pkg/commands/install" "github.com/ekristen/distillery/pkg/osconfig" + "github.com/ekristen/distillery/pkg/provider" "github.com/ekristen/distillery/pkg/source" ) @@ -21,7 +23,7 @@ func Test_New(t *testing.T) { cases := []struct { source string - want source.ISource + want provider.ISource }{ { source: "ekristen/aws-nuke", @@ -82,7 +84,7 @@ func Test_New(t *testing.T) { for _, tt := range cases { t.Run(tt.source, func(t *testing.T) { - got, err := source.New(tt.source, &source.Options{}) + got, err := install.NewSource(tt.source, &provider.Options{}) assert.NoError(t, err) assert.Equal(t, tt.want.GetSource(), got.GetSource()) }) @@ -102,6 +104,7 @@ type testSourceDiscoverMatrix struct { } type testSourceDiscoverExpected struct { + error string binary string signature string checksum string @@ -401,6 +404,45 @@ func TestSourceDiscover(t *testing.T) { }, }, }, + { + name: "nerdctl", + filenames: []string{ + "nerdctl-1.7.7-freebsd-amd64.tar.gz", + "nerdctl-1.7.7-go-mod-vendor.tar.gz", + "nerdctl-1.7.7-linux-amd64.tar.gz", + "nerdctl-1.7.7-linux-amd-v7.tar.gz", + "nerdctl-1.7.7-linux-arm64.tar.gz", + "nerdctl-1.7.7-linux-ppc64le.tar.gz", + "nerdctl-1.7.7-linux-riscv64.tar.gz", + "nerdctl-1.7.7-linux-s390x.tar.gz", + "nerdctl-1.7.7-windows-amd64.tar.gz", + "nerdctl-full-1.7.7-linux-amd64.tar.gz", + "nerdctl-full-1.7.7-linux-arm64.tar.gz", + "SHA256SUMS", + "SHA256SUMS.asc", + }, + matrix: []testSourceDiscoverMatrix{ + { + os: "darwin", + arch: "amd64", + expected: testSourceDiscoverExpected{ + error: "no matching asset found, score too low", + binary: "", + signature: "", + checksum: "", + }, + }, + { + os: "linux", + arch: "arm64", + expected: testSourceDiscoverExpected{ + binary: "nerdctl-1.7.7-linux-arm64.tar.gz", + signature: "SHA256SUMS.asc", + checksum: "SHA256SUMS", + }, + }, + }, + }, } t.Parallel() @@ -419,9 +461,9 @@ func TestSourceDiscover(t *testing.T) { assets = append(assets, newA) } - testSource := source.Source{ + testSource := provider.Provider{ OSConfig: osconfig.New(m.os, m.arch), - Options: &source.Options{ + Options: &provider.Options{ OS: m.os, Arch: m.arch, Settings: map[string]interface{}{ @@ -432,6 +474,11 @@ func TestSourceDiscover(t *testing.T) { } err := testSource.Discover([]string{tc.name}) + if m.expected.error != "" { + assert.EqualError(t, err, m.expected.error) + return + } + assert.NoError(t, err) if m.expected.binary != "" { diff --git a/pkg/source/github.go b/pkg/source/github.go index ab677a6..2856688 100644 --- a/pkg/source/github.go +++ b/pkg/source/github.go @@ -3,6 +3,7 @@ package source import ( "context" "fmt" + "path/filepath" "strings" @@ -13,12 +14,13 @@ import ( "github.com/sirupsen/logrus" "github.com/ekristen/distillery/pkg/asset" + "github.com/ekristen/distillery/pkg/provider" ) const GitHubSource = "github" type GitHub struct { - Source + provider.Provider client *github.Client @@ -43,7 +45,7 @@ func (s *GitHub) GetApp() string { } func (s *GitHub) GetDownloadsDir() string { - return filepath.Join(s.Options.DownloadsDir, s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) + return filepath.Join(s.Options.Config.GetDownloadsPath(), s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) } func (s *GitHub) GetID() string { @@ -56,12 +58,12 @@ func (s *GitHub) Run(ctx context.Context) error { return err } - // this is from the Source struct + // this is from the Provider struct if err := s.Discover([]string{s.Repo}); err != nil { return err } - if err := s.commonRun(ctx); err != nil { + if err := s.CommonRun(ctx); err != nil { return err } @@ -70,7 +72,7 @@ func (s *GitHub) Run(ctx context.Context) error { // sourceRun - run the source specific logic func (s *GitHub) sourceRun(ctx context.Context) error { - cacheFile := filepath.Join(s.Options.MetadataDir, fmt.Sprintf("cache-%s", s.GetID())) + cacheFile := filepath.Join(s.Options.Config.GetMetadataPath(), fmt.Sprintf("cache-%s", s.GetID())) s.client = github.NewClient(httpcache.NewTransport(diskcache.New(cacheFile)).Client()) githubToken := s.Options.Settings["github-token"].(string) @@ -95,7 +97,7 @@ func (s *GitHub) FindRelease(ctx context.Context) error { var err error var release *github.RepositoryRelease - if s.Version == VersionLatest { + if s.Version == provider.VersionLatest { release, _, err = s.client.Repositories.GetLatestRelease(ctx, s.GetOwner(), s.GetRepo()) if err != nil && !strings.Contains(err.Error(), "404 Not Found") { return err diff --git a/pkg/source/github_asset.go b/pkg/source/github_asset.go index 369d412..8937266 100644 --- a/pkg/source/github_asset.go +++ b/pkg/source/github_asset.go @@ -24,7 +24,11 @@ type GitHubAsset struct { } func (a *GitHubAsset) ID() string { - return fmt.Sprintf("%s-%s-%s-%d", a.GitHub.GetOwner(), a.GitHub.GetRepo(), a.GitHub.Version, a.ReleaseAsset.GetID()) + return fmt.Sprintf("%s-%d", a.GetType(), a.ReleaseAsset.GetID()) +} + +func (a *GitHubAsset) Path() string { + return filepath.Join("github", a.GitHub.GetOwner(), a.GitHub.GetRepo(), a.GitHub.Version) } func (a *GitHubAsset) Download(ctx context.Context) error { @@ -90,7 +94,7 @@ func (a *GitHubAsset) Download(ctx context.Context) error { a.Hash = string(hasher.Sum(nil)) logrus.Tracef("Downloaded asset to: %s", tmpFile.Name()) - logrus.Tracef(a.ReleaseAsset.GetName()) + logrus.Tracef("Release asset name: %s", a.ReleaseAsset.GetName()) return nil } diff --git a/pkg/source/gitlab.go b/pkg/source/gitlab.go index e319023..b7ef659 100644 --- a/pkg/source/gitlab.go +++ b/pkg/source/gitlab.go @@ -5,14 +5,16 @@ import ( "fmt" "path/filepath" - "github.com/ekristen/distillery/pkg/asset" - "github.com/ekristen/distillery/pkg/clients/gitlab" "github.com/gregjones/httpcache" "github.com/gregjones/httpcache/diskcache" + + "github.com/ekristen/distillery/pkg/asset" + "github.com/ekristen/distillery/pkg/clients/gitlab" + "github.com/ekristen/distillery/pkg/provider" ) type GitLab struct { - Source + provider.Provider client *gitlab.Client @@ -40,11 +42,11 @@ func (s *GitLab) GetID() string { } func (s *GitLab) GetDownloadsDir() string { - return filepath.Join(s.Options.DownloadsDir, s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) + return filepath.Join(s.Options.Config.GetDownloadsPath(), s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) } func (s *GitLab) sourceRun(ctx context.Context) error { - cacheFile := filepath.Join(s.Options.MetadataDir, fmt.Sprintf("cache-%s", s.GetID())) + cacheFile := filepath.Join(s.Options.Config.GetMetadataPath(), fmt.Sprintf("cache-%s", s.GetID())) s.client = gitlab.NewClient(httpcache.NewTransport(diskcache.New(cacheFile)).Client()) token := s.Options.Settings["gitlab-token"].(string) @@ -52,7 +54,7 @@ func (s *GitLab) sourceRun(ctx context.Context) error { s.client.SetToken(token) } - if s.Version == VersionLatest { + if s.Version == provider.VersionLatest { release, err := s.client.GetLatestRelease(ctx, fmt.Sprintf("%s/%s", s.Owner, s.Repo)) if err != nil { return err @@ -93,7 +95,7 @@ func (s *GitLab) Run(ctx context.Context) error { return err } - if err := s.commonRun(ctx); err != nil { + if err := s.CommonRun(ctx); err != nil { return err } diff --git a/pkg/source/gitlab_asset.go b/pkg/source/gitlab_asset.go index 1d7b054..e99f687 100644 --- a/pkg/source/gitlab_asset.go +++ b/pkg/source/gitlab_asset.go @@ -24,7 +24,11 @@ type GitLabAsset struct { } func (a *GitLabAsset) ID() string { - return fmt.Sprintf("%s-%s-%s-%d", a.GitLab.GetOwner(), a.GitLab.GetRepo(), a.GitLab.Version, a.Link.ID) + return fmt.Sprintf("%s-%d", a.GetType(), a.Link.ID) +} + +func (a *GitLabAsset) Path() string { + return filepath.Join("gitlab", a.GitLab.GetOwner(), a.GitLab.GetRepo(), a.GitLab.Version) } func (a *GitLabAsset) Download(ctx context.Context) error { //nolint:dupl,nolintlint @@ -47,7 +51,7 @@ func (a *GitLabAsset) Download(ctx context.Context) error { //nolint:dupl,nolint } if stats != nil { - logrus.Debug("file already downloaded") + logrus.Debugf("file already downloaded: %s", assetFile) return nil } diff --git a/pkg/source/hashicorp.go b/pkg/source/hashicorp.go index 06ee526..8ed077f 100644 --- a/pkg/source/hashicorp.go +++ b/pkg/source/hashicorp.go @@ -12,12 +12,13 @@ import ( "github.com/ekristen/distillery/pkg/asset" "github.com/ekristen/distillery/pkg/clients/hashicorp" + "github.com/ekristen/distillery/pkg/provider" ) const HashicorpSource = "hashicorp" type Hashicorp struct { - Source + provider.Provider client *hashicorp.Client @@ -43,11 +44,11 @@ func (s *Hashicorp) GetID() string { } func (s *Hashicorp) GetDownloadsDir() string { - return filepath.Join(s.Options.DownloadsDir, s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) + return filepath.Join(s.Options.Config.GetDownloadsPath(), s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) } func (s *Hashicorp) sourceRun(ctx context.Context) error { - cacheFile := filepath.Join(s.Options.MetadataDir, fmt.Sprintf("cache-%s", s.GetID())) + cacheFile := filepath.Join(s.Options.Config.GetMetadataPath(), fmt.Sprintf("cache-%s", s.GetID())) s.client = hashicorp.NewClient(httpcache.NewTransport(diskcache.New(cacheFile)).Client()) @@ -89,6 +90,27 @@ func (s *Hashicorp) sourceRun(ctx context.Context) error { }) } + if len(release.URLShasums) > 0 { + s.Assets = append(s.Assets, &HashicorpAsset{ + Asset: asset.New(filepath.Base(release.URLShasums), "", s.GetOS(), s.GetArch(), s.Version), + Hashicorp: s, + Build: &hashicorp.Build{ + URL: release.URLShasums, + }, + Release: release, + }) + } + if len(release.URLShasumsSignatures) > 0 { + s.Assets = append(s.Assets, &HashicorpAsset{ + Asset: asset.New(filepath.Base(release.URLShasumsSignatures[0]), "", s.GetOS(), s.GetArch(), s.Version), + Hashicorp: s, + Build: &hashicorp.Build{ + URL: release.URLShasumsSignatures[0], + }, + Release: release, + }) + } + return nil } @@ -101,7 +123,7 @@ func (s *Hashicorp) Run(ctx context.Context) error { return err } - if err := s.commonRun(ctx); err != nil { + if err := s.CommonRun(ctx); err != nil { return err } diff --git a/pkg/source/hashicorp_asset.go b/pkg/source/hashicorp_asset.go index df05ff4..b7766b6 100644 --- a/pkg/source/hashicorp_asset.go +++ b/pkg/source/hashicorp_asset.go @@ -28,8 +28,11 @@ func (a *HashicorpAsset) ID() string { urlHash := sha256.Sum256([]byte(a.Build.URL)) urlHashShort := fmt.Sprintf("%x", urlHash)[:9] - return fmt.Sprintf("%s-%s-%s-%s", - a.Hashicorp.GetSource(), a.Hashicorp.GetRepo(), a.Hashicorp.Version, urlHashShort) + return fmt.Sprintf("%s-%s", a.GetType(), urlHashShort) +} + +func (a *HashicorpAsset) Path() string { + return filepath.Join("hashicorp", a.Hashicorp.GetRepo(), a.Hashicorp.Version) } func (a *HashicorpAsset) Download(ctx context.Context) error { diff --git a/pkg/source/homebrew.go b/pkg/source/homebrew.go index 9d7177d..a939b6a 100644 --- a/pkg/source/homebrew.go +++ b/pkg/source/homebrew.go @@ -12,12 +12,13 @@ import ( "github.com/ekristen/distillery/pkg/asset" "github.com/ekristen/distillery/pkg/clients/homebrew" + "github.com/ekristen/distillery/pkg/provider" ) const HomebrewSource = "homebrew" type Homebrew struct { - Source + provider.Provider client *homebrew.Client @@ -42,11 +43,11 @@ func (s *Homebrew) GetID() string { } func (s *Homebrew) GetDownloadsDir() string { - return filepath.Join(s.Options.DownloadsDir, s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) + return filepath.Join(s.Options.Config.GetDownloadsPath(), s.GetSource(), s.GetOwner(), s.GetRepo(), s.Version) } func (s *Homebrew) sourceRun(ctx context.Context) error { - cacheFile := filepath.Join(s.Options.MetadataDir, fmt.Sprintf("cache-%s", s.GetID())) + cacheFile := filepath.Join(s.Options.Config.GetMetadataPath(), fmt.Sprintf("cache-%s", s.GetID())) s.client = homebrew.NewClient(httpcache.NewTransport(diskcache.New(cacheFile)).Client()) @@ -102,7 +103,7 @@ func (s *Homebrew) Run(ctx context.Context) error { return err } - if err := s.commonRun(ctx); err != nil { + if err := s.CommonRun(ctx); err != nil { return err } diff --git a/pkg/source/homebrew_asset.go b/pkg/source/homebrew_asset.go index 9a63c9b..d23c9ab 100644 --- a/pkg/source/homebrew_asset.go +++ b/pkg/source/homebrew_asset.go @@ -25,8 +25,11 @@ type HomebrewAsset struct { } func (a *HomebrewAsset) ID() string { - return fmt.Sprintf("%s-%s-%s-%s", - a.Homebrew.GetOwner(), a.Homebrew.GetRepo(), a.Homebrew.Version, a.FileVariant.Sha256[:9]) + return fmt.Sprintf("%s-%s", a.GetType(), a.FileVariant.Sha256[:9]) +} + +func (a *HomebrewAsset) Path() string { + return filepath.Join("homebrew", a.Homebrew.GetRepo(), a.Homebrew.Version) } type GHCRAuth struct {