diff --git a/command/init.go b/command/init.go index 2d8a1937759..a9a99342639 100644 --- a/command/init.go +++ b/command/init.go @@ -85,6 +85,7 @@ for more info.`) Checksummers: []plugingetter.Checksummer{ {Type: "sha256", Hash: sha256.New()}, }, + ReleasesOnly: true, }, } @@ -127,7 +128,20 @@ for more info.`) } if cla.Force && !cla.Upgrade { - pluginRequirement.VersionConstraints, _ = gversion.NewConstraint(fmt.Sprintf("=%s", installs[len(installs)-1].Version)) + // Only place another constaint to the latest release + // binary, if any, otherwise this is essentially the same + // as an upgrade + var installVersion string + for _, install := range installs { + ver, _ := gversion.NewVersion(install.Version) + if ver.Prerelease() == "" { + installVersion = install.Version + } + } + + if installVersion != "" { + pluginRequirement.VersionConstraints, _ = gversion.NewConstraint(fmt.Sprintf("=%s", installVersion)) + } } } diff --git a/command/plugins_install.go b/command/plugins_install.go index 59e197a3f6a..57419f827ae 100644 --- a/command/plugins_install.go +++ b/command/plugins_install.go @@ -130,6 +130,7 @@ func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args *Plugi Checksummers: []plugingetter.Checksummer{ {Type: "sha256", Hash: sha256.New()}, }, + ReleasesOnly: true, }, } if runtime.GOOS == "windows" { diff --git a/packer/plugin-getter/plugins.go b/packer/plugin-getter/plugins.go index 69c49387030..9813e35afde 100644 --- a/packer/plugin-getter/plugins.go +++ b/packer/plugin-getter/plugins.go @@ -5,8 +5,10 @@ package plugingetter import ( "archive/zip" + "bytes" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -22,7 +24,7 @@ import ( "time" "github.com/hashicorp/go-multierror" - "github.com/hashicorp/go-version" + goversion "github.com/hashicorp/go-version" pluginsdk "github.com/hashicorp/packer-plugin-sdk/plugin" "github.com/hashicorp/packer-plugin-sdk/tmp" "github.com/hashicorp/packer/hcl2template/addrs" @@ -47,7 +49,7 @@ type Requirement struct { // VersionConstraints as defined by user. Empty ( to be avoided ) means // highest found version. - VersionConstraints version.Constraints + VersionConstraints goversion.Constraints } type BinaryInstallationOptions struct { @@ -93,6 +95,32 @@ func (rlerr *RateLimitError) Error() string { return s } +// PrereleaseInstallError is returned when a getter encounters the install of a pre-release version. +type PrereleaseInstallError struct { + PluginSrc string + Err error +} + +func (e *PrereleaseInstallError) Error() string { + var s strings.Builder + s.WriteString(e.Err.Error() + "\n") + s.WriteString("Remote installation of pre-release plugin versions is unsupported.\n") + s.WriteString("This is likely an upstream issue, which should be reported.\n") + s.WriteString("If you require this specific version of the plugin, download the binary and install it manually.\n") + s.WriteString("\npacker plugins install --path '' " + e.PluginSrc) + return s.String() +} + +// ContinuableInstallError describe a failed getter install that is +// capable of falling back to next available version. +type ContinuableInstallError struct { + Err error +} + +func (e *ContinuableInstallError) Error() string { + return fmt.Sprintf("Continuing to next available version: %s", e.Err) +} + func (pr Requirement) FilenamePrefix() string { if pr.Identifier == nil { return "packer-plugin-" @@ -211,22 +239,16 @@ func (pr Requirement) ListInstallations(opts ListInstallationsOptions) (InstallL } } - descOut, err := exec.Command(path, "describe").Output() + describeInfo, err := GetPluginDescription(path) if err != nil { - log.Printf("couldn't call describe on %q, ignoring", path) + log.Printf("failed to call describe on %q: %s", path, err) continue } - var describeInfo pluginsdk.SetDescription - err = json.Unmarshal(descOut, &describeInfo) - if err != nil { - log.Printf("%q: describe output deserialization error %q, ignoring", path, err) - } - // versionsStr now looks like v1.2.3_x5.1 or amazon_v1.2.3_x5.1 parts := strings.SplitN(versionsStr, "_", 2) pluginVersionStr, protocolVersionStr := parts[0], parts[1] - ver, err := version.NewVersion(pluginVersionStr) + ver, err := goversion.NewVersion(pluginVersionStr) if err != nil { // could not be parsed, ignoring the file log.Printf("found %q with an incorrect %q version, ignoring it. %v", path, pluginVersionStr, err) @@ -243,19 +265,13 @@ func (pr Requirement) ListInstallations(opts ListInstallationsOptions) (InstallL continue } - rawVersion, err := version.NewVersion(pluginVersionStr) - if err != nil { - log.Printf("malformed version string in filename %q: %s, ignoring", pluginVersionStr, err) - continue - } - - descVersion, err := version.NewVersion(describeInfo.Version) + descVersion, err := goversion.NewVersion(describeInfo.Version) if err != nil { log.Printf("malformed reported version string %q: %s, ignoring", describeInfo.Version, err) continue } - if rawVersion.Compare(descVersion) != 0 { + if ver.Compare(descVersion) != 0 { log.Printf("plugin %q reported version %q while its name implies version %q, ignoring", path, describeInfo.Version, pluginVersionStr) continue } @@ -277,7 +293,7 @@ func (pr Requirement) ListInstallations(opts ListInstallationsOptions) (InstallL // Note: we use the raw version name here, without the pre-release // suffix, as otherwise constraints reject them, which is not // what we want by default. - if !pr.VersionConstraints.Check(rawVersion.Core()) { + if !pr.VersionConstraints.Check(ver.Core()) { log.Printf("[TRACE] version %q of file %q does not match constraint %q", pluginVersionStr, path, pr.VersionConstraints.String()) continue } @@ -417,7 +433,7 @@ type GetOptions struct { BinaryInstallationOptions - version *version.Version + version *goversion.Version expectedZipFilename string } @@ -620,7 +636,7 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) getters := opts.Getters log.Printf("[TRACE] getting available versions for the %s plugin", pr.Identifier) - versions := version.Collection{} + versions := goversion.Collection{} var errs *multierror.Error for _, getter := range getters { @@ -648,7 +664,7 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) continue } for _, release := range releases { - v, err := version.NewVersion(release.Version) + v, err := goversion.NewVersion(release.Version) if err != nil { err := fmt.Errorf("could not parse release version %s. %w", release.Version, err) errs = multierror.Append(errs, err) @@ -732,9 +748,6 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) continue } if err := entry.validate("v"+version.String(), opts.BinaryInstallationOptions); err != nil { - err := fmt.Errorf("ignoring invalid remote binary %s: %s", entry.Filename, err) - errs = multierror.Append(errs, err) - log.Printf("[TRACE] %s", err) continue } @@ -755,11 +768,8 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) } expectedZipFilename := checksum.Filename expectedBinaryFilename := strings.TrimSuffix(expectedZipFilename, filepath.Ext(expectedZipFilename)) + opts.BinaryInstallationOptions.Ext + outputFileName := filepath.Join(outputFolder, expectedBinaryFilename) - outputFileName := filepath.Join( - outputFolder, - expectedBinaryFilename, - ) for _, potentialChecksumer := range opts.Checksummers { // First check if a local checksum file is already here in the expected // download folder. Here we want to download a binary so we only check @@ -772,7 +782,7 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) Checksummer: potentialChecksumer, } - log.Printf("[TRACE] found a pre-exising %q checksum file", potentialChecksumer.Type) + log.Printf("[TRACE] found a pre-existing %q checksum file", potentialChecksumer.Type) // if outputFile is there and matches the checksum: do nothing more. if err := localChecksum.ChecksumFile(localChecksum.Expected, outputFileName); err == nil && !opts.Force { log.Printf("[INFO] %s v%s plugin is already correctly installed in %q", pr.Identifier, version, outputFileName) @@ -781,27 +791,7 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) } } - // The last folder from the installation list is where we will install. - outputFileName = filepath.Join(outputFolder, expectedBinaryFilename) - - // create directories if need be - if err := os.MkdirAll(outputFolder, 0755); err != nil { - err := fmt.Errorf("could not create plugin folder %q: %w", outputFolder, err) - errs = multierror.Append(errs, err) - log.Printf("[TRACE] %s", err.Error()) - return nil, errs - } - for _, getter := range getters { - // create temporary file that will receive a temporary binary.zip - tmpFile, err := tmp.File("packer-plugin-*.zip") - if err != nil { - err = fmt.Errorf("could not create temporary file to dowload plugin: %w", err) - errs = multierror.Append(errs, err) - return nil, errs - } - defer tmpFile.Close() - // start fetching binary remoteZipFile, err := getter.Get("zip", GetOptions{ PluginRequirement: pr, @@ -810,51 +800,45 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) expectedZipFilename: expectedZipFilename, }) if err != nil { - err := fmt.Errorf("could not get binary for %s version %s. Is the file present on the release and correctly named ? %s", pr.Identifier, version, err) - errs = multierror.Append(errs, err) - log.Printf("[TRACE] %v", err) + errs = multierror.Append(errs, + fmt.Errorf("could not get binary for %s version %s. Is the file present on the release and correctly named ? %s", + pr.Identifier, version, err)) continue } - + // create temporary file that will receive a temporary binary.zip + tmpFile, err := tmp.File("packer-plugin-*.zip") + if err != nil { + err = fmt.Errorf("could not create temporary file to download plugin: %w", err) + errs = multierror.Append(errs, err) + return nil, errs + } + defer func() { + tmpFilePath := tmpFile.Name() + tmpFile.Close() + os.Remove(tmpFilePath) + }() // write binary to tmp file _, err = io.Copy(tmpFile, remoteZipFile) _ = remoteZipFile.Close() if err != nil { err := fmt.Errorf("Error getting plugin, trying another getter: %w", err) errs = multierror.Append(errs, err) - log.Printf("[TRACE] %s", err) continue } - if _, err := tmpFile.Seek(0, 0); err != nil { - err := fmt.Errorf("Error seeking begining of temporary file for checksumming, continuing: %w", err) + err := fmt.Errorf("Error seeking beginning of temporary file for checksumming, continuing: %w", err) errs = multierror.Append(errs, err) - log.Printf("[TRACE] %s", err) continue } - // verify that the checksum for the zip is what we expect. if err := checksum.Checksummer.Checksum(checksum.Expected, tmpFile); err != nil { err := fmt.Errorf("%w. Is the checksum file correct ? Is the binary file correct ?", err) errs = multierror.Append(errs, err) - log.Printf("%s, truncating the zipfile", err) - if err := tmpFile.Truncate(0); err != nil { - log.Printf("[TRACE] %v", err) - } continue } - - tmpFileStat, err := tmpFile.Stat() + zr, err := zip.OpenReader(tmpFile.Name()) if err != nil { - err := fmt.Errorf("failed to stat: %w", err) - errs = multierror.Append(errs, err) - return nil, errs - } - - zr, err := zip.NewReader(tmpFile, tmpFileStat.Size()) - if err != nil { - err := fmt.Errorf("zip : %v", err) - errs = multierror.Append(errs, err) + errs = multierror.Append(errs, fmt.Errorf("zip : %v", err)) return nil, errs } @@ -865,49 +849,82 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) } copyFrom, err = f.Open() if err != nil { - err := fmt.Errorf("failed to open temp file: %w", err) - errs = multierror.Append(errs, err) + multierror.Append(errs, fmt.Errorf("failed to open temp file: %w", err)) return nil, errs } break } if copyFrom == nil { - err := fmt.Errorf("could not find a %s file in zipfile", checksum.Filename) + err := fmt.Errorf("could not find a %q file in zipfile", expectedBinaryFilename) errs = multierror.Append(errs, err) return nil, errs } - outputFile, err := os.OpenFile(outputFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + var outputFileData bytes.Buffer + if _, err := io.Copy(&outputFileData, copyFrom); err != nil { + err := fmt.Errorf("extract file: %w", err) + errs = multierror.Append(errs, err) + return nil, errs + } + tmpBinFileName := filepath.Join(os.TempDir(), expectedBinaryFilename) + tmpOutputFile, err := os.OpenFile(tmpBinFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { - err := fmt.Errorf("failed to create %s: %w", outputFileName, err) + err = fmt.Errorf("could not create temporary file to download plugin: %w", err) errs = multierror.Append(errs, err) return nil, errs } - defer outputFile.Close() + defer func() { + os.Remove(tmpBinFileName) + }() - if _, err := io.Copy(outputFile, copyFrom); err != nil { + if _, err := tmpOutputFile.Write(outputFileData.Bytes()); err != nil { err := fmt.Errorf("extract file: %w", err) errs = multierror.Append(errs, err) return nil, errs } + tmpOutputFile.Close() - if _, err := outputFile.Seek(0, 0); err != nil { - err := fmt.Errorf("Error seeking begining of binary file for checksumming: %w", err) + if err := checkVersion(tmpBinFileName, pr.Identifier.String(), version); err != nil { errs = multierror.Append(errs, err) - log.Printf("[WARNING] %v, ignoring", err) + var continuableError *ContinuableInstallError + if errors.As(err, &continuableError) { + continue + } + return nil, errs } - cs, err := checksum.Checksummer.Sum(outputFile) + // create directories if need be + if err := os.MkdirAll(outputFolder, 0755); err != nil { + err := fmt.Errorf("could not create plugin folder %q: %w", outputFolder, err) + errs = multierror.Append(errs, err) + log.Printf("[TRACE] %s", err.Error()) + return nil, errs + } + outputFile, err := os.OpenFile(outputFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + err = fmt.Errorf("could not create final plugin binary file: %w", err) + errs = multierror.Append(errs, err) + return nil, errs + } + if _, err := outputFile.Write(outputFileData.Bytes()); err != nil { + err = fmt.Errorf("could not write final plugin binary file: %w", err) + errs = multierror.Append(errs, err) + return nil, errs + } + outputFile.Close() + + cs, err := checksum.Checksummer.Sum(&outputFileData) if err != nil { err := fmt.Errorf("failed to checksum binary file: %s", err) errs = multierror.Append(errs, err) log.Printf("[WARNING] %v, ignoring", err) } - if err := os.WriteFile(outputFileName+checksum.Checksummer.FileExt(), []byte(hex.EncodeToString(cs)), 0644); err != nil { err := fmt.Errorf("failed to write local binary checksum file: %s", err) errs = multierror.Append(errs, err) log.Printf("[WARNING] %v, ignoring", err) + os.Remove(outputFileName) + continue } // Success !! @@ -916,23 +933,70 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) Version: "v" + version.String(), }, nil } - } } - } } - if errs.Len() == 0 { + if errs.ErrorOrNil() == nil { err := fmt.Errorf("could not find a local nor a remote checksum for plugin %q %q", pr.Identifier, pr.VersionConstraints) errs = multierror.Append(errs, err) } - errs = multierror.Append(errs, fmt.Errorf("could not install any compatible version of plugin %q", pr.Identifier)) - return nil, errs } +func GetPluginDescription(pluginPath string) (pluginsdk.SetDescription, error) { + out, err := exec.Command(pluginPath, "describe").Output() + if err != nil { + return pluginsdk.SetDescription{}, err + } + + desc := pluginsdk.SetDescription{} + err = json.Unmarshal(out, &desc) + + return desc, err +} + +// checkVersion checks the described version of a plugin binary against the requested version constriant. +// A ContinuableInstallError is returned upon a version mismatch to indicate that the caller should try the next +// available version. A PrereleaseInstallError is returned to indicate an unsupported version install. +func checkVersion(binPath string, identifier string, version *goversion.Version) error { + desc, err := GetPluginDescription(binPath) + if err != nil { + err := fmt.Errorf("failed to describe plugin binary %q: %s", binPath, err) + return &ContinuableInstallError{Err: err} + } + descVersion, err := goversion.NewSemver(desc.Version) + if err != nil { + err := fmt.Errorf("invalid self-reported version %q: %s", desc.Version, err) + return &ContinuableInstallError{Err: err} + } + if descVersion.Core().Compare(version.Core()) != 0 { + err := fmt.Errorf("binary reported version (%q) is different from the expected %q, skipping", desc.Version, version.String()) + return &ContinuableInstallError{Err: err} + } + if version.Prerelease() != "" { + return &PrereleaseInstallError{ + PluginSrc: identifier, + Err: errors.New("binary reported a pre-release version of " + version.String()), + } + } + // Since only final releases can be installed remotely, a non-empty prerelease version + // means something's not right on the release, as it should report a final version. + // + // Therefore to avoid surprises (and avoid being able to install a version that + // cannot be loaded), we error here, and advise users to manually install the plugin if they + // need it. + if descVersion.Prerelease() != "" { + return &PrereleaseInstallError{ + PluginSrc: identifier, + Err: errors.New("binary reported a pre-release version of " + descVersion.String()), + } + } + return nil +} + func init() { var err error // Should never error if both components are set diff --git a/packer/plugin-getter/plugins_test.go b/packer/plugin-getter/plugins_test.go index 85bcab42e80..f0518f50190 100644 --- a/packer/plugin-getter/plugins_test.go +++ b/packer/plugin-getter/plugins_test.go @@ -13,6 +13,7 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" "testing" @@ -199,12 +200,17 @@ func TestRequirement_InstallLatest(t *testing.T) { ChecksumFileEntries: map[string][]ChecksumFileEntry{ "2.10.0": {{ Filename: "packer-plugin-amazon_v2.10.0_x6.0_darwin_amd64.zip", - Checksum: "43156b1900dc09b026b54610c4a152edd277366a7f71ff3812583e4a35dd0d4a", + Checksum: "5763f8b5b5ed248894e8511a089cf399b96c7ef92d784fb30ee6242a7cb35bce", }}, }, Zips: map[string]io.ReadCloser{ "github.com/hashicorp/packer-plugin-amazon/packer-plugin-amazon_v2.10.0_x6.0_darwin_amd64.zip": zipFile(map[string]string{ - "packer-plugin-amazon_v2.10.0_x6.0_darwin_amd64": "v2.10.0_x6.0_darwin_amd64", + // Make the false plugin echo an output that matches a subset of `describe` for install to work + // + // Note: this won't work on Windows as they don't have bin/sh, but this will + // eventually be replaced by acceptance tests. + "packer-plugin-amazon_v2.10.0_x6.0_darwin_amd64": `#!/bin/sh +echo '{"version":"v2.10.0","api_version":"x6.0"}'`, }), }, }, @@ -246,12 +252,17 @@ func TestRequirement_InstallLatest(t *testing.T) { ChecksumFileEntries: map[string][]ChecksumFileEntry{ "2.10.1": {{ Filename: "packer-plugin-amazon_v2.10.1_x6.1_darwin_amd64.zip", - Checksum: "90ca5b0f13a90238b62581bbf30bacd7e2c9af6592c7f4849627bddbcb039dec", + Checksum: "51451da5cd7f1ecd8699668d806bafe58a9222430842afbefdc62a6698dab260", }}, }, Zips: map[string]io.ReadCloser{ "github.com/hashicorp/packer-plugin-amazon/packer-plugin-amazon_v2.10.1_x6.1_darwin_amd64.zip": zipFile(map[string]string{ - "packer-plugin-amazon_v2.10.1_x6.1_darwin_amd64": "v2.10.1_x6.1_darwin_amd64", + // Make the false plugin echo an output that matches a subset of `describe` for install to work + // + // Note: this won't work on Windows as they don't have bin/sh, but this will + // eventually be replaced by acceptance tests. + "packer-plugin-amazon_v2.10.1_x6.1_darwin_amd64": `#!/bin/sh +echo '{"version":"v2.10.1","api_version":"x6.1"}'`, }), }, }, @@ -293,12 +304,17 @@ func TestRequirement_InstallLatest(t *testing.T) { ChecksumFileEntries: map[string][]ChecksumFileEntry{ "2.10.0": {{ Filename: "packer-plugin-amazon_v2.10.0_x6.1_linux_amd64.zip", - Checksum: "825fc931ae0cb151df0c56be41a17a9136c4d1f1ee73ddb8ed6baa17cef31afa", + Checksum: "5196f57f37e18bfeac10168db6915caae0341bfc4168ebc3d2b959d746cebd0a", }}, }, Zips: map[string]io.ReadCloser{ "github.com/hashicorp/packer-plugin-amazon/packer-plugin-amazon_v2.10.0_x6.1_linux_amd64.zip": zipFile(map[string]string{ - "packer-plugin-amazon_v2.10.0_x6.1_linux_amd64": "v2.10.0_x6.1_linux_amd64", + // Make the false plugin echo an output that matches a subset of `describe` for install to work + // + // Note: this won't work on Windows as they don't have bin/sh, but this will + // eventually be replaced by acceptance tests. + "packer-plugin-amazon_v2.10.0_x6.1_linux_amd64": `#!/bin/sh +echo '{"version":"v2.10.0","api_version":"x6.1"}'`, }), }, }, @@ -402,6 +418,16 @@ func TestRequirement_InstallLatest(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + switch tt.name { + case "upgrade-with-diff-protocol-version", + "upgrade-with-same-protocol-version", + "upgrade-with-one-missing-checksum-file": + if runtime.GOOS != "windows" { + break + } + t.Skipf("Test %q cannot run on Windows because of a shell script being invoked, skipping.", tt.name) + } + log.Printf("starting %s test", tt.name) identifier, diags := addrs.ParsePluginSourceString("github.com/hashicorp/" + tt.fields.Identifier) diff --git a/packer/plugin.go b/packer/plugin.go index de85071d7ad..cda4118f5e3 100644 --- a/packer/plugin.go +++ b/packer/plugin.go @@ -5,7 +5,6 @@ package packer import ( "crypto/sha256" - "encoding/json" "fmt" "log" "os" @@ -127,13 +126,9 @@ func (c *PluginConfig) Discover() error { // if the "packer-plugin-amazon" binary had an "ebs" builder one could use // the "amazon-ebs" builder. func (c *PluginConfig) DiscoverMultiPlugin(pluginName, pluginPath string) error { - out, err := exec.Command(pluginPath, "describe").Output() + desc, err := plugingetter.GetPluginDescription(pluginPath) if err != nil { - return err - } - var desc pluginsdk.SetDescription - if err := json.Unmarshal(out, &desc); err != nil { - return err + return fmt.Errorf("failed to get plugin description from executable %q: %s", pluginPath, err) } pluginPrefix := pluginName + "-"