Add docker plugin upgrade
This allows a plugin to be upgraded without requiring to uninstall/reinstall a plugin. Since plugin resources (e.g. volumes) are tied to a plugin ID, this is important to ensure resources aren't lost. The plugin must be disabled while upgrading (errors out if enabled). This does not add any convenience flags for automatically disabling/re-enabling the plugin during before/after upgrade. Since an upgrade may change requested permissions, the user is required to accept permissions just like `docker plugin install`. Signed-off-by: Brian Goff <cpuguy83@gmail.com>
This commit is contained in:
parent
edd977db97
commit
03c6949739
26 changed files with 568 additions and 117 deletions
|
@ -21,5 +21,6 @@ type Backend interface {
|
|||
Privileges(ctx context.Context, ref reference.Named, metaHeaders http.Header, authConfig *enginetypes.AuthConfig) (enginetypes.PluginPrivileges, error)
|
||||
Pull(ctx context.Context, ref reference.Named, name string, metaHeaders http.Header, authConfig *enginetypes.AuthConfig, privileges enginetypes.PluginPrivileges, outStream io.Writer) error
|
||||
Push(ctx context.Context, name string, metaHeaders http.Header, authConfig *enginetypes.AuthConfig, outStream io.Writer) error
|
||||
Upgrade(ctx context.Context, ref reference.Named, name string, metaHeaders http.Header, authConfig *enginetypes.AuthConfig, privileges enginetypes.PluginPrivileges, outStream io.Writer) error
|
||||
CreateFromContext(ctx context.Context, tarCtx io.ReadCloser, options *enginetypes.PluginCreateOptions) error
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ func (r *pluginRouter) initRoutes() {
|
|||
router.NewPostRoute("/plugins/{name:.*}/disable", r.disablePlugin),
|
||||
router.Cancellable(router.NewPostRoute("/plugins/pull", r.pullPlugin)),
|
||||
router.Cancellable(router.NewPostRoute("/plugins/{name:.*}/push", r.pushPlugin)),
|
||||
router.Cancellable(router.NewPostRoute("/plugins/{name:.*}/upgrade", r.upgradePlugin)),
|
||||
router.NewPostRoute("/plugins/{name:.*}/set", r.setPlugin),
|
||||
router.NewPostRoute("/plugins/create", r.createPlugin),
|
||||
}
|
||||
|
|
|
@ -101,6 +101,45 @@ func (pr *pluginRouter) getPrivileges(ctx context.Context, w http.ResponseWriter
|
|||
return httputils.WriteJSON(w, http.StatusOK, privileges)
|
||||
}
|
||||
|
||||
func (pr *pluginRouter) upgradePlugin(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
|
||||
if err := httputils.ParseForm(r); err != nil {
|
||||
return errors.Wrap(err, "failed to parse form")
|
||||
}
|
||||
|
||||
var privileges types.PluginPrivileges
|
||||
dec := json.NewDecoder(r.Body)
|
||||
if err := dec.Decode(&privileges); err != nil {
|
||||
return errors.Wrap(err, "failed to parse privileges")
|
||||
}
|
||||
if dec.More() {
|
||||
return errors.New("invalid privileges")
|
||||
}
|
||||
|
||||
metaHeaders, authConfig := parseHeaders(r.Header)
|
||||
ref, tag, err := parseRemoteRef(r.FormValue("remote"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name, err := getName(ref, tag, vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Docker-Plugin-Name", name)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
output := ioutils.NewWriteFlusher(w)
|
||||
|
||||
if err := pr.backend.Upgrade(ctx, ref, name, metaHeaders, authConfig, privileges, output); err != nil {
|
||||
if !output.Flushed() {
|
||||
return err
|
||||
}
|
||||
output.Write(streamformatter.NewJSONStreamFormatter().FormatError(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pr *pluginRouter) pullPlugin(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
|
||||
if err := httputils.ParseForm(r); err != nil {
|
||||
return errors.Wrap(err, "failed to parse form")
|
||||
|
@ -116,40 +155,14 @@ func (pr *pluginRouter) pullPlugin(ctx context.Context, w http.ResponseWriter, r
|
|||
}
|
||||
|
||||
metaHeaders, authConfig := parseHeaders(r.Header)
|
||||
|
||||
ref, tag, err := parseRemoteRef(r.FormValue("remote"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
if _, ok := ref.(reference.Canonical); ok {
|
||||
trimmed := reference.TrimNamed(ref)
|
||||
if tag != "" {
|
||||
nt, err := reference.WithTag(trimmed, tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = nt.String()
|
||||
} else {
|
||||
name = reference.WithDefaultTag(trimmed).String()
|
||||
}
|
||||
} else {
|
||||
name = ref.String()
|
||||
}
|
||||
} else {
|
||||
localRef, err := reference.ParseNamed(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := localRef.(reference.Canonical); ok {
|
||||
return errors.New("cannot use digest in plugin tag")
|
||||
}
|
||||
if distreference.IsNameOnly(localRef) {
|
||||
// TODO: log change in name to out stream
|
||||
name = reference.WithDefaultTag(localRef).String()
|
||||
}
|
||||
name, err := getName(ref, tag, r.FormValue("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Docker-Plugin-Name", name)
|
||||
|
||||
|
@ -166,6 +179,38 @@ func (pr *pluginRouter) pullPlugin(ctx context.Context, w http.ResponseWriter, r
|
|||
return nil
|
||||
}
|
||||
|
||||
func getName(ref reference.Named, tag, name string) (string, error) {
|
||||
if name == "" {
|
||||
if _, ok := ref.(reference.Canonical); ok {
|
||||
trimmed := reference.TrimNamed(ref)
|
||||
if tag != "" {
|
||||
nt, err := reference.WithTag(trimmed, tag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name = nt.String()
|
||||
} else {
|
||||
name = reference.WithDefaultTag(trimmed).String()
|
||||
}
|
||||
} else {
|
||||
name = ref.String()
|
||||
}
|
||||
} else {
|
||||
localRef, err := reference.ParseNamed(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, ok := localRef.(reference.Canonical); ok {
|
||||
return "", errors.New("cannot use digest in plugin tag")
|
||||
}
|
||||
if distreference.IsNameOnly(localRef) {
|
||||
// TODO: log change in name to out stream
|
||||
name = reference.WithDefaultTag(localRef).String()
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (pr *pluginRouter) createPlugin(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
|
||||
if err := httputils.ParseForm(r); err != nil {
|
||||
return err
|
||||
|
|
|
@ -1412,6 +1412,10 @@ definitions:
|
|||
type: "array"
|
||||
items:
|
||||
$ref: "#/definitions/PluginDevice"
|
||||
PluginReference:
|
||||
description: "plugin remote reference used to push/pull the plugin"
|
||||
type: "string"
|
||||
x-nullable: false
|
||||
Config:
|
||||
description: "The config of a plugin."
|
||||
type: "object"
|
||||
|
|
|
@ -22,6 +22,9 @@ type Plugin struct {
|
|||
// Required: true
|
||||
Name string `json:"Name"`
|
||||
|
||||
// plugin remote reference used to push/pull the plugin
|
||||
PluginReference string `json:"PluginReference,omitempty"`
|
||||
|
||||
// settings
|
||||
// Required: true
|
||||
Settings PluginSettings `json:"Settings"`
|
||||
|
|
|
@ -85,3 +85,8 @@ func (c *pluginContext) Enabled() bool {
|
|||
c.AddHeader(enabledHeader)
|
||||
return c.p.Enabled
|
||||
}
|
||||
|
||||
func (c *pluginContext) PluginReference() string {
|
||||
c.AddHeader(imageHeader)
|
||||
return c.p.PluginReference
|
||||
}
|
||||
|
|
|
@ -150,8 +150,8 @@ func TestPluginContextWriteJSON(t *testing.T) {
|
|||
{ID: "pluginID2", Name: "foobar_bar"},
|
||||
}
|
||||
expectedJSONs := []map[string]interface{}{
|
||||
{"Description": "", "Enabled": false, "ID": "pluginID1", "Name": "foobar_baz"},
|
||||
{"Description": "", "Enabled": false, "ID": "pluginID2", "Name": "foobar_bar"},
|
||||
{"Description": "", "Enabled": false, "ID": "pluginID1", "Name": "foobar_baz", "PluginReference": ""},
|
||||
{"Description": "", "Enabled": false, "ID": "pluginID2", "Name": "foobar_bar", "PluginReference": ""},
|
||||
}
|
||||
|
||||
out := bytes.NewBufferString("")
|
||||
|
|
|
@ -25,6 +25,7 @@ func NewPluginCommand(dockerCli *command.DockerCli) *cobra.Command {
|
|||
newSetCommand(dockerCli),
|
||||
newPushCommand(dockerCli),
|
||||
newCreateCommand(dockerCli),
|
||||
newUpgradeCommand(dockerCli),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
|
|
@ -15,15 +15,22 @@ import (
|
|||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type pluginOptions struct {
|
||||
name string
|
||||
alias string
|
||||
grantPerms bool
|
||||
disable bool
|
||||
args []string
|
||||
remote string
|
||||
localName string
|
||||
grantPerms bool
|
||||
disable bool
|
||||
args []string
|
||||
skipRemoteCheck bool
|
||||
}
|
||||
|
||||
func loadPullFlags(opts *pluginOptions, flags *pflag.FlagSet) {
|
||||
flags.BoolVar(&opts.grantPerms, "grant-all-permissions", false, "Grant all permissions necessary to run the plugin")
|
||||
command.AddTrustVerificationFlags(flags)
|
||||
}
|
||||
|
||||
func newInstallCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
|
@ -33,7 +40,7 @@ func newInstallCommand(dockerCli *command.DockerCli) *cobra.Command {
|
|||
Short: "Install a plugin",
|
||||
Args: cli.RequiresMinArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
options.name = args[0]
|
||||
options.remote = args[0]
|
||||
if len(args) > 1 {
|
||||
options.args = args[1:]
|
||||
}
|
||||
|
@ -42,12 +49,9 @@ func newInstallCommand(dockerCli *command.DockerCli) *cobra.Command {
|
|||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVar(&options.grantPerms, "grant-all-permissions", false, "Grant all permissions necessary to run the plugin")
|
||||
loadPullFlags(&options, flags)
|
||||
flags.BoolVar(&options.disable, "disable", false, "Do not enable the plugin on install")
|
||||
flags.StringVar(&options.alias, "alias", "", "Local name for plugin")
|
||||
|
||||
command.AddTrustVerificationFlags(flags)
|
||||
|
||||
flags.StringVar(&options.localName, "alias", "", "Local name for plugin")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
@ -83,49 +87,33 @@ func newRegistryService() registry.Service {
|
|||
}
|
||||
}
|
||||
|
||||
func runInstall(dockerCli *command.DockerCli, opts pluginOptions) error {
|
||||
func buildPullConfig(ctx context.Context, dockerCli *command.DockerCli, opts pluginOptions, cmdName string) (types.PluginInstallOptions, error) {
|
||||
// Names with both tag and digest will be treated by the daemon
|
||||
// as a pull by digest with an alias for the tag
|
||||
// (if no alias is provided).
|
||||
ref, err := reference.ParseNormalizedNamed(opts.name)
|
||||
// as a pull by digest with a local name for the tag
|
||||
// (if no local name is provided).
|
||||
ref, err := reference.ParseNormalizedNamed(opts.remote)
|
||||
if err != nil {
|
||||
return err
|
||||
return types.PluginInstallOptions{}, err
|
||||
}
|
||||
|
||||
alias := ""
|
||||
if opts.alias != "" {
|
||||
aref, err := reference.ParseNormalizedNamed(opts.alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := aref.(reference.Canonical); ok {
|
||||
return fmt.Errorf("invalid name: %s", opts.alias)
|
||||
}
|
||||
alias = reference.FamiliarString(reference.EnsureTagged(aref))
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
repoInfo, err := registry.ParseRepositoryInfo(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
return types.PluginInstallOptions{}, err
|
||||
}
|
||||
|
||||
remote := ref.String()
|
||||
|
||||
_, isCanonical := ref.(reference.Canonical)
|
||||
if command.IsTrusted() && !isCanonical {
|
||||
if alias == "" {
|
||||
alias = reference.FamiliarString(ref)
|
||||
}
|
||||
|
||||
nt, ok := ref.(reference.NamedTagged)
|
||||
if !ok {
|
||||
nt = reference.EnsureTagged(ref)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
trusted, err := image.TrustedReference(ctx, dockerCli, nt, newRegistryService())
|
||||
if err != nil {
|
||||
return err
|
||||
return types.PluginInstallOptions{}, err
|
||||
}
|
||||
remote = reference.FamiliarString(trusted)
|
||||
}
|
||||
|
@ -134,23 +122,42 @@ func runInstall(dockerCli *command.DockerCli, opts pluginOptions) error {
|
|||
|
||||
encodedAuth, err := command.EncodeAuthToBase64(authConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
return types.PluginInstallOptions{}, err
|
||||
}
|
||||
|
||||
registryAuthFunc := command.RegistryAuthenticationPrivilegedFunc(dockerCli, repoInfo.Index, "plugin install")
|
||||
registryAuthFunc := command.RegistryAuthenticationPrivilegedFunc(dockerCli, repoInfo.Index, cmdName)
|
||||
|
||||
options := types.PluginInstallOptions{
|
||||
RegistryAuth: encodedAuth,
|
||||
RemoteRef: remote,
|
||||
Disabled: opts.disable,
|
||||
AcceptAllPermissions: opts.grantPerms,
|
||||
AcceptPermissionsFunc: acceptPrivileges(dockerCli, opts.name),
|
||||
AcceptPermissionsFunc: acceptPrivileges(dockerCli, opts.remote),
|
||||
// TODO: Rename PrivilegeFunc, it has nothing to do with privileges
|
||||
PrivilegeFunc: registryAuthFunc,
|
||||
Args: opts.args,
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
responseBody, err := dockerCli.Client().PluginInstall(ctx, alias, options)
|
||||
func runInstall(dockerCli *command.DockerCli, opts pluginOptions) error {
|
||||
var localName string
|
||||
if opts.localName != "" {
|
||||
aref, err := reference.ParseNormalizedNamed(opts.localName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := aref.(reference.Canonical); ok {
|
||||
return fmt.Errorf("invalid name: %s", opts.localName)
|
||||
}
|
||||
localName = reference.FamiliarString(reference.EnsureTagged(aref))
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
options, err := buildPullConfig(ctx, dockerCli, opts, "plugin install")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
responseBody, err := dockerCli.Client().PluginInstall(ctx, localName, options)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "target is image") {
|
||||
return errors.New(err.Error() + " - Use `docker image pull`")
|
||||
|
@ -161,7 +168,7 @@ func runInstall(dockerCli *command.DockerCli, opts pluginOptions) error {
|
|||
if err := jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(dockerCli.Out(), "Installed plugin %s\n", opts.name) // todo: return proper values from the API for this result
|
||||
fmt.Fprintf(dockerCli.Out(), "Installed plugin %s\n", opts.remote) // todo: return proper values from the API for this result
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
100
cli/command/plugin/upgrade.go
Normal file
100
cli/command/plugin/upgrade.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/cli"
|
||||
"github.com/docker/docker/cli/command"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/docker/reference"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newUpgradeCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
var options pluginOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "upgrade [OPTIONS] PLUGIN [REMOTE]",
|
||||
Short: "Upgrade an existing plugin",
|
||||
Args: cli.RequiresRangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
options.localName = args[0]
|
||||
if len(args) == 2 {
|
||||
options.remote = args[1]
|
||||
}
|
||||
return runUpgrade(dockerCli, options)
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
loadPullFlags(&options, flags)
|
||||
flags.BoolVar(&options.skipRemoteCheck, "skip-remote-check", false, "Do not check if specified remote plugin matches existing plugin image")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runUpgrade(dockerCli *command.DockerCli, opts pluginOptions) error {
|
||||
ctx := context.Background()
|
||||
p, _, err := dockerCli.Client().PluginInspectWithRaw(ctx, opts.localName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading plugin data: %v", err)
|
||||
}
|
||||
|
||||
if p.Enabled {
|
||||
return fmt.Errorf("the plugin must be disabled before upgrading")
|
||||
}
|
||||
|
||||
opts.localName = p.Name
|
||||
if opts.remote == "" {
|
||||
opts.remote = p.PluginReference
|
||||
}
|
||||
remote, err := reference.ParseNamed(opts.remote)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error parsing remote upgrade image reference")
|
||||
}
|
||||
remote = reference.WithDefaultTag(remote)
|
||||
|
||||
old, err := reference.ParseNamed(p.PluginReference)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error parsing current image reference")
|
||||
}
|
||||
old = reference.WithDefaultTag(old)
|
||||
|
||||
fmt.Fprintf(dockerCli.Out(), "Upgrading plugin %s from %s to %s\n", p.Name, old, remote)
|
||||
if !opts.skipRemoteCheck && remote.String() != old.String() {
|
||||
_, err := fmt.Fprint(dockerCli.Out(), "Plugin images do not match, are you sure? ")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error writing to stdout")
|
||||
}
|
||||
|
||||
rdr := bufio.NewReader(dockerCli.In())
|
||||
line, _, err := rdr.ReadLine()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error reading from stdin")
|
||||
}
|
||||
if strings.ToLower(string(line)) != "y" {
|
||||
return errors.New("canceling upgrade request")
|
||||
}
|
||||
}
|
||||
|
||||
options, err := buildPullConfig(ctx, dockerCli, opts, "plugin upgrade")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responseBody, err := dockerCli.Client().PluginUpgrade(ctx, opts.localName, options)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "target is image") {
|
||||
return errors.New(err.Error() + " - Use `docker image pull`")
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer responseBody.Close()
|
||||
if err := jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(dockerCli.Out(), "Upgraded plugin %s to %s\n", opts.localName, opts.remote) // todo: return proper values from the API for this result
|
||||
return nil
|
||||
}
|
|
@ -113,6 +113,7 @@ type PluginAPIClient interface {
|
|||
PluginEnable(ctx context.Context, name string, options types.PluginEnableOptions) error
|
||||
PluginDisable(ctx context.Context, name string, options types.PluginDisableOptions) error
|
||||
PluginInstall(ctx context.Context, name string, options types.PluginInstallOptions) (io.ReadCloser, error)
|
||||
PluginUpgrade(ctx context.Context, name string, options types.PluginInstallOptions) (io.ReadCloser, error)
|
||||
PluginPush(ctx context.Context, name string, registryAuth string) (io.ReadCloser, error)
|
||||
PluginSet(ctx context.Context, name string, args []string) error
|
||||
PluginInspectWithRaw(ctx context.Context, name string) (*types.Plugin, []byte, error)
|
||||
|
|
|
@ -20,43 +20,15 @@ func (cli *Client) PluginInstall(ctx context.Context, name string, options types
|
|||
}
|
||||
query.Set("remote", options.RemoteRef)
|
||||
|
||||
resp, err := cli.tryPluginPrivileges(ctx, query, options.RegistryAuth)
|
||||
if resp.statusCode == http.StatusUnauthorized && options.PrivilegeFunc != nil {
|
||||
// todo: do inspect before to check existing name before checking privileges
|
||||
newAuthHeader, privilegeErr := options.PrivilegeFunc()
|
||||
if privilegeErr != nil {
|
||||
ensureReaderClosed(resp)
|
||||
return nil, privilegeErr
|
||||
}
|
||||
options.RegistryAuth = newAuthHeader
|
||||
resp, err = cli.tryPluginPrivileges(ctx, query, options.RegistryAuth)
|
||||
}
|
||||
privileges, err := cli.checkPluginPermissions(ctx, query, options)
|
||||
if err != nil {
|
||||
ensureReaderClosed(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var privileges types.PluginPrivileges
|
||||
if err := json.NewDecoder(resp.body).Decode(&privileges); err != nil {
|
||||
ensureReaderClosed(resp)
|
||||
return nil, err
|
||||
}
|
||||
ensureReaderClosed(resp)
|
||||
|
||||
if !options.AcceptAllPermissions && options.AcceptPermissionsFunc != nil && len(privileges) > 0 {
|
||||
accept, err := options.AcceptPermissionsFunc(privileges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !accept {
|
||||
return nil, pluginPermissionDenied{options.RemoteRef}
|
||||
}
|
||||
}
|
||||
|
||||
// set name for plugin pull, if empty should default to remote reference
|
||||
query.Set("name", name)
|
||||
|
||||
resp, err = cli.tryPluginPull(ctx, query, privileges, options.RegistryAuth)
|
||||
resp, err := cli.tryPluginPull(ctx, query, privileges, options.RegistryAuth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -103,3 +75,39 @@ func (cli *Client) tryPluginPull(ctx context.Context, query url.Values, privileg
|
|||
headers := map[string][]string{"X-Registry-Auth": {registryAuth}}
|
||||
return cli.post(ctx, "/plugins/pull", query, privileges, headers)
|
||||
}
|
||||
|
||||
func (cli *Client) checkPluginPermissions(ctx context.Context, query url.Values, options types.PluginInstallOptions) (types.PluginPrivileges, error) {
|
||||
resp, err := cli.tryPluginPrivileges(ctx, query, options.RegistryAuth)
|
||||
if resp.statusCode == http.StatusUnauthorized && options.PrivilegeFunc != nil {
|
||||
// todo: do inspect before to check existing name before checking privileges
|
||||
newAuthHeader, privilegeErr := options.PrivilegeFunc()
|
||||
if privilegeErr != nil {
|
||||
ensureReaderClosed(resp)
|
||||
return nil, privilegeErr
|
||||
}
|
||||
options.RegistryAuth = newAuthHeader
|
||||
resp, err = cli.tryPluginPrivileges(ctx, query, options.RegistryAuth)
|
||||
}
|
||||
if err != nil {
|
||||
ensureReaderClosed(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var privileges types.PluginPrivileges
|
||||
if err := json.NewDecoder(resp.body).Decode(&privileges); err != nil {
|
||||
ensureReaderClosed(resp)
|
||||
return nil, err
|
||||
}
|
||||
ensureReaderClosed(resp)
|
||||
|
||||
if !options.AcceptAllPermissions && options.AcceptPermissionsFunc != nil && len(privileges) > 0 {
|
||||
accept, err := options.AcceptPermissionsFunc(privileges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !accept {
|
||||
return nil, pluginPermissionDenied{options.RemoteRef}
|
||||
}
|
||||
}
|
||||
return privileges, nil
|
||||
}
|
||||
|
|
37
client/plugin_upgrade.go
Normal file
37
client/plugin_upgrade.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// PluginUpgrade upgrades a plugin
|
||||
func (cli *Client) PluginUpgrade(ctx context.Context, name string, options types.PluginInstallOptions) (rc io.ReadCloser, err error) {
|
||||
query := url.Values{}
|
||||
if _, err := reference.ParseNamed(options.RemoteRef); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid remote reference")
|
||||
}
|
||||
query.Set("remote", options.RemoteRef)
|
||||
|
||||
privileges, err := cli.checkPluginPermissions(ctx, query, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := cli.tryPluginUpgrade(ctx, query, privileges, name, options.RegistryAuth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.body, nil
|
||||
}
|
||||
|
||||
func (cli *Client) tryPluginUpgrade(ctx context.Context, query url.Values, privileges types.PluginPrivileges, name, registryAuth string) (serverResponse, error) {
|
||||
headers := map[string][]string{"X-Registry-Auth": {registryAuth}}
|
||||
return cli.post(ctx, fmt.Sprintf("/plugins/%s/upgrade", name), query, privileges, headers)
|
||||
}
|
|
@ -58,3 +58,4 @@ The plugin can subsequently be enabled for local use or pushed to the public reg
|
|||
* [plugin push](plugin_push.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
|
@ -63,3 +63,4 @@ ID NAME TAG DESCRIP
|
|||
* [plugin push](plugin_push.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
|
@ -62,3 +62,4 @@ ID NAME TAG DESCRIP
|
|||
* [plugin push](plugin_push.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
|
@ -37,6 +37,7 @@ $ docker plugin inspect tiborvass/sample-volume-plugin:latest
|
|||
{
|
||||
"Id": "8c74c978c434745c3ade82f1bc0acf38d04990eaf494fa507c16d9f1daa99c21",
|
||||
"Name": "tiborvass/sample-volume-plugin:latest",
|
||||
"PluginReference": "tiborvas/sample-volume-plugin:latest",
|
||||
"Enabled": true,
|
||||
"Config": {
|
||||
"Mounts": [
|
||||
|
@ -160,3 +161,4 @@ $ docker plugin inspect -f '{{.Id}}' tiborvass/sample-volume-plugin:latest
|
|||
* [plugin push](plugin_push.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
|
@ -69,3 +69,4 @@ ID NAME TAG DESCRIPTION
|
|||
* [plugin push](plugin_push.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
|
@ -83,10 +83,11 @@ Valid placeholders for the Go template are listed below:
|
|||
|
||||
Placeholder | Description
|
||||
---------------|------------------------------------------------------------------------------------------
|
||||
`.ID` | Plugin ID
|
||||
`.Name` | Plugin name
|
||||
`.Description` | Plugin description
|
||||
`.Enabled` | Whether plugin is enabled or not
|
||||
`.ID` | Plugin ID
|
||||
`.Name` | Plugin name
|
||||
`.Description` | Plugin description
|
||||
`.Enabled` | Whether plugin is enabled or not
|
||||
`.PluginReference` | The reference used to push/pull from a registry
|
||||
|
||||
When using the `--format` option, the `plugin ls` command will either
|
||||
output the data exactly as the template declares or, when using the
|
||||
|
@ -111,3 +112,4 @@ $ docker plugin ls --format "{{.ID}}: {{.Name}}"
|
|||
* [plugin push](plugin_push.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
|
@ -48,3 +48,4 @@ $ docker plugin push user/plugin
|
|||
* [plugin ls](plugin_ls.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
|
@ -53,3 +53,4 @@ tiborvass/sample-volume-plugin
|
|||
* [plugin ls](plugin_ls.md)
|
||||
* [plugin push](plugin_push.md)
|
||||
* [plugin set](plugin_set.md)
|
||||
* [plugin upgrade](plugin_upgrade.md)
|
||||
|
|
84
docs/reference/commandline/plugin_upgrade.md
Normal file
84
docs/reference/commandline/plugin_upgrade.md
Normal file
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
title: "plugin upgrade"
|
||||
description: "the plugin upgrade command description and usage"
|
||||
keywords: "plugin, upgrade"
|
||||
---
|
||||
|
||||
<!-- This file is maintained within the docker/docker Github
|
||||
repository at https://github.com/docker/docker/. Make all
|
||||
pull requests against that repo. If you see this file in
|
||||
another repository, consider it read-only there, as it will
|
||||
periodically be overwritten by the definitive file. Pull
|
||||
requests which include edits to this file in other repositories
|
||||
will be rejected.
|
||||
-->
|
||||
|
||||
# plugin upgrade
|
||||
|
||||
```markdown
|
||||
Usage: docker plugin upgrade [OPTIONS] PLUGIN [REMOTE]
|
||||
|
||||
Upgrade a plugin
|
||||
|
||||
Options:
|
||||
--disable-content-trust Skip image verification (default true)
|
||||
--grant-all-permissions Grant all permissions necessary to run the plugin
|
||||
--help Print usage
|
||||
--skip-remote-check Do not check if specified remote plugin matches existing plugin image
|
||||
```
|
||||
|
||||
Upgrades an existing plugin to the specified remote plugin image. If no remote
|
||||
is specified, Docker will re-pull the current image and use the updated version.
|
||||
All existing references to the plugin will continue to work.
|
||||
The plugin must be disabled before running the upgrade.
|
||||
|
||||
The following example installs `vieus/sshfs` plugin, uses it to create and use
|
||||
a volume, then upgrades the plugin.
|
||||
|
||||
```bash
|
||||
$ docker plugin install vieux/sshfs DEBUG=1
|
||||
|
||||
Plugin "vieux/sshfs:next" is requesting the following privileges:
|
||||
- network: [host]
|
||||
- device: [/dev/fuse]
|
||||
- capabilities: [CAP_SYS_ADMIN]
|
||||
Do you grant the above permissions? [y/N] y
|
||||
vieux/sshfs:next
|
||||
|
||||
$ docker volume create -d vieux/sshfs:next -o sshcmd=root@1.2.3.4:/tmp/shared -o password=XXX sshvolume
|
||||
sshvolume
|
||||
$ docker run -it -v sshvolume:/data alpine sh -c "touch /data/hello"
|
||||
$ docker plugin disable -f vieux/sshfs:next
|
||||
viex/sshfs:next
|
||||
|
||||
# Here docker volume ls doesn't show 'sshfsvolume', since the plugin is disabled
|
||||
$ docker volume ls
|
||||
DRIVER VOLUME NAME
|
||||
|
||||
$ docker plugin upgrade vieux/sshfs:next vieux/sshfs:next
|
||||
Plugin "vieux/sshfs:next" is requesting the following privileges:
|
||||
- network: [host]
|
||||
- device: [/dev/fuse]
|
||||
- capabilities: [CAP_SYS_ADMIN]
|
||||
Do you grant the above permissions? [y/N] y
|
||||
Upgrade plugin vieux/sshfs:next to vieux/sshfs:next
|
||||
$ docker plugin enable vieux/sshfs:next
|
||||
viex/sshfs:next
|
||||
$ docker volume ls
|
||||
DRIVER VOLUME NAME
|
||||
viuex/sshfs:next sshvolume
|
||||
$ docker run -it -v sshvolume:/data alpine sh -c "ls /data"
|
||||
hello
|
||||
```
|
||||
|
||||
## Related information
|
||||
|
||||
* [plugin create](plugin_create.md)
|
||||
* [plugin disable](plugin_disable.md)
|
||||
* [plugin enable](plugin_enable.md)
|
||||
* [plugin inspect](plugin_inspect.md)
|
||||
* [plugin install](plugin_install.md)
|
||||
* [plugin ls](plugin_ls.md)
|
||||
* [plugin push](plugin_push.md)
|
||||
* [plugin rm](plugin_rm.md)
|
||||
* [plugin set](plugin_set.md)
|
|
@ -427,3 +427,30 @@ enabled: true`, id, pNameWithTag)
|
|||
out, _ = dockerCmd(c, "--config", config, "plugin", "ls", "--no-trunc")
|
||||
c.Assert(strings.TrimSpace(out), checker.Contains, expectedOutput)
|
||||
}
|
||||
|
||||
func (s *DockerSuite) TestPluginUpgrade(c *check.C) {
|
||||
testRequires(c, DaemonIsLinux, Network, SameHostDaemon, IsAmd64)
|
||||
plugin := "cpuguy83/docker-volume-driver-plugin-local:latest"
|
||||
pluginV2 := "cpuguy83/docker-volume-driver-plugin-local:v2"
|
||||
|
||||
dockerCmd(c, "plugin", "install", "--grant-all-permissions", plugin)
|
||||
out, _, err := dockerCmdWithError("plugin", "upgrade", "--grant-all-permissions", plugin, pluginV2)
|
||||
c.Assert(err, checker.NotNil, check.Commentf(out))
|
||||
c.Assert(out, checker.Contains, "disabled before upgrading")
|
||||
|
||||
out, _ = dockerCmd(c, "plugin", "inspect", "--format={{.ID}}", plugin)
|
||||
id := strings.TrimSpace(out)
|
||||
|
||||
// make sure "v2" does not exists
|
||||
_, err = os.Stat(filepath.Join(testEnv.DockerBasePath(), "plugins", id, "rootfs", "v2"))
|
||||
c.Assert(os.IsNotExist(err), checker.True, check.Commentf(out))
|
||||
|
||||
dockerCmd(c, "plugin", "disable", plugin)
|
||||
dockerCmd(c, "plugin", "upgrade", "--grant-all-permissions", "--skip-remote-check", plugin, pluginV2)
|
||||
|
||||
// make sure "v2" file exists
|
||||
_, err = os.Stat(filepath.Join(testEnv.DockerBasePath(), "plugins", id, "rootfs", "v2"))
|
||||
c.Assert(err, checker.IsNil)
|
||||
|
||||
dockerCmd(c, "plugin", "enable", plugin)
|
||||
}
|
||||
|
|
|
@ -215,6 +215,60 @@ func (pm *Manager) Privileges(ctx context.Context, ref reference.Named, metaHead
|
|||
return computePrivileges(config)
|
||||
}
|
||||
|
||||
// Upgrade upgrades a plugin
|
||||
func (pm *Manager) Upgrade(ctx context.Context, ref reference.Named, name string, metaHeader http.Header, authConfig *types.AuthConfig, privileges types.PluginPrivileges, outStream io.Writer) (err error) {
|
||||
p, err := pm.config.Store.GetV2Plugin(name)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "plugin must be installed before upgrading")
|
||||
}
|
||||
|
||||
if p.IsEnabled() {
|
||||
return fmt.Errorf("plugin must be disabled before upgrading")
|
||||
}
|
||||
|
||||
pm.muGC.RLock()
|
||||
defer pm.muGC.RUnlock()
|
||||
|
||||
// revalidate because Pull is public
|
||||
nameref, err := reference.ParseNamed(name)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to parse %q", name)
|
||||
}
|
||||
name = reference.WithDefaultTag(nameref).String()
|
||||
|
||||
tmpRootFSDir, err := ioutil.TempDir(pm.tmpDir(), ".rootfs")
|
||||
defer os.RemoveAll(tmpRootFSDir)
|
||||
|
||||
dm := &downloadManager{
|
||||
tmpDir: tmpRootFSDir,
|
||||
blobStore: pm.blobStore,
|
||||
}
|
||||
|
||||
pluginPullConfig := &distribution.ImagePullConfig{
|
||||
Config: distribution.Config{
|
||||
MetaHeaders: metaHeader,
|
||||
AuthConfig: authConfig,
|
||||
RegistryService: pm.config.RegistryService,
|
||||
ImageEventLogger: pm.config.LogPluginEvent,
|
||||
ImageStore: dm,
|
||||
},
|
||||
DownloadManager: dm, // todo: reevaluate if possible to substitute distribution/xfer dependencies instead
|
||||
Schema2Types: distribution.PluginTypes,
|
||||
}
|
||||
|
||||
err = pm.pull(ctx, ref, pluginPullConfig, outStream)
|
||||
if err != nil {
|
||||
go pm.GC()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pm.upgradePlugin(p, dm.configDigest, dm.blobs, tmpRootFSDir, &privileges); err != nil {
|
||||
return err
|
||||
}
|
||||
p.PluginObj.PluginReference = ref.String()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pull pulls a plugin, check if the correct privileges are provided and install the plugin.
|
||||
func (pm *Manager) Pull(ctx context.Context, ref reference.Named, name string, metaHeader http.Header, authConfig *types.AuthConfig, privileges types.PluginPrivileges, outStream io.Writer) (err error) {
|
||||
pm.muGC.RLock()
|
||||
|
@ -257,9 +311,11 @@ func (pm *Manager) Pull(ctx context.Context, ref reference.Named, name string, m
|
|||
return err
|
||||
}
|
||||
|
||||
if _, err := pm.createPlugin(name, dm.configDigest, dm.blobs, tmpRootFSDir, &privileges); err != nil {
|
||||
p, err := pm.createPlugin(name, dm.configDigest, dm.blobs, tmpRootFSDir, &privileges)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PluginObj.PluginReference = ref.String()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -573,7 +629,8 @@ func (pm *Manager) CreateFromContext(ctx context.Context, tarCtx io.ReadCloser,
|
|||
if _, ok := ref.(reference.Canonical); ok {
|
||||
return errors.Errorf("canonical references are not permitted")
|
||||
}
|
||||
name := reference.WithDefaultTag(ref).String()
|
||||
taggedRef := reference.WithDefaultTag(ref)
|
||||
name := taggedRef.String()
|
||||
|
||||
if err := pm.config.Store.validateName(name); err != nil { // fast check, real check is in createPlugin()
|
||||
return err
|
||||
|
@ -655,6 +712,7 @@ func (pm *Manager) CreateFromContext(ctx context.Context, tarCtx io.ReadCloser,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PluginObj.PluginReference = taggedRef.String()
|
||||
|
||||
pm.config.LogPluginEvent(p.PluginObj.ID, name, "create")
|
||||
|
||||
|
|
|
@ -40,6 +40,11 @@ func (pm *Manager) Pull(ctx context.Context, ref reference.Named, name string, m
|
|||
return errNotSupported
|
||||
}
|
||||
|
||||
// Upgrade pulls a plugin, check if the correct privileges are provided and install the plugin.
|
||||
func (pm *Manager) Upgrade(ctx context.Context, ref reference.Named, name string, metaHeader http.Header, authConfig *types.AuthConfig, privileges types.PluginPrivileges, outStream io.Writer) error {
|
||||
return errNotSupported
|
||||
}
|
||||
|
||||
// List displays the list of plugins and associated metadata.
|
||||
func (pm *Manager) List(pluginFilters filters.Args) ([]types.Plugin, error) {
|
||||
return nil, errNotSupported
|
||||
|
|
|
@ -149,37 +149,91 @@ func (pm *Manager) Shutdown() {
|
|||
}
|
||||
}
|
||||
|
||||
// createPlugin creates a new plugin. take lock before calling.
|
||||
func (pm *Manager) createPlugin(name string, configDigest digest.Digest, blobsums []digest.Digest, rootFSDir string, privileges *types.PluginPrivileges) (p *v2.Plugin, err error) {
|
||||
if err := pm.config.Store.validateName(name); err != nil { // todo: this check is wrong. remove store
|
||||
return nil, err
|
||||
func (pm *Manager) upgradePlugin(p *v2.Plugin, configDigest digest.Digest, blobsums []digest.Digest, tmpRootFSDir string, privileges *types.PluginPrivileges) (err error) {
|
||||
config, err := pm.setupNewPlugin(configDigest, blobsums, privileges)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pdir := filepath.Join(pm.config.Root, p.PluginObj.ID)
|
||||
orig := filepath.Join(pdir, "rootfs")
|
||||
backup := orig + "-old"
|
||||
if err := os.Rename(orig, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
if rmErr := os.RemoveAll(orig); rmErr != nil && !os.IsNotExist(rmErr) {
|
||||
logrus.WithError(rmErr).WithField("dir", backup).Error("error cleaning up after failed upgrade")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Rename(backup, orig); err != nil {
|
||||
err = errors.Wrap(err, "error restoring old plugin root on upgrade failure")
|
||||
}
|
||||
if rmErr := os.RemoveAll(tmpRootFSDir); rmErr != nil && !os.IsNotExist(rmErr) {
|
||||
logrus.WithError(rmErr).WithField("plugin", p.Name()).Errorf("error cleaning up plugin upgrade dir: %s", tmpRootFSDir)
|
||||
}
|
||||
} else {
|
||||
if rmErr := os.RemoveAll(backup); rmErr != nil && !os.IsNotExist(rmErr) {
|
||||
logrus.WithError(rmErr).WithField("dir", backup).Error("error cleaning up old plugin root after successful upgrade")
|
||||
}
|
||||
|
||||
p.Config = configDigest
|
||||
p.Blobsums = blobsums
|
||||
}
|
||||
}()
|
||||
|
||||
if err := os.Rename(tmpRootFSDir, orig); err != nil {
|
||||
return errors.Wrap(err, "error upgrading")
|
||||
}
|
||||
|
||||
p.PluginObj.Config = config
|
||||
err = pm.save(p)
|
||||
return errors.Wrap(err, "error saving upgraded plugin config")
|
||||
}
|
||||
|
||||
func (pm *Manager) setupNewPlugin(configDigest digest.Digest, blobsums []digest.Digest, privileges *types.PluginPrivileges) (types.PluginConfig, error) {
|
||||
configRC, err := pm.blobStore.Get(configDigest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return types.PluginConfig{}, err
|
||||
}
|
||||
defer configRC.Close()
|
||||
|
||||
var config types.PluginConfig
|
||||
dec := json.NewDecoder(configRC)
|
||||
if err := dec.Decode(&config); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse config")
|
||||
return types.PluginConfig{}, errors.Wrapf(err, "failed to parse config")
|
||||
}
|
||||
if dec.More() {
|
||||
return nil, errors.New("invalid config json")
|
||||
return types.PluginConfig{}, errors.New("invalid config json")
|
||||
}
|
||||
|
||||
requiredPrivileges, err := computePrivileges(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return types.PluginConfig{}, err
|
||||
}
|
||||
if privileges != nil {
|
||||
if err := validatePrivileges(requiredPrivileges, *privileges); err != nil {
|
||||
return nil, err
|
||||
return types.PluginConfig{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// createPlugin creates a new plugin. take lock before calling.
|
||||
func (pm *Manager) createPlugin(name string, configDigest digest.Digest, blobsums []digest.Digest, rootFSDir string, privileges *types.PluginPrivileges) (p *v2.Plugin, err error) {
|
||||
if err := pm.config.Store.validateName(name); err != nil { // todo: this check is wrong. remove store
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := pm.setupNewPlugin(configDigest, blobsums, privileges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p = &v2.Plugin{
|
||||
PluginObj: types.Plugin{
|
||||
Name: name,
|
||||
|
|
Loading…
Reference in a new issue