From 03c694973968f63743ed53cef83d0b7455695081 Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Sat, 28 Jan 2017 16:54:32 -0800 Subject: [PATCH] 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 --- api/server/router/plugin/backend.go | 1 + api/server/router/plugin/plugin.go | 1 + api/server/router/plugin/plugin_routes.go | 103 +++++++++++++------ api/swagger.yaml | 4 + api/types/plugin.go | 3 + cli/command/formatter/plugin.go | 5 + cli/command/formatter/plugin_test.go | 4 +- cli/command/plugin/cmd.go | 1 + cli/command/plugin/install.go | 89 ++++++++-------- cli/command/plugin/upgrade.go | 100 ++++++++++++++++++ client/interface.go | 1 + client/plugin_install.go | 68 ++++++------ client/plugin_upgrade.go | 37 +++++++ docs/reference/commandline/plugin_create.md | 1 + docs/reference/commandline/plugin_disable.md | 1 + docs/reference/commandline/plugin_enable.md | 1 + docs/reference/commandline/plugin_inspect.md | 2 + docs/reference/commandline/plugin_install.md | 1 + docs/reference/commandline/plugin_ls.md | 10 +- docs/reference/commandline/plugin_push.md | 1 + docs/reference/commandline/plugin_rm.md | 1 + docs/reference/commandline/plugin_upgrade.md | 84 +++++++++++++++ integration-cli/docker_cli_plugins_test.go | 27 +++++ plugin/backend_linux.go | 62 ++++++++++- plugin/backend_unsupported.go | 5 + plugin/manager_linux.go | 72 +++++++++++-- 26 files changed, 568 insertions(+), 117 deletions(-) create mode 100644 cli/command/plugin/upgrade.go create mode 100644 client/plugin_upgrade.go create mode 100644 docs/reference/commandline/plugin_upgrade.md diff --git a/api/server/router/plugin/backend.go b/api/server/router/plugin/backend.go index dadc169a26..f4ce9a5af5 100644 --- a/api/server/router/plugin/backend.go +++ b/api/server/router/plugin/backend.go @@ -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 } diff --git a/api/server/router/plugin/plugin.go b/api/server/router/plugin/plugin.go index 9aa82f338c..e4ea9e23bf 100644 --- a/api/server/router/plugin/plugin.go +++ b/api/server/router/plugin/plugin.go @@ -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), } diff --git a/api/server/router/plugin/plugin_routes.go b/api/server/router/plugin/plugin_routes.go index a0a1abb24a..07cd1f64ef 100644 --- a/api/server/router/plugin/plugin_routes.go +++ b/api/server/router/plugin/plugin_routes.go @@ -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 diff --git a/api/swagger.yaml b/api/swagger.yaml index 25f4d96da0..018597520f 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -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" diff --git a/api/types/plugin.go b/api/types/plugin.go index 46f47be26f..6cc7a23b02 100644 --- a/api/types/plugin.go +++ b/api/types/plugin.go @@ -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"` diff --git a/cli/command/formatter/plugin.go b/cli/command/formatter/plugin.go index 5f94714a6b..00bdf3d0f4 100644 --- a/cli/command/formatter/plugin.go +++ b/cli/command/formatter/plugin.go @@ -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 +} diff --git a/cli/command/formatter/plugin_test.go b/cli/command/formatter/plugin_test.go index 9ddbe11dff..a6c8f7e6c1 100644 --- a/cli/command/formatter/plugin_test.go +++ b/cli/command/formatter/plugin_test.go @@ -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("") diff --git a/cli/command/plugin/cmd.go b/cli/command/plugin/cmd.go index 2173943f89..92c990a975 100644 --- a/cli/command/plugin/cmd.go +++ b/cli/command/plugin/cmd.go @@ -25,6 +25,7 @@ func NewPluginCommand(dockerCli *command.DockerCli) *cobra.Command { newSetCommand(dockerCli), newPushCommand(dockerCli), newCreateCommand(dockerCli), + newUpgradeCommand(dockerCli), ) return cmd } diff --git a/cli/command/plugin/install.go b/cli/command/plugin/install.go index ebfe1f1eec..631917a07c 100644 --- a/cli/command/plugin/install.go +++ b/cli/command/plugin/install.go @@ -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 } diff --git a/cli/command/plugin/upgrade.go b/cli/command/plugin/upgrade.go new file mode 100644 index 0000000000..d212cd7e52 --- /dev/null +++ b/cli/command/plugin/upgrade.go @@ -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 +} diff --git a/client/interface.go b/client/interface.go index d30ba5f705..5823eed883 100644 --- a/client/interface.go +++ b/client/interface.go @@ -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) diff --git a/client/plugin_install.go b/client/plugin_install.go index b305780cfb..3217c4cf39 100644 --- a/client/plugin_install.go +++ b/client/plugin_install.go @@ -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 +} diff --git a/client/plugin_upgrade.go b/client/plugin_upgrade.go new file mode 100644 index 0000000000..95a4356b97 --- /dev/null +++ b/client/plugin_upgrade.go @@ -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) +} diff --git a/docs/reference/commandline/plugin_create.md b/docs/reference/commandline/plugin_create.md index df3b2cccc8..fa74d31c39 100644 --- a/docs/reference/commandline/plugin_create.md +++ b/docs/reference/commandline/plugin_create.md @@ -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) diff --git a/docs/reference/commandline/plugin_disable.md b/docs/reference/commandline/plugin_disable.md index e8d16c4253..451f1ace9c 100644 --- a/docs/reference/commandline/plugin_disable.md +++ b/docs/reference/commandline/plugin_disable.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) diff --git a/docs/reference/commandline/plugin_enable.md b/docs/reference/commandline/plugin_enable.md index 060592ef07..df8bee3af5 100644 --- a/docs/reference/commandline/plugin_enable.md +++ b/docs/reference/commandline/plugin_enable.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) diff --git a/docs/reference/commandline/plugin_inspect.md b/docs/reference/commandline/plugin_inspect.md index d40cd40e75..fdcc030c43 100644 --- a/docs/reference/commandline/plugin_inspect.md +++ b/docs/reference/commandline/plugin_inspect.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) diff --git a/docs/reference/commandline/plugin_install.md b/docs/reference/commandline/plugin_install.md index b94f78a912..4cba32cf8b 100644 --- a/docs/reference/commandline/plugin_install.md +++ b/docs/reference/commandline/plugin_install.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) diff --git a/docs/reference/commandline/plugin_ls.md b/docs/reference/commandline/plugin_ls.md index c018dec813..b0224a00f8 100644 --- a/docs/reference/commandline/plugin_ls.md +++ b/docs/reference/commandline/plugin_ls.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) diff --git a/docs/reference/commandline/plugin_push.md b/docs/reference/commandline/plugin_push.md index d6f326d6de..89a0a0bb0e 100644 --- a/docs/reference/commandline/plugin_push.md +++ b/docs/reference/commandline/plugin_push.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) diff --git a/docs/reference/commandline/plugin_rm.md b/docs/reference/commandline/plugin_rm.md index 31029324b6..323ce83f3c 100644 --- a/docs/reference/commandline/plugin_rm.md +++ b/docs/reference/commandline/plugin_rm.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) diff --git a/docs/reference/commandline/plugin_upgrade.md b/docs/reference/commandline/plugin_upgrade.md new file mode 100644 index 0000000000..20efc577aa --- /dev/null +++ b/docs/reference/commandline/plugin_upgrade.md @@ -0,0 +1,84 @@ +--- +title: "plugin upgrade" +description: "the plugin upgrade command description and usage" +keywords: "plugin, upgrade" +--- + + + +# 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) diff --git a/integration-cli/docker_cli_plugins_test.go b/integration-cli/docker_cli_plugins_test.go index e294fffd19..12900f90e4 100644 --- a/integration-cli/docker_cli_plugins_test.go +++ b/integration-cli/docker_cli_plugins_test.go @@ -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) +} diff --git a/plugin/backend_linux.go b/plugin/backend_linux.go index cc42058bc2..0933c097a2 100644 --- a/plugin/backend_linux.go +++ b/plugin/backend_linux.go @@ -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") diff --git a/plugin/backend_unsupported.go b/plugin/backend_unsupported.go index f9a4d5c6b6..35feb5cdca 100644 --- a/plugin/backend_unsupported.go +++ b/plugin/backend_unsupported.go @@ -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 diff --git a/plugin/manager_linux.go b/plugin/manager_linux.go index 458b6c4a26..b5f0b66d87 100644 --- a/plugin/manager_linux.go +++ b/plugin/manager_linux.go @@ -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,