From 6fc1d154bdac4c8f6c4b823c90aab517dee77498 Mon Sep 17 00:00:00 2001 From: Lucas Bajolet Date: Thu, 4 Apr 2024 16:23:28 -0400 Subject: [PATCH] packer: relax constraints on sources The source parsing logic was heavily directed towards Github compatible source URIs, however if we want to support more cases, we need to make sure we are able to specify those URIs, and to load plugins installed from those sources. Right now, since the getters available are only github.com, we will not support remotely instlling plugins from sources other than github.com, with the same set of constraints as before. However, we do support now installing from a local plugin binary to any kind of source, and we support loading them, including if a template wants this plugin installed locally with version constraints. --- acctest/plugin/component_acc_test.go | 4 +- acctest/plugin/plugin_acc_test.go | 53 +++----- command/plugins_install.go | 2 +- hcl2template/addrs/plugin.go | 134 +++++++++++--------- hcl2template/addrs/plugin_test.go | 104 +++++++++++++-- hcl2template/types.packer_config_test.go | 24 +--- hcl2template/types.required_plugins_test.go | 6 +- packer/plugin-getter/github/getter.go | 45 ++++++- packer/plugin-getter/plugins.go | 74 +++++++++-- packer/plugin-getter/plugins_test.go | 11 +- 10 files changed, 294 insertions(+), 163 deletions(-) diff --git a/acctest/plugin/component_acc_test.go b/acctest/plugin/component_acc_test.go index 53a49ad22..14d0e5702 100644 --- a/acctest/plugin/component_acc_test.go +++ b/acctest/plugin/component_acc_test.go @@ -22,9 +22,7 @@ var basicAmazonAmiDatasourceHCL2Template string func TestAccInitAndBuildBasicAmazonAmiDatasource(t *testing.T) { plugin := addrs.Plugin{ - Hostname: "github.com", - Namespace: "hashicorp", - Type: "amazon", + Source: "github.com/hashicorp/amazon", } testCase := &acctest.PluginTestCase{ Name: "amazon-ami_basic_datasource_test", diff --git a/acctest/plugin/plugin_acc_test.go b/acctest/plugin/plugin_acc_test.go index b2afe7e81..c9eec1fbc 100644 --- a/acctest/plugin/plugin_acc_test.go +++ b/acctest/plugin/plugin_acc_test.go @@ -18,7 +18,7 @@ import ( "github.com/hashicorp/packer-plugin-sdk/acctest" "github.com/hashicorp/packer-plugin-sdk/acctest/testutils" "github.com/hashicorp/packer/hcl2template/addrs" - "github.com/mitchellh/go-homedir" + "github.com/hashicorp/packer/packer" ) //go:embed test-fixtures/basic-amazon-ebs.pkr.hcl @@ -26,9 +26,7 @@ var basicAmazonEbsHCL2Template string func TestAccInitAndBuildBasicAmazonEbs(t *testing.T) { plugin := addrs.Plugin{ - Hostname: "github.com", - Namespace: "hashicorp", - Type: "amazon", + Source: "github.com/hashicorp/amazon", } testCase := &acctest.PluginTestCase{ Name: "amazon-ebs_basic_plugin_init_and_build_test", @@ -69,27 +67,22 @@ func TestAccInitAndBuildBasicAmazonEbs(t *testing.T) { acctest.TestPlugin(t, testCase) } -func cleanupPluginInstallation(plugin addrs.Plugin) error { - home, err := homedir.Dir() +func pluginDirectory(plugin addrs.Plugin) (string, error) { + pluginDir, err := packer.PluginFolder() if err != nil { - return err + return "", err } - pluginPath := filepath.Join(home, - ".packer.d", - "plugins", - plugin.Hostname, - plugin.Namespace, - plugin.Type) - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { - pluginPath = filepath.Join(xdgConfigHome, - "packer", - "plugins", - plugin.Hostname, - plugin.Namespace, - plugin.Type) - } + pluginParts := []string{pluginDir} + pluginParts = append(pluginParts, plugin.Parts()...) + return filepath.Join(pluginParts...), nil +} +func cleanupPluginInstallation(plugin addrs.Plugin) error { + pluginPath, err := pluginDirectory(plugin) + if err != nil { + return err + } testutils.CleanupFiles(pluginPath) return nil } @@ -100,27 +93,11 @@ func checkPluginInstallation(initOutput string, plugin addrs.Plugin) error { return fmt.Errorf("logs doesn't contain expected foo value %q", initOutput) } - home, err := homedir.Dir() + pluginPath, err := pluginDirectory(plugin) if err != nil { return err } - pluginPath := filepath.Join(home, - ".packer.d", - "plugins", - plugin.Hostname, - plugin.Namespace, - plugin.Type) - - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { - pluginPath = filepath.Join(xdgConfigHome, - "packer", - "plugins", - plugin.Hostname, - plugin.Namespace, - plugin.Type) - } - if !testutils.FileExists(pluginPath) { return fmt.Errorf("%s plugin installation not found", plugin.String()) } diff --git a/command/plugins_install.go b/command/plugins_install.go index 4360de411..59e197a3f 100644 --- a/command/plugins_install.go +++ b/command/plugins_install.go @@ -308,7 +308,7 @@ func (c *PluginsInstallCommand) InstallFromBinary(opts plugingetter.ListInstalla outputPrefix := fmt.Sprintf( "packer-plugin-%s_v%s_%s", - pluginIdentifier.Type, + pluginIdentifier.Name(), noMetaVersion, desc.APIVersion, ) diff --git a/hcl2template/addrs/plugin.go b/hcl2template/addrs/plugin.go index 9db29b317..2baae5703 100644 --- a/hcl2template/addrs/plugin.go +++ b/hcl2template/addrs/plugin.go @@ -5,6 +5,8 @@ package addrs import ( "fmt" + "net/url" + "path" "strings" "github.com/hashicorp/hcl/v2" @@ -13,21 +15,31 @@ import ( // Plugin encapsulates a single plugin type. type Plugin struct { - Hostname string - Namespace string - Type string + Source string } -func (p Plugin) RealRelativePath() string { - return p.Namespace + "/packer-plugin-" + p.Type +// Parts returns the list of components of the source URL, starting with the +// host, and ending with the name of the plugin. +// +// This will correspond more or less to the filesystem hierarchy where +// the plugin is installed. +func (p Plugin) Parts() []string { + return strings.FieldsFunc(p.Source, func(r rune) bool { + return r == '/' + }) } -func (p Plugin) Parts() []string { - return []string{p.Hostname, p.Namespace, p.Type} +// Name returns the raw name of the plugin from its source +// +// Exemples: +// - "github.com/hashicorp/amazon" -> "amazon" +func (p Plugin) Name() string { + parts := p.Parts() + return parts[len(parts)-1] } func (p Plugin) String() string { - return strings.Join(p.Parts(), "/") + return p.Source } // ParsePluginPart processes an addrs.Plugin namespace or type string @@ -98,67 +110,66 @@ func IsPluginPartNormalized(str string) (bool, error) { // // The following are valid source string formats: // -// name // namespace/name // hostname/namespace/name func ParsePluginSourceString(str string) (*Plugin, hcl.Diagnostics) { - ret := &Plugin{ - Hostname: "", - Namespace: "", - } var diags hcl.Diagnostics - // split the source string into individual components - parts := strings.Split(str, "/") - if len(parts) != 3 { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid plugin source string", - Detail: `The "source" attribute must be in the format "hostname/namespace/name"`, - }) - return nil, diags + var errs []string + + if strings.HasPrefix(str, "/") { + errs = append(errs, "A source URL must not start with a '/' character.") } - // check for an invalid empty string in any part - for i := range parts { - if parts[i] == "" { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid plugin source string", - Detail: `The "source" attribute must be in the format "hostname/namespace/name"`, - }) - return nil, diags - } + if strings.HasSuffix(str, "/") { + errs = append(errs, "A source URL must not end with a '/' character.") } - // check the 'name' portion, which is always the last part - givenName := parts[len(parts)-1] - name, err := ParsePluginPart(givenName) + if !strings.Contains(str, "/") { + errs = append(errs, "A source URL must at least contain a host and a path.") + } + + url, err := url.Parse(str) if err != nil { - diags = diags.Append(&hcl.Diagnostic{ + errs = append(errs, fmt.Sprintf("Failed to parse source URL: %s", err)) + } + + if url != nil && url.Scheme != "" { + errs = append(errs, "A source URL must not contain a scheme (e.g. https://).") + } + + if url != nil && url.RawQuery != "" { + errs = append(errs, "A source URL must not contain a query (e.g. ?var=val)") + } + + if url != nil && url.Fragment != "" { + errs = append(errs, "A source URL must not contain a fragment (e.g. #anchor).") + } + + if errs != nil { + errsMsg := &strings.Builder{} + for _, err := range errs { + fmt.Fprintf(errsMsg, "* %s\n", err) + } + + return nil, diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: "Invalid plugin type", - Detail: fmt.Sprintf(`Invalid plugin type %q in source %q: %s"`, givenName, str, err), + Summary: "Malformed source URL", + Detail: fmt.Sprintf("The provided source URL %q is invalid. The following errors have been discovered:\n%s\nA valid source looks like \"github.com/hashicorp/happycloud\"", str, errsMsg), }) - return nil, diags } - ret.Type = name - // the namespace is always the second-to-last part - givenNamespace := parts[len(parts)-2] - namespace, err := ParsePluginPart(givenNamespace) + // check the 'name' portion, which is always the last part + _, givenName := path.Split(str) + _, err = ParsePluginPart(givenName) if err != nil { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: "Invalid plugin namespace", - Detail: fmt.Sprintf(`Invalid plugin namespace %q in source %q: %s"`, namespace, str, err), + Summary: "Invalid plugin type", + Detail: fmt.Sprintf(`Invalid plugin type %q in source %q: %s"`, givenName, str, err), }) return nil, diags } - ret.Namespace = namespace - - // the hostname is always the first part in a three-part source string - ret.Hostname = parts[0] // Due to how plugin executables are named and plugin git repositories // are conventionally named, it's a reasonable and @@ -171,8 +182,8 @@ func ParsePluginSourceString(str string) (*Plugin, hcl.Diagnostics) { // packer-plugin- prefix to help them self-correct. const redundantPrefix = "packer-" const userErrorPrefix = "packer-plugin-" - if strings.HasPrefix(ret.Type, redundantPrefix) { - if strings.HasPrefix(ret.Type, userErrorPrefix) { + if strings.HasPrefix(givenName, redundantPrefix) { + if strings.HasPrefix(givenName, userErrorPrefix) { // Likely user error. We only return this specialized error if // whatever is after the prefix would otherwise be a // syntactically-valid plugin type, so we don't end up advising @@ -181,32 +192,33 @@ func ParsePluginSourceString(str string) (*Plugin, hcl.Diagnostics) { // (This is mainly just for robustness, because the validation // we already did above should've rejected most/all ways for // the suggestedType to end up invalid here.) - suggestedType := ret.Type[len(userErrorPrefix):] + suggestedType := strings.Replace(givenName, userErrorPrefix, "", -1) if _, err := ParsePluginPart(suggestedType); err == nil { - suggestedAddr := ret - suggestedAddr.Type = suggestedType - diags = diags.Append(&hcl.Diagnostic{ + return nil, diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid plugin type", Detail: fmt.Sprintf("Plugin source %q has a type with the prefix %q, which isn't valid. "+ "Although that prefix is often used in the names of version control repositories for Packer plugins, "+ "plugin source strings should not include it.\n"+ - "\nDid you mean %q?", ret, userErrorPrefix, suggestedAddr), + "\nDid you mean %q?", str, userErrorPrefix, suggestedType), }) - return nil, diags } } // Otherwise, probably instead an incorrectly-named plugin, perhaps // arising from a similar instinct to what causes there to be // thousands of Python packages on PyPI with "python-"-prefixed // names. - diags = diags.Append(&hcl.Diagnostic{ + return nil, diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid plugin type", - Detail: fmt.Sprintf("Plugin source %q has a type with the prefix %q, which isn't allowed because it would be redundant to name a Packer plugin with that prefix. If you are the author of this plugin, rename it to not include the prefix.", ret, redundantPrefix), + Detail: fmt.Sprintf("Plugin source %q has a type with the prefix %q, which isn't allowed "+ + "because it would be redundant to name a Packer plugin with that prefix. "+ + "If you are the author of this plugin, rename it to not include the prefix.", + str, redundantPrefix), }) - return nil, diags } - return ret, diags + return &Plugin{ + Source: str, + }, diags } diff --git a/hcl2template/addrs/plugin_test.go b/hcl2template/addrs/plugin_test.go index e99a72416..60af038be 100644 --- a/hcl2template/addrs/plugin_test.go +++ b/hcl2template/addrs/plugin_test.go @@ -6,29 +6,109 @@ package addrs import ( "reflect" "testing" + + "github.com/google/go-cmp/cmp" ) -func TestParsePluginSourceString(t *testing.T) { - type args struct { - str string - } +func TestPluginParseSourceString(t *testing.T) { tests := []struct { - args args + name string + source string want *Plugin wantDiags bool }{ - {args{"potato"}, nil, true}, - {args{"hashicorp/azr"}, nil, true}, - {args{"github.com/hashicorp/azr"}, &Plugin{"github.com", "hashicorp", "azr"}, false}, + {"invalid: only one component, rejected", "potato", nil, true}, + {"valid: two components in name", "hashicorp/azr", &Plugin{"hashicorp/azr"}, false}, + {"valid: three components, nothing superfluous", "github.com/hashicorp/azr", &Plugin{"github.com/hashicorp/azr"}, false}, + {"invalid: trailing slash", "github.com/hashicorp/azr/", nil, true}, + {"invalid: reject because scheme specified", "https://github.com/hashicorp/azr", nil, true}, + {"invalid: reject because query non nil", "github.com/hashicorp/azr?arg=1", nil, true}, + {"invalid: reject because fragment present", "github.com/hashicorp/azr#anchor", nil, true}, + {"invalid: leading and trailing slashes are removed", "/github.com/hashicorp/azr/", nil, true}, + {"invalid: leading slashes are removed", "/github.com/hashicorp/azr", nil, true}, + {"invalid: plugin name contains packer-", "/github.com/hashicorp/packer-azr", nil, true}, + {"invalid: plugin name contains packer-plugin-", "/github.com/hashicorp/packer-plugin-azr", nil, true}, } for _, tt := range tests { - t.Run(tt.args.str, func(t *testing.T) { - got, gotDiags := ParsePluginSourceString(tt.args.str) + t.Run(tt.name, func(t *testing.T) { + got, gotDiags := ParsePluginSourceString(tt.source) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ParsePluginSourceString() got = %v, want %v", got, tt.want) } - if tt.wantDiags == (len(gotDiags) == 0) { - t.Errorf("Unexpected diags %s", gotDiags) + if tt.wantDiags && len(gotDiags) == 0 { + t.Errorf("Expected diags, but got none") + } + if !tt.wantDiags && len(gotDiags) != 0 { + t.Errorf("Unexpected diags: %s", gotDiags) + } + }) + } +} + +func TestPluginName(t *testing.T) { + tests := []struct { + name string + pluginString string + expectName string + }{ + { + "valid minimal name", + "github.com/hashicorp/amazon", + "amazon", + }, + { + // Technically we can call `Name` on a plugin created manually + // but this is invalid as the Source's Name should not contain + // `packer-plugin-`. + "invalid name with prefix", + "github.com/hashicorp/packer-plugin-amazon", + "packer-plugin-amazon", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plug := &Plugin{ + Source: tt.pluginString, + } + + name := plug.Name() + if name != tt.expectName { + t.Errorf("Expected plugin %q to have %q as name, got %q", tt.pluginString, tt.expectName, name) + } + }) + } +} + +func TestPluginParts(t *testing.T) { + tests := []struct { + name string + pluginSource string + expectedParts []string + }{ + { + "valid with two parts", + "factiartory.com/packer", + []string{"factiartory.com", "packer"}, + }, + { + "valid with four parts", + "factiartory.com/hashicrop/fields/packer", + []string{"factiartory.com", "hashicrop", "fields", "packer"}, + }, + { + "valid, with double-slashes in the name", + "factiartory.com/hashicrop//fields/packer//", + []string{"factiartory.com", "hashicrop", "fields", "packer"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{tt.pluginSource} + diff := cmp.Diff(plugin.Parts(), tt.expectedParts) + if diff != "" { + t.Errorf("Difference found between expected and computed parts: %s", diff) } }) } diff --git a/hcl2template/types.packer_config_test.go b/hcl2template/types.packer_config_test.go index eec35860e..10a0e73a7 100644 --- a/hcl2template/types.packer_config_test.go +++ b/hcl2template/types.packer_config_test.go @@ -474,9 +474,7 @@ func TestParser_no_init(t *testing.T) { Name: "amazon", Source: "github.com/hashicorp/amazon", Type: &addrs.Plugin{ - Type: "amazon", - Namespace: "hashicorp", - Hostname: "github.com", + Source: "github.com/hashicorp/amazon", }, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint(">= v0")), @@ -486,9 +484,7 @@ func TestParser_no_init(t *testing.T) { Name: "amazon-v1", Source: "github.com/hashicorp/amazon", Type: &addrs.Plugin{ - Type: "amazon", - Namespace: "hashicorp", - Hostname: "github.com", + Source: "github.com/hashicorp/amazon", }, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint(">= v1")), @@ -498,9 +494,7 @@ func TestParser_no_init(t *testing.T) { Name: "amazon-v2", Source: "github.com/hashicorp/amazon", Type: &addrs.Plugin{ - Type: "amazon", - Namespace: "hashicorp", - Hostname: "github.com", + Source: "github.com/hashicorp/amazon", }, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint(">= v2")), @@ -510,9 +504,7 @@ func TestParser_no_init(t *testing.T) { Name: "amazon-v3", Source: "github.com/hashicorp/amazon", Type: &addrs.Plugin{ - Type: "amazon", - Namespace: "hashicorp", - Hostname: "github.com", + Source: "github.com/hashicorp/amazon", }, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint(">= v3")), @@ -522,9 +514,7 @@ func TestParser_no_init(t *testing.T) { Name: "amazon-v3-azr", Source: "github.com/azr/amazon", Type: &addrs.Plugin{ - Type: "amazon", - Namespace: "azr", - Hostname: "github.com", + Source: "github.com/azr/amazon", }, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint(">= v3")), @@ -534,9 +524,7 @@ func TestParser_no_init(t *testing.T) { Name: "amazon-v4", Source: "github.com/hashicorp/amazon", Type: &addrs.Plugin{ - Type: "amazon", - Namespace: "hashicorp", - Hostname: "github.com", + Source: "github.com/hashicorp/amazon", }, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint(">= v4")), diff --git a/hcl2template/types.required_plugins_test.go b/hcl2template/types.required_plugins_test.go index 0329d1505..8a44b6e17 100644 --- a/hcl2template/types.required_plugins_test.go +++ b/hcl2template/types.required_plugins_test.go @@ -42,7 +42,7 @@ func TestPackerConfig_required_plugin_parse(t *testing.T) { "amazon": { Name: "amazon", Source: "github.com/hashicorp/amazon", - Type: &addrs.Plugin{Hostname: "github.com", Namespace: "hashicorp", Type: "amazon"}, + Type: &addrs.Plugin{Source: "github.com/hashicorp/amazon"}, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint("~> v1.2.3")), }, @@ -72,7 +72,7 @@ func TestPackerConfig_required_plugin_parse(t *testing.T) { "amazon": { Name: "amazon", Source: "github.com/azr/amazon", - Type: &addrs.Plugin{Hostname: "github.com", Namespace: "azr", Type: "amazon"}, + Type: &addrs.Plugin{Source: "github.com/azr/amazon"}, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint("~> v1.2.3")), }, @@ -103,7 +103,7 @@ func TestPackerConfig_required_plugin_parse(t *testing.T) { "amazon": { Name: "amazon", Source: "github.com/azr/amazon", - Type: &addrs.Plugin{Hostname: "github.com", Namespace: "azr", Type: "amazon"}, + Type: &addrs.Plugin{Source: "github.com/azr/amazon"}, Requirement: VersionConstraint{ Required: mustVersionConstraints(version.NewConstraint("~> v1.2.3")), }, diff --git a/packer/plugin-getter/github/getter.go b/packer/plugin-getter/github/getter.go index a4942a421..e4cd4f603 100644 --- a/packer/plugin-getter/github/getter.go +++ b/packer/plugin-getter/github/getter.go @@ -14,10 +14,12 @@ import ( "log" "net/http" "os" + "path" "path/filepath" "strings" "github.com/google/go-github/v33/github" + "github.com/hashicorp/packer/hcl2template/addrs" plugingetter "github.com/hashicorp/packer/packer/plugin-getter" "golang.org/x/oauth2" ) @@ -156,10 +158,40 @@ func (t *HostSpecificTokenAuthTransport) base() http.RoundTripper { return http.DefaultTransport } +type GithubPlugin struct { + Hostname string + Namespace string + Type string +} + +func NewGithubPlugin(source *addrs.Plugin) (*GithubPlugin, error) { + parts := source.Parts() + if len(parts) != 3 { + return nil, fmt.Errorf("Invalid github.com URI %q: a Github-compatible source must be in the github.com// format.", source.String()) + } + + if parts[0] != defaultHostname { + return nil, fmt.Errorf("%q doesn't appear to be a valid %q source address; check source and try again.", source.String(), defaultHostname) + } + + return &GithubPlugin{ + Hostname: parts[0], + Namespace: parts[1], + Type: strings.Replace(parts[2], "packer-plugin-", "", 1), + }, nil +} + +func (gp GithubPlugin) RealRelativePath() string { + return path.Join( + gp.Namespace, + fmt.Sprintf("packer-plugin-%s", gp.Type), + ) +} + func (g *Getter) Get(what string, opts plugingetter.GetOptions) (io.ReadCloser, error) { - if opts.PluginRequirement.Identifier.Hostname != defaultHostname { - s := opts.PluginRequirement.Identifier.String() + " doesn't appear to be a valid " + defaultHostname + " source address; check source and try again." - return nil, errors.New(s) + ghURI, err := NewGithubPlugin(opts.PluginRequirement.Identifier) + if err != nil { + return nil, err } ctx := context.TODO() @@ -188,19 +220,18 @@ func (g *Getter) Get(what string, opts plugingetter.GetOptions) (io.ReadCloser, } var req *http.Request - var err error transform := func(in io.ReadCloser) (io.ReadCloser, error) { return in, nil } switch what { case "releases": - u := filepath.ToSlash("/repos/" + opts.PluginRequirement.Identifier.RealRelativePath() + "/git/matching-refs/tags") + u := filepath.ToSlash("/repos/" + ghURI.RealRelativePath() + "/git/matching-refs/tags") req, err = g.Client.NewRequest("GET", u, nil) transform = transformVersionStream case "sha256": // something like https://github.com/sylviamoss/packer-plugin-comment/releases/download/v0.2.11/packer-plugin-comment_v0.2.11_x5_SHA256SUMS - u := filepath.ToSlash("https://github.com/" + opts.PluginRequirement.Identifier.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.PluginRequirement.FilenamePrefix() + opts.Version() + "_SHA256SUMS") + u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.PluginRequirement.FilenamePrefix() + opts.Version() + "_SHA256SUMS") req, err = g.Client.NewRequest( "GET", u, @@ -208,7 +239,7 @@ func (g *Getter) Get(what string, opts plugingetter.GetOptions) (io.ReadCloser, ) transform = transformChecksumStream() case "zip": - u := filepath.ToSlash("https://github.com/" + opts.PluginRequirement.Identifier.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.ExpectedZipFilename()) + u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.ExpectedZipFilename()) req, err = g.Client.NewRequest( "GET", u, diff --git a/packer/plugin-getter/plugins.go b/packer/plugin-getter/plugins.go index 402ab92f1..69c493870 100644 --- a/packer/plugin-getter/plugins.go +++ b/packer/plugin-getter/plugins.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "log" "os" "os/exec" @@ -96,13 +97,66 @@ func (pr Requirement) FilenamePrefix() string { if pr.Identifier == nil { return "packer-plugin-" } - return "packer-plugin-" + pr.Identifier.Type + "_" + + return "packer-plugin-" + pr.Identifier.Name() + "_" } func (opts BinaryInstallationOptions) FilenameSuffix() string { return "_" + opts.OS + "_" + opts.ARCH + opts.Ext } +// getPluginBinaries lists the plugin binaries installed locally. +// +// Each plugin binary must be in the right hierarchy (not root) and has to be +// conforming to the packer-plugin-____ convention. +func (pr Requirement) getPluginBinaries(opts ListInstallationsOptions) ([]string, error) { + var matches []string + + rootdir := opts.PluginDirectory + if pr.Identifier != nil { + rootdir = filepath.Join(rootdir, path.Dir(pr.Identifier.Source)) + } + + if _, err := os.Lstat(rootdir); err != nil { + log.Printf("Directory %q does not exist, the plugin likely isn't installed locally yet.", rootdir) + return matches, nil + } + + err := filepath.WalkDir(rootdir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // No need to inspect directory entries, we can continue walking + if d.IsDir() { + return nil + } + + // Skip plugins installed at root, only those in a hierarchy should be considered valid + if filepath.Dir(path) == opts.PluginDirectory { + return nil + } + + // If the binary's name doesn't start with packer-plugin-, we skip it. + if !strings.HasPrefix(filepath.Base(path), pr.FilenamePrefix()) { + return nil + } + // If the binary's name doesn't match the expected convention, we skip it + if !strings.HasSuffix(filepath.Base(path), opts.FilenameSuffix()) { + return nil + } + + matches = append(matches, path) + + return nil + }) + if err != nil { + return nil, err + } + + return matches, err +} + // ListInstallations lists unique installed versions of plugin Requirement pr // with opts as a filter. // @@ -113,21 +167,13 @@ func (opts BinaryInstallationOptions) FilenameSuffix() string { // considered. func (pr Requirement) ListInstallations(opts ListInstallationsOptions) (InstallList, error) { res := InstallList{} - FilenamePrefix := pr.FilenamePrefix() - filenameSuffix := opts.FilenameSuffix() log.Printf("[TRACE] listing potential installations for %q that match %q. %#v", pr.Identifier, pr.VersionConstraints, opts) - glob := "" - if pr.Identifier == nil { - glob = filepath.Join(opts.PluginDirectory, "*", "*", "*", FilenamePrefix+"*"+filenameSuffix) - } else { - glob = filepath.Join(opts.PluginDirectory, pr.Identifier.Hostname, pr.Identifier.Namespace, pr.Identifier.Type, FilenamePrefix+"*"+filenameSuffix) - } - - matches, err := filepath.Glob(glob) + matches, err := pr.getPluginBinaries(opts) if err != nil { - return nil, fmt.Errorf("ListInstallations: %q failed to list binaries in folder: %v", pr.Identifier.String(), err) + return nil, fmt.Errorf("ListInstallations: failed to list installed plugins: %s", err) } + for _, path := range matches { fname := filepath.Base(path) if fname == "." { @@ -156,8 +202,8 @@ func (pr Requirement) ListInstallations(opts ListInstallationsOptions) (InstallL } // base name could look like packer-plugin-amazon_v1.2.3_x5.1_darwin_amd64.exe - versionsStr := strings.TrimPrefix(fname, FilenamePrefix) - versionsStr = strings.TrimSuffix(versionsStr, filenameSuffix) + versionsStr := strings.TrimPrefix(fname, pr.FilenamePrefix()) + versionsStr = strings.TrimSuffix(versionsStr, opts.FilenameSuffix()) if pr.Identifier == nil { if idx := strings.Index(versionsStr, "_"); idx > 0 { diff --git a/packer/plugin-getter/plugins_test.go b/packer/plugin-getter/plugins_test.go index e92e636b1..85bcab42e 100644 --- a/packer/plugin-getter/plugins_test.go +++ b/packer/plugin-getter/plugins_test.go @@ -35,9 +35,7 @@ func TestChecksumFileEntry_init(t *testing.T) { expectedVersion := "v0.3.0" req := &Requirement{ Identifier: &addrs.Plugin{ - Hostname: "github.com", - Namespace: "ddelnano", - Type: "xenserver", + Source: "github.com/ddelnano/xenserver", }, } @@ -460,9 +458,10 @@ func (g *mockPluginGetter) Get(what string, options GetOptions) (io.ReadCloser, } toEncode = enc case "zip": - acc := options.PluginRequirement.Identifier.Hostname + "/" + - options.PluginRequirement.Identifier.RealRelativePath() + "/" + - options.ExpectedZipFilename() + // Note: we'll act as if the plugin sources would always be github sources for now. + // This test will need to be updated if/when we move on to support other sources. + parts := options.PluginRequirement.Identifier.Parts() + acc := fmt.Sprintf("%s/%s/packer-plugin-%s/%s", parts[0], parts[1], parts[2], options.ExpectedZipFilename()) zip, found := g.Zips[acc] if found == false {