diff --git a/command/cli.go b/command/cli.go index 4ad80440f0e..2dfc57dbd0d 100644 --- a/command/cli.go +++ b/command/cli.go @@ -104,6 +104,7 @@ type BuildArgs struct { func (ia *InitArgs) AddFlagSets(flags *flag.FlagSet) { flags.BoolVar(&ia.Upgrade, "upgrade", false, "upgrade any present plugin to the highest allowed version.") + flags.BoolVar(&ia.Force, "force", false, "force installation of a plugin, even if already installed") ia.MetaArgs.AddFlagSets(flags) } @@ -112,6 +113,7 @@ func (ia *InitArgs) AddFlagSets(flags *flag.FlagSet) { type InitArgs struct { MetaArgs Upgrade bool + Force bool } // PluginsRequiredArgs represents a parsed cli line for a `packer plugins required ` diff --git a/command/init.go b/command/init.go index 71332d57d25..9e5b82f04ba 100644 --- a/command/init.go +++ b/command/init.go @@ -11,6 +11,7 @@ import ( "runtime" "strings" + gversion "github.com/hashicorp/go-version" pluginsdk "github.com/hashicorp/packer-plugin-sdk/plugin" "github.com/hashicorp/packer/packer" plugingetter "github.com/hashicorp/packer/packer/plugin-getter" @@ -120,16 +121,21 @@ for more info.`) return 1 } - log.Printf("[TRACE] for plugin %s found %d matching installation(s)", pluginRequirement.Identifier, len(installs)) + if len(installs) > 0 { + if !cla.Force && !cla.Upgrade { + continue + } - if len(installs) > 0 && cla.Upgrade == false { - continue + if cla.Force && !cla.Upgrade { + pluginRequirement.VersionConstraints, _ = gversion.NewConstraint(fmt.Sprintf("=%s", installs[len(installs)-1].Version)) + } } newInstall, err := pluginRequirement.InstallLatest(plugingetter.InstallOptions{ InFolders: opts.FromFolders, BinaryInstallationOptions: opts.BinaryInstallationOptions, Getters: getters, + Force: cla.Force, }) if err != nil { c.Ui.Error(fmt.Sprintf("Failed getting the %q plugin:", pluginRequirement.Identifier)) @@ -163,6 +169,8 @@ Options: version, if there is a new higher one. Note that this still takes into consideration the version constraint of the config. + -force Forces reinstallation of plugins, even if already + installed. ` return strings.TrimSpace(helpText) diff --git a/command/plugins_install.go b/command/plugins_install.go index 3037c0959d0..4d8434a2780 100644 --- a/command/plugins_install.go +++ b/command/plugins_install.go @@ -4,20 +4,28 @@ package command import ( + "bytes" "context" "crypto/sha256" + "encoding/json" + "flag" "fmt" + "io" + "os" + "os/exec" + "path/filepath" "runtime" "strings" "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/packer-plugin-sdk/plugin" pluginsdk "github.com/hashicorp/packer-plugin-sdk/plugin" "github.com/hashicorp/packer/hcl2template/addrs" "github.com/hashicorp/packer/packer" plugingetter "github.com/hashicorp/packer/packer/plugin-getter" "github.com/hashicorp/packer/packer/plugin-getter/github" pkrversion "github.com/hashicorp/packer/version" - "github.com/mitchellh/cli" ) type PluginsInstallCommand struct { @@ -30,7 +38,7 @@ func (c *PluginsInstallCommand) Synopsis() string { func (c *PluginsInstallCommand) Help() string { helpText := ` -Usage: packer plugins install [] +Usage: packer plugins install [OPTIONS...] [] This command will install the most recent compatible Packer plugin matching version constraint. @@ -38,6 +46,15 @@ Usage: packer plugins install [] installed. Ex: packer plugins install github.com/hashicorp/happycloud v1.2.3 + packer plugins install --path ./packer-plugin-happycloud "github.com/hashicorp/happycloud" + +Options: + - path : install the plugin from a locally-sourced plugin binary. This + installs the plugin where a normal invocation would, but will + not try to download it from a remote location, and instead + install the binary in the Packer plugins path. + This option cannot be specified with a version constraint. + - force: forces reinstallation of plugins, even if already installed. ` return strings.TrimSpace(helpText) @@ -47,14 +64,62 @@ func (c *PluginsInstallCommand) Run(args []string) int { ctx, cleanup := handleTermInterrupt(c.Ui) defer cleanup() - return c.RunContext(ctx, args) + cmdArgs, ret := c.ParseArgs(args) + if ret != 0 { + return ret + } + + return c.RunContext(ctx, cmdArgs) +} + +type PluginsInstallArgs struct { + MetaArgs + PluginIdentifier string + PluginPath string + Version string + Force bool } -func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []string) int { +func (pa *PluginsInstallArgs) AddFlagSets(flags *flag.FlagSet) { + flags.StringVar(&pa.PluginPath, "path", "", "install the binary specified by path as a Packer plugin.") + flags.BoolVar(&pa.Force, "force", false, "force installation of the specified plugin, even if already installed.") + pa.MetaArgs.AddFlagSets(flags) +} + +func (c *PluginsInstallCommand) ParseArgs(args []string) (*PluginsInstallArgs, int) { + pa := &PluginsInstallArgs{} + + flags := c.Meta.FlagSet("plugins install") + flags.Usage = func() { c.Ui.Say(c.Help()) } + pa.AddFlagSets(flags) + err := flags.Parse(args) + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to parse options: %s", err)) + return pa, 1 + } + + args = flags.Args() if len(args) < 1 || len(args) > 2 { - return cli.RunResultHelp + c.Ui.Error(fmt.Sprintf("Invalid arguments, expected either 1 or 2 positional arguments, got %d", len(args))) + flags.Usage() + return pa, 1 + } + + if len(args) == 2 { + pa.Version = args[1] } + if pa.Path != "" && pa.Version != "" { + c.Ui.Error("Invalid arguments: a version cannot be specified when using --path to install a local plugin binary") + flags.Usage() + return pa, 1 + } + + pa.PluginIdentifier = args[0] + return pa, 0 +} + +func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args *PluginsInstallArgs) int { opts := plugingetter.ListInstallationsOptions{ FromFolders: c.Meta.CoreConfig.Components.PluginConfig.KnownPluginFolders, BinaryInstallationOptions: plugingetter.BinaryInstallationOptions{ @@ -67,20 +132,29 @@ func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []stri }, }, } + if runtime.GOOS == "windows" { + opts.BinaryInstallationOptions.Ext = ".exe" + } - plugin, diags := addrs.ParsePluginSourceString(args[0]) + plugin, diags := addrs.ParsePluginSourceString(args.PluginIdentifier) if diags.HasErrors() { c.Ui.Error(diags.Error()) return 1 } + // If we did specify a binary to install the plugin from, we ignore + // the Github-based getter in favour of installing it directly. + if args.PluginPath != "" { + return c.InstallFromBinary(opts, plugin, args) + } + // a plugin requirement that matches them all pluginRequirement := plugingetter.Requirement{ Identifier: plugin, } - if len(args) > 1 { - constraints, err := version.NewConstraint(args[1]) + if args.Version != "" { + constraints, err := version.NewConstraint(args.Version) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -88,10 +162,6 @@ func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []stri pluginRequirement.VersionConstraints = constraints } - if runtime.GOOS == "windows" && opts.Ext == "" { - opts.BinaryInstallationOptions.Ext = ".exe" - } - getters := []plugingetter.Getter{ &github.Getter{ // In the past some terraform plugins downloads were blocked from a @@ -109,6 +179,7 @@ func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []stri InFolders: opts.FromFolders, BinaryInstallationOptions: opts.BinaryInstallationOptions, Getters: getters, + Force: args.Force, }) if err != nil { @@ -128,3 +199,158 @@ func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []stri return 0 } + +func (c *PluginsInstallCommand) InstallFromBinary(opts plugingetter.ListInstallationsOptions, pluginIdentifier *addrs.Plugin, args *PluginsInstallArgs) int { + // As with the other commands, we get the last plugin directory as it + // has precedence over the others, and is where we'll install the + // plugins to. + pluginDir := opts.FromFolders[len(opts.FromFolders)-1] + + var err error + + args.PluginPath, err = filepath.Abs(args.PluginPath) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to transform path", + Detail: fmt.Sprintf("Failed to transform the given path to an absolute one: %s", err), + }}) + } + + s, err := os.Stat(args.PluginPath) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unable to find plugin to promote", + Detail: fmt.Sprintf("The plugin %q failed to be opened because of an error: %s", args.PluginIdentifier, err), + }}) + } + + if s.IsDir() { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Plugin to promote cannot be a directory", + Detail: "The packer plugin promote command can only install binaries, not directories", + }}) + } + + describeCmd, err := exec.Command(args.PluginPath, "describe").Output() + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to describe the plugin", + Detail: fmt.Sprintf("Packer failed to run %s describe: %s", args.PluginPath, err), + }}) + } + + var desc plugin.SetDescription + if err := json.Unmarshal(describeCmd, &desc); err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to decode plugin describe info", + Detail: fmt.Sprintf("'%s describe' produced information that Packer couldn't decode: %s", args.PluginPath, err), + }}) + } + + semver, err := version.NewSemver(desc.Version) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version", + Detail: fmt.Sprintf("Plugin's reported version (%q) is not semver-compatible: %s", desc.Version, err), + }}) + } + if semver.Prerelease() != "" { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version", + Detail: fmt.Sprintf("Packer can only install plugin releases with this command (ex: 1.0.0), the binary's reported version is %q", desc.Version), + }}) + } + + pluginBinary, err := os.Open(args.PluginPath) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to open plugin binary", + Detail: fmt.Sprintf("Failed to open plugin binary from %q: %s", args.PluginPath, err), + }}) + } + + pluginContents := bytes.Buffer{} + _, err = io.Copy(&pluginContents, pluginBinary) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to read plugin binary's contents", + Detail: fmt.Sprintf("Failed to read plugin binary from %q: %s", args.PluginPath, err), + }}) + } + _ = pluginBinary.Close() + + // At this point, we know the provided binary behaves correctly with + // describe, so it's very likely to be a plugin, let's install it. + installDir := filepath.Join( + pluginDir, + filepath.Join(pluginIdentifier.Parts()...), + ) + err = os.MkdirAll(installDir, 0755) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to create output directory", + Detail: fmt.Sprintf("The installation directory %q failed to be created because of an error: %s", installDir, err), + }}) + } + + outputPrefix := fmt.Sprintf( + "packer-plugin-%s_v%s_%s", + pluginIdentifier.Type, + desc.Version, + desc.APIVersion, + ) + binaryPath := filepath.Join( + installDir, + outputPrefix+opts.BinaryInstallationOptions.FilenameSuffix(), + ) + + outputPlugin, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0755) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to create plugin binary", + Detail: fmt.Sprintf("Failed to create plugin binary at %q: %s", binaryPath, err), + }}) + } + defer outputPlugin.Close() + + _, err = outputPlugin.Write(pluginContents.Bytes()) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to copy plugin binary's contents", + Detail: fmt.Sprintf("Failed to copy plugin binary from %q to %q: %s", args.PluginPath, binaryPath, err), + }}) + } + + // We'll install the SHA256SUM file alongside the plugin, based on the + // contents of the plugin being passed. + shasum := sha256.New() + _, _ = shasum.Write(pluginContents.Bytes()) + + shasumPath := fmt.Sprintf("%s_SHA256SUM", binaryPath) + shaFile, err := os.OpenFile(shasumPath, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0644) + if err != nil { + return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to create plugin SHA256SUM file", + Detail: fmt.Sprintf("Failed to create SHA256SUM file at %q: %s", shasumPath, err), + }}) + } + defer shaFile.Close() + + fmt.Fprintf(shaFile, "%x", shasum.Sum([]byte{})) + c.Ui.Say(fmt.Sprintf("Successfully installed plugin %s from %s to %s", args.PluginIdentifier, args.PluginPath, binaryPath)) + + return 0 +} diff --git a/command/plugins_install_test.go b/command/plugins_install_test.go index 105b9b00067..5461360c36e 100644 --- a/command/plugins_install_test.go +++ b/command/plugins_install_test.go @@ -13,7 +13,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/mitchellh/cli" "golang.org/x/mod/sumdb/dirhash" ) @@ -109,7 +108,7 @@ func TestPluginsInstallCommand_Run(t *testing.T) { expectedPackerConfigDirHashBeforeInstall: "h1:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", packerConfigDir: cfg.dir("4_pkr_plugins_config"), pluginSourceArgs: []string{"github.com/sylviamoss/comment", "v0.2.18", "github.com/sylviamoss/comment", "v0.2.19"}, - want: cli.RunResultHelp, + want: 1, dirFiles: nil, expectedPackerConfigDirHashAfterInstall: "h1:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", }, @@ -120,7 +119,7 @@ func TestPluginsInstallCommand_Run(t *testing.T) { expectedPackerConfigDirHashBeforeInstall: "h1:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", packerConfigDir: cfg.dir("5_pkr_plugins_config"), pluginSourceArgs: []string{}, - want: cli.RunResultHelp, + want: 1, dirFiles: nil, expectedPackerConfigDirHashAfterInstall: "h1:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", }, diff --git a/packer/plugin-getter/plugins.go b/packer/plugin-getter/plugins.go index c9fbd0b9d97..29b34da1c04 100644 --- a/packer/plugin-getter/plugins.go +++ b/packer/plugin-getter/plugins.go @@ -244,6 +244,9 @@ type InstallOptions struct { // folder of this list. InFolders []string + // Forces installation of the plugin, even if already installed. + Force bool + BinaryInstallationOptions } @@ -582,7 +585,7 @@ func (pr *Requirement) InstallLatest(opts InstallOptions) (*Installation, error) log.Printf("[TRACE] found a pre-exising %q checksum file", potentialChecksumer.Type) // if outputFile is there and matches the checksum: do nothing more. - if err := localChecksum.ChecksumFile(localChecksum.Expected, potentialOutputFilename); err == nil { + if err := localChecksum.ChecksumFile(localChecksum.Expected, potentialOutputFilename); err == nil && !opts.Force { log.Printf("[INFO] %s v%s plugin is already correctly installed in %q", pr.Identifier, version, potentialOutputFilename) return nil, nil // success } diff --git a/packer/plugin-getter/plugins_test.go b/packer/plugin-getter/plugins_test.go index dfb28767067..513f2c4993b 100644 --- a/packer/plugin-getter/plugins_test.go +++ b/packer/plugin-getter/plugins_test.go @@ -349,6 +349,7 @@ func TestRequirement_InstallLatest(t *testing.T) { pluginFolderOne, pluginFolderTwo, }, + false, BinaryInstallationOptions{ APIVersionMajor: "5", APIVersionMinor: "0", OS: "darwin", ARCH: "amd64", @@ -385,6 +386,7 @@ func TestRequirement_InstallLatest(t *testing.T) { pluginFolderOne, pluginFolderTwo, }, + false, BinaryInstallationOptions{ APIVersionMajor: "5", APIVersionMinor: "1", OS: "darwin", ARCH: "amd64", @@ -430,6 +432,7 @@ func TestRequirement_InstallLatest(t *testing.T) { pluginFolderOne, pluginFolderTwo, }, + false, BinaryInstallationOptions{ APIVersionMajor: "5", APIVersionMinor: "0", OS: "darwin", ARCH: "amd64", @@ -477,6 +480,7 @@ func TestRequirement_InstallLatest(t *testing.T) { pluginFolderOne, pluginFolderTwo, }, + false, BinaryInstallationOptions{ APIVersionMajor: "6", APIVersionMinor: "1", OS: "darwin", ARCH: "amd64", @@ -527,6 +531,7 @@ func TestRequirement_InstallLatest(t *testing.T) { pluginFolderOne, pluginFolderTwo, }, + false, BinaryInstallationOptions{ APIVersionMajor: "6", APIVersionMinor: "1", OS: "darwin", ARCH: "amd64", @@ -577,6 +582,7 @@ func TestRequirement_InstallLatest(t *testing.T) { pluginFolderOne, pluginFolderTwo, }, + false, BinaryInstallationOptions{ APIVersionMajor: "6", APIVersionMinor: "1", OS: "linux", ARCH: "amd64", @@ -621,6 +627,7 @@ func TestRequirement_InstallLatest(t *testing.T) { pluginFolderOne, pluginFolderTwo, }, + false, BinaryInstallationOptions{ APIVersionMajor: "6", APIVersionMinor: "1", OS: "darwin", ARCH: "amd64", @@ -662,6 +669,7 @@ func TestRequirement_InstallLatest(t *testing.T) { []string{ pluginFolderWrongChecksums, }, + false, BinaryInstallationOptions{ APIVersionMajor: "6", APIVersionMinor: "1", OS: "darwin", ARCH: "amd64", diff --git a/website/content/docs/plugins/install-plugins.mdx b/website/content/docs/plugins/install-plugins.mdx index 1c2b602823f..b2d97975216 100644 --- a/website/content/docs/plugins/install-plugins.mdx +++ b/website/content/docs/plugins/install-plugins.mdx @@ -227,5 +227,15 @@ If you have a `required_plugins` for the plugin you're manually installing, make it respects the constraints described in the [Plugin loading workflow](#plugin-loading-workflow) section, otherwise Packer will not be able to load it. +Starting with v1.10.0 of Packer, you can also use `packer plugins install` with the +`--path` flag to install a plugin from a binary, following the layout that is required to +work with `required_plugins` block. + +```shell +packer plugins install --path github.com/hashicorp/vagrant +``` + +-> packer plugins install --path only works with release versions of plugins. +