Skip to content

Commit

Permalink
command: add packer plugins install path flag
Browse files Browse the repository at this point in the history
This new flag allows the `packer plugins install' command to install a
plugin from a local binary rather than from Github.

This command will only call `describe' on the plugin, and won't do any
further checks for functionality. The SHA256SUM will be directly
computed from the binary, so as with anything manual and potentially
sourced by the community, extra care should be applied when invoking
this.
  • Loading branch information
lbajolet-hashicorp committed Oct 16, 2023
1 parent 020f18e commit 666039c
Show file tree
Hide file tree
Showing 2 changed files with 231 additions and 11 deletions.
237 changes: 229 additions & 8 deletions command/plugins_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,24 @@ package command
import (
"context"
"crypto/sha256"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"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 {
Expand All @@ -30,14 +36,20 @@ func (c *PluginsInstallCommand) Synopsis() string {

func (c *PluginsInstallCommand) Help() string {
helpText := `
Usage: packer plugins install <plugin> [<version constraint>]
Usage: packer plugins install [OPTIONS...] <plugin> [<version constraint>]
This command will install the most recent compatible Packer plugin matching
version constraint.
When the version constraint is omitted, the most recent version will be
installed.
Ex: packer plugins install github.com/hashicorp/happycloud v1.2.3
Options:
- path <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 web server, but instead directly
install the binary for Packer to be able to load it later on.
`

return strings.TrimSpace(helpText)
Expand All @@ -47,14 +59,55 @@ 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
PluginName string
PluginPath string
Version string
}

func (pa *PluginsInstallArgs) AddFlagSets(flags *flag.FlagSet) {
flags.StringVar(&pa.PluginPath, "path", "", "install the plugin from a specific path")
pa.MetaArgs.AddFlagSets(flags)
}

func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []string) int {
func (c *PluginsInstallCommand) ParseArgs(args []string) (*PluginsInstallArgs, int) {
pa := &PluginsInstallArgs{}

flags := c.Meta.FlagSet("plugins promote")
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]
}

pa.PluginName = 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{
Expand All @@ -68,19 +121,25 @@ func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []stri
},
}

plugin, diags := addrs.ParsePluginSourceString(args[0])
plugin, diags := addrs.ParsePluginSourceString(args.PluginName)
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(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
Expand Down Expand Up @@ -128,3 +187,165 @@ func (c *PluginsInstallCommand) RunContext(buildCtx context.Context, args []stri

return 0
}

func (c *PluginsInstallCommand) InstallFromBinary(args *PluginsInstallArgs) int {
pluginDirs := c.Meta.CoreConfig.Components.PluginConfig.KnownPluginFolders

if len(pluginDirs) == 0 {
c.Ui.Say(`Error: cannot find a place to install the plugin to
In order to install the plugin for later use, Packer needs to know where to
install them.
This can be specified through the PACKER_CONFIG_DIR environment variable,
but should be automatically inferred by Packer.
If you see this message, this is likely a Packer bug, please consider opening
an issue on our Github repo to signal it.`)
}

pluginSlugParts := strings.Split(args.PluginName, "/")
if len(pluginSlugParts) != 3 {
return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid plugin name specifier",
Detail: fmt.Sprintf("The plugin name specified provided (%q) does not conform to the mandated format of <host>/<org>/<plugin-name>.", args.PluginName),
}})
}

// 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 := pluginDirs[len(pluginDirs)-1]

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.PluginName, 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),
}})
}

// Let's override the plugin's version if we specify it in the options
// of the command
if args.Version != "" {
desc.Version = args.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),
}})
}
defer pluginBinary.Close()

// We'll install the SHA256SUM file alongside the plugin, based on the
// contents of the plugin being passed.
//
// This will make our loaders happy as they require a valid checksum
// for loading plugins installed this way.
shasum := sha256.New()
_, err = io.Copy(shasum, 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),
}})
}

// 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 := fmt.Sprintf("%s/%s", pluginDir, args.PluginName)
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),
}})
}

binaryPath := fmt.Sprintf(
"%s/packer-plugin-%s_v%s_%s_%s_%s",
installDir,
pluginSlugParts[2],
desc.Version,
desc.APIVersion,
runtime.GOOS,
runtime.GOARCH,
)
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 = pluginBinary.Seek(0, 0)
if err != nil {
return writeDiags(c.Ui, nil, hcl.Diagnostics{&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to reset plugin's reader",
Detail: fmt.Sprintf("Failed to seek offset 0 while attempting to reset the buffer for the plugin to install: %s", err),
}})
}

_, err = io.Copy(outputPlugin, pluginBinary)
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),
}})
}

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.PluginName, args.PluginPath, binaryPath))

return 0
}
5 changes: 2 additions & 3 deletions command/plugins_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/mitchellh/cli"
"golang.org/x/mod/sumdb/dirhash"
)

Expand Down Expand Up @@ -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=",
},
Expand All @@ -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=",
},
Expand Down

0 comments on commit 666039c

Please sign in to comment.