Skip to content

Commit

Permalink
Install multiple plugins by passing in a file argument to porter plug…
Browse files Browse the repository at this point in the history
…ins install (#2504)

Signed-off-by: Yingrong Zhao <[email protected]>
  • Loading branch information
VinozzZ authored Jan 12, 2023
1 parent 0714843 commit 569394a
Show file tree
Hide file tree
Showing 10 changed files with 282 additions and 28 deletions.
4 changes: 3 additions & 1 deletion cmd/porter/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ By default plugins are downloaded from the official Porter plugin feed at https:
porter plugin install azure --version v0.8.2-beta.1
porter plugin install azure --version canary`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return opts.Validate(args)
return opts.Validate(args, p.Context)
},
RunE: func(cmd *cobra.Command, args []string) error {
return p.InstallPlugin(cmd.Context(), opts)
Expand All @@ -128,6 +128,8 @@ By default plugins are downloaded from the official Porter plugin feed at https:
"URL of an atom feed where the plugin can be downloaded. Defaults to the official Porter plugin feed.")
flags.StringVar(&opts.Mirror, "mirror", pkgmgmt.DefaultPackageMirror,
"Mirror of official Porter assets")
flags.StringVarP(&opts.File, "file", "f", "",
"Path to porter plugins config file.")

return cmd
}
Expand Down
19 changes: 15 additions & 4 deletions docs/content/cli/plugins_install.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@ url: /cli/porter_plugins_install/
---
## porter plugins install

Install a plugin
Install plugins

### Synopsis

Install a plugin.

By default plugins are downloaded from the official Porter plugin feed at https://cdn.porter.sh/plugins/atom.xml. To download from a mirror, set the environment variable PORTER_MIRROR, or mirror in the Porter config file, with the value to replace https://cdn.porter.sh with.
Porter offers two ways to install plugins. Users can install plugins one at a time or multiple plugins through a plugins definition file.

Below command will install one plugin:
```
porter plugins install NAME [flags]
```

To install multiple command, users can pass a file to the install command through `--file` flag:
```
porter plugins install --file plugins.yaml
```

By default plugins are downloaded from the official Porter plugin feed at https://cdn.porter.sh/plugins/atom.xml. To download from a mirror, set the environment variable PORTER_MIRROR, or mirror in the Porter config file, with the value to replace https://cdn.porter.sh with.


### Examples

```
Expand All @@ -25,12 +32,16 @@ porter plugins install NAME [flags]
porter plugin install azure --feed-url https://cdn.porter.sh/plugins/atom.xml
porter plugin install azure --version v0.8.2-beta.1
porter plugin install azure --version canary
porter plugin install --file plugins.yaml
porter plugin install --file plugins.yaml --feed-url https://cdn.porter.sh/plugins/atom.xml
porter plugin install --file plugins.yaml --mirror https://cdn.porter.sh
```

### Options

```
--feed-url string URL of an atom feed where the plugin can be downloaded. Defaults to the official Porter plugin feed.
-f, --file string Path to porter plugins config file.
-h, --help help for install
--mirror string Mirror of official Porter assets (default "https://cdn.porter.sh")
--url string URL from where the plugin can be downloaded, for example https://github.com/org/proj/releases/downloads
Expand Down
26 changes: 26 additions & 0 deletions docs/content/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ description: Defines the format of files used by Porter
* [Credential Sets](#credential-set)
* [Parameter Sets](#parameter-set)
* [Installation](#installation)
* [Plugins](#plugins)
* [Porter Operator File Formats](/operator/file-formats/)

## Supported Versions
Expand Down Expand Up @@ -152,6 +153,31 @@ parameters:
\* The bundle section requires a repository and one of the following fields: digest, version, or tag.
## Plugins
Plugins can be defined in either json or yaml.
You can use this [json schema][ps-schema] to validate a parameter set file.
```yaml
schemaType: Plugins
schemaVersion: 1.0.0
azure:
version: v1.0.0
feedURL: https://cdn.porter.sh/plugins/atom.xml
url: https://example.com
mirror: https://example.com
```
| Field | Required | Description |
|----------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------|
| schemaType | false | The type of document. This isn't used by Porter but is included when Porter outputs the file, so that editors can determine the resource type. |
| schemaVersion | true | The version of the Plugins schema used in this file. |
| <pluginName>.version | false | The version of the plugin. |
| <pluginName>.feedURL | false | The url of an atom feed where the plugin can be downloaded.
| <pluginName>.url | false | The url from where the plugin can be downloaded. |
| <pluginName>.mirror | false | The mirror of official Porter assets. |
[cs-schema]: /schema/v1/credential-set.schema.json
[ps-schema]: /schema/v1/parameter-set.schema.json
[inst-schema]: /schema/v1/installation.schema.json
[plugins-schema]: /schema/v1/plugins.schema.json
69 changes: 68 additions & 1 deletion pkg/plugins/install.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,81 @@
package plugins

import (
"fmt"
"sort"

"get.porter.sh/porter/pkg/pkgmgmt"
"get.porter.sh/porter/pkg/portercontext"
)

type InstallOptions struct {
pkgmgmt.InstallOptions

File string
}

func (o *InstallOptions) Validate(args []string) error {
func (o *InstallOptions) Validate(args []string, cxt *portercontext.Context) error {
o.PackageType = "plugin"
if o.File != "" {
if len(args) > 0 {
return fmt.Errorf("plugin name should not be specified when --file is provided")
}

if o.URL != "" {
return fmt.Errorf("plugin URL should not be specified when --file is provided")
}

if o.Version != "" {
return fmt.Errorf("plugin version should not be specified when --file is provided")
}

if _, err := cxt.FileSystem.Stat(o.File); err != nil {
return fmt.Errorf("unable to access --file %s: %w", o.File, err)
}

return nil
}

return o.InstallOptions.Validate(args)
}

// InstallFileOption is the go representation of plugin installation file format.
type InstallFileOption map[string]pkgmgmt.InstallOptions

// InstallPluginsConfig is a sorted list of InstallationFileOption in alphabetical order.
type InstallPluginsConfig struct {
data InstallFileOption
keys []string
}

// NewInstallPluginConfigs returns a new instance of InstallPluginConfigs with plugins sorted in alphabetical order
// using their names.
func NewInstallPluginConfigs(opt InstallFileOption) InstallPluginsConfig {
keys := make([]string, 0, len(opt))
data := make(InstallFileOption, len(opt))
for k, v := range opt {
keys = append(keys, k)

v.Name = k
v.PackageType = "plugin"
data[k] = v
}

sort.SliceStable(keys, func(i, j int) bool {
return keys[i] < keys[j]
})

return InstallPluginsConfig{
data: data,
keys: keys,
}
}

// Configs returns InstallOptions list in alphabetical order.
func (pc InstallPluginsConfig) Configs() []pkgmgmt.InstallOptions {
value := make([]pkgmgmt.InstallOptions, 0, len(pc.keys))
for _, k := range pc.keys {
value = append(value, pc.data[k])
}
return value
}
13 changes: 12 additions & 1 deletion pkg/plugins/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ package plugins
import (
"testing"

"get.porter.sh/porter/pkg/pkgmgmt"
"get.porter.sh/porter/pkg/portercontext"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInstallOptions_Validate(t *testing.T) {
// InstallOptions is already tested in pkgmgmt, we just want to make sure DefaultFeedURL is set
cxt := portercontext.NewTestContext(t)
opts := InstallOptions{}
err := opts.Validate([]string{"pkg1"})
err := opts.Validate([]string{"pkg1"}, cxt.Context)
require.NoError(t, err, "Validate failed")
assert.NotEmpty(t, opts.FeedURL, "Feed URL was not defaulted to the plugins feed URL")
}

func TestInstallPluginsConfig(t *testing.T) {
input := InstallFileOption{"kubernetes": pkgmgmt.InstallOptions{URL: "test-kubernetes.com"}, "azure": pkgmgmt.InstallOptions{URL: "test-azure.com"}}
expected := []pkgmgmt.InstallOptions{{Name: "azure", PackageType: "plugin", URL: "test-azure.com"}, {Name: "kubernetes", PackageType: "plugin", URL: "test-kubernetes.com"}}

cfg := NewInstallPluginConfigs(input)
require.Equal(t, expected, cfg.Configs())
}
69 changes: 62 additions & 7 deletions pkg/porter/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ import (
"os"
"strings"

"get.porter.sh/porter/pkg/encoding"
"get.porter.sh/porter/pkg/pkgmgmt"
"get.porter.sh/porter/pkg/plugins"
"get.porter.sh/porter/pkg/printer"
"get.porter.sh/porter/pkg/tracing"
"github.com/olekukonko/tablewriter"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/zap/zapcore"
)

// PrintPluginsOptions represent options for the PrintPlugins function
Expand Down Expand Up @@ -151,18 +155,27 @@ func (p *Porter) GetPlugin(ctx context.Context, name string) (*plugins.Metadata,
}

func (p *Porter) InstallPlugin(ctx context.Context, opts plugins.InstallOptions) error {
err := p.Plugins.Install(ctx, opts.InstallOptions)
ctx, log := tracing.StartSpan(ctx)
defer log.EndSpan()

installConfigs, err := p.getPluginInstallConfigs(ctx, opts)
if err != nil {
return err
}
for _, cfg := range installConfigs {
err := p.Plugins.Install(ctx, cfg)
if err != nil {
return err
}

plugin, err := p.Plugins.GetMetadata(ctx, opts.Name)
if err != nil {
return fmt.Errorf("failed to get plugin metadata: %w", err)
}
plugin, err := p.Plugins.GetMetadata(ctx, cfg.Name)
if err != nil {
return fmt.Errorf("failed to get plugin metadata: %w", err)
}

v := plugin.GetVersionInfo()
fmt.Fprintf(p.Out, "installed %s plugin %s (%s)\n", opts.Name, v.Version, v.Commit)
v := plugin.GetVersionInfo()
fmt.Fprintf(p.Out, "installed %s plugin %s (%s)\n", cfg.Name, v.Version, v.Commit)
}

return nil
}
Expand All @@ -177,3 +190,45 @@ func (p *Porter) UninstallPlugin(ctx context.Context, opts pkgmgmt.UninstallOpti

return nil
}

func (p *Porter) getPluginInstallConfigs(ctx context.Context, opts plugins.InstallOptions) ([]pkgmgmt.InstallOptions, error) {
_, log := tracing.StartSpan(ctx)
defer log.EndSpan()

var installConfigs []pkgmgmt.InstallOptions
if opts.File != "" {
var data plugins.InstallFileOption
if log.ShouldLog(zapcore.DebugLevel) {
// ignoring any error here, printing debug info isn't critical
contents, _ := p.FileSystem.ReadFile(opts.File)
log.Debug("read input file", attribute.String("contents", string(contents)))
}

if err := encoding.UnmarshalFile(p.FileSystem, opts.File, &data); err != nil {
return nil, fmt.Errorf("unable to parse %s as an installation document: %w", opts.File, err)
}
sortedCfgs := plugins.NewInstallPluginConfigs(data)

for _, config := range sortedCfgs.Configs() {
// if user specified a feed url or mirror using the flags, it will become
// the default value and apply to empty values parsed from the provided file
if config.FeedURL == "" {
config.FeedURL = opts.FeedURL
}
if config.Mirror == "" {
config.Mirror = opts.Mirror
}

if err := config.Validate([]string{config.Name}); err != nil {
return nil, err
}
installConfigs = append(installConfigs, config)

}

return installConfigs, nil
}

installConfigs = append(installConfigs, opts.InstallOptions)
return installConfigs, nil
}
47 changes: 33 additions & 14 deletions pkg/porter/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package porter

import (
"context"
"fmt"
"testing"

"get.porter.sh/porter/pkg/pkgmgmt"
Expand Down Expand Up @@ -221,20 +222,38 @@ implementations:
}

func TestPorter_InstallPlugin(t *testing.T) {
p := NewTestPorter(t)
defer p.Close()

opts := plugins.InstallOptions{}
opts.URL = "https://example.com"
err := opts.Validate([]string{"plugin1"})
require.NoError(t, err, "Validate failed")

err = p.InstallPlugin(context.Background(), opts)
require.NoError(t, err, "InstallPlugin failed")

wantOutput := "installed plugin1 plugin v1.0 (abc123)\n"
gotOutput := p.TestConfig.TestContext.GetOutput()
assert.Contains(t, wantOutput, gotOutput)
testcases := []struct {
name string
args []string
config plugins.InstallOptions
expectedOutput string
}{
{name: "json file", config: plugins.InstallOptions{File: "plugins.json"}, expectedOutput: "installed plugin1 plugin v1.0 (abc123)\ninstalled plugin2 plugin v1.0 (abc123)\n"},
{name: "yaml file", config: plugins.InstallOptions{File: "plugins.yaml"}, expectedOutput: "installed plugin1 plugin v1.0 (abc123)\ninstalled plugin2 plugin v1.0 (abc123)\n"},
{name: "with feed url default", config: plugins.InstallOptions{File: "plugins.yaml", InstallOptions: pkgmgmt.InstallOptions{FeedURL: "https://example.com/"}}, expectedOutput: "installed plugin1 plugin v1.0 (abc123)\ninstalled plugin2 plugin v1.0 (abc123)\n"},
{name: "with feed url default", config: plugins.InstallOptions{File: "plugins.json", InstallOptions: pkgmgmt.InstallOptions{PackageDownloadOptions: pkgmgmt.PackageDownloadOptions{Mirror: "https://example.com/"}}}, expectedOutput: "installed plugin1 plugin v1.0 (abc123)\ninstalled plugin2 plugin v1.0 (abc123)\n"},
{name: "through arg", args: []string{"plugin1"}, config: plugins.InstallOptions{InstallOptions: pkgmgmt.InstallOptions{URL: "https://example.com/"}}, expectedOutput: "installed plugin1 plugin v1.0 (abc123)\n"},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
p := NewTestPorter(t)
defer p.Close()

if tc.config.File != "" {
p.TestConfig.TestContext.AddTestFile(fmt.Sprintf("testdata/%s", tc.config.File), fmt.Sprintf("/%s", tc.config.File))
}
err := tc.config.Validate(tc.args, p.Context)
require.NoError(t, err, "Validate failed")

err = p.InstallPlugin(context.Background(), tc.config)
require.NoError(t, err, "InstallPlugin failed")

gotOutput := p.TestConfig.TestContext.GetOutput()
assert.NotEmpty(t, gotOutput)
assert.Contains(t, tc.expectedOutput, gotOutput)
})
}
}

func TestPorter_UninstallPlugin(t *testing.T) {
Expand Down
8 changes: 8 additions & 0 deletions pkg/porter/testdata/plugins.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"plugin1": {
"version": "v1.0"
},
"plugin2": {
"version": "v1.0"
}
}
4 changes: 4 additions & 0 deletions pkg/porter/testdata/plugins.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
plugin1:
version: v1.0
plugin2:
version: v1.0
Loading

0 comments on commit 569394a

Please sign in to comment.