The CLI currently hard-codes the schema-version for CLI plugins to "0.1.0", which doesn't allow us to expand the schema for plugins. As there's many plugins that we shipped already, we can't break compatibility until we reach 2.0.0, but we can expand the schema with non-breaking changes. This patch makes the validation more permissive to allow new schema versions <= 2.0.0. Note that existing CLIs will still invalidate such versions, so we cannot update the version until such CLIs are no longer expected to be used, but this patch lays the ground-work to open that option. Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
196 lines
5.8 KiB
Go
196 lines
5.8 KiB
Go
package manager
|
|
|
|
import (
|
|
"context"
|
|
"encoding"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/docker/cli/cli-plugins/metadata"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// Plugin represents a potential plugin with all it's metadata.
|
|
type Plugin struct {
|
|
metadata.Metadata
|
|
|
|
Name string `json:",omitempty"`
|
|
Path string `json:",omitempty"`
|
|
|
|
// Err is non-nil if the plugin failed one of the candidate tests.
|
|
Err error `json:",omitempty"`
|
|
|
|
// ShadowedPaths contains the paths of any other plugins which this plugin takes precedence over.
|
|
ShadowedPaths []string `json:",omitempty"`
|
|
}
|
|
|
|
// MarshalJSON implements [json.Marshaler] to handle marshaling the
|
|
// [Plugin.Err] field (Go doesn't marshal errors by default).
|
|
func (p *Plugin) MarshalJSON() ([]byte, error) {
|
|
type Alias Plugin // avoid recursion
|
|
|
|
cp := *p // shallow copy to avoid mutating original
|
|
|
|
if cp.Err != nil {
|
|
if _, ok := cp.Err.(encoding.TextMarshaler); !ok {
|
|
cp.Err = &pluginError{cp.Err}
|
|
}
|
|
}
|
|
|
|
return json.Marshal((*Alias)(&cp))
|
|
}
|
|
|
|
// pluginCandidate represents a possible plugin candidate, for mocking purposes.
|
|
type pluginCandidate interface {
|
|
Path() string
|
|
Metadata() ([]byte, error)
|
|
}
|
|
|
|
// newPlugin determines if the given candidate is valid and returns a
|
|
// Plugin. If the candidate fails one of the tests then `Plugin.Err`
|
|
// is set, and is always a `pluginError`, but the `Plugin` is still
|
|
// returned with no error. An error is only returned due to a
|
|
// non-recoverable error.
|
|
func newPlugin(c pluginCandidate, cmds []*cobra.Command) (Plugin, error) {
|
|
path := c.Path()
|
|
if path == "" {
|
|
return Plugin{}, errors.New("plugin candidate path cannot be empty")
|
|
}
|
|
|
|
// The candidate listing process should have skipped anything
|
|
// which would fail here, so there are all real errors.
|
|
fullname := filepath.Base(path)
|
|
if fullname == "." {
|
|
return Plugin{}, fmt.Errorf("unable to determine basename of plugin candidate %q", path)
|
|
}
|
|
var err error
|
|
if fullname, err = trimExeSuffix(fullname); err != nil {
|
|
return Plugin{}, fmt.Errorf("plugin candidate %q: %w", path, err)
|
|
}
|
|
if !strings.HasPrefix(fullname, metadata.NamePrefix) {
|
|
return Plugin{}, fmt.Errorf("plugin candidate %q: does not have %q prefix", path, metadata.NamePrefix)
|
|
}
|
|
|
|
p := Plugin{
|
|
Name: strings.TrimPrefix(fullname, metadata.NamePrefix),
|
|
Path: path,
|
|
}
|
|
|
|
// Now apply the candidate tests, so these update p.Err.
|
|
if !isValidPluginName(p.Name) {
|
|
p.Err = newPluginError("plugin candidate %q did not match %q", p.Name, pluginNameFormat)
|
|
return p, nil
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
// Ignore conflicts with commands which are
|
|
// just plugin stubs (i.e. from a previous
|
|
// call to AddPluginCommandStubs).
|
|
if IsPluginCommand(cmd) {
|
|
continue
|
|
}
|
|
if cmd.Name() == p.Name {
|
|
p.Err = newPluginError("plugin %q duplicates builtin command", p.Name)
|
|
return p, nil
|
|
}
|
|
if cmd.HasAlias(p.Name) {
|
|
p.Err = newPluginError("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name())
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
// We are supposed to check for relevant execute permissions here. Instead we rely on an attempt to execute.
|
|
meta, err := c.Metadata()
|
|
if err != nil {
|
|
p.Err = wrapAsPluginError(err, "failed to fetch metadata")
|
|
return p, nil
|
|
}
|
|
|
|
if err := json.Unmarshal(meta, &p.Metadata); err != nil {
|
|
p.Err = wrapAsPluginError(err, "invalid metadata")
|
|
return p, nil
|
|
}
|
|
if err := validateSchemaVersion(p.Metadata.SchemaVersion); err != nil {
|
|
p.Err = &pluginError{cause: err}
|
|
return p, nil
|
|
}
|
|
if p.Metadata.Vendor == "" {
|
|
p.Err = newPluginError("plugin metadata does not define a vendor")
|
|
return p, nil
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// validateSchemaVersion validates if the plugin's schemaVersion is supported.
|
|
//
|
|
// The current schema-version is "0.1.0", but we don't want to break compatibility
|
|
// until v2.0.0 of the schema version. Check for the major version to be < 2.0.0.
|
|
//
|
|
// Note that CLI versions before 28.4.1 may not support these versions as they were
|
|
// hard-coded to only accept "0.1.0".
|
|
func validateSchemaVersion(version string) error {
|
|
if version == "0.1.0" {
|
|
return nil
|
|
}
|
|
if version == "" {
|
|
return errors.New("plugin SchemaVersion version cannot be empty")
|
|
}
|
|
major, _, ok := strings.Cut(version, ".")
|
|
majorVersion, err := strconv.Atoi(major)
|
|
if !ok || err != nil {
|
|
return fmt.Errorf("plugin SchemaVersion %q has wrong format: must be <major>.<minor>.<patch>", version)
|
|
}
|
|
if majorVersion > 1 {
|
|
return fmt.Errorf("plugin SchemaVersion %q is not supported: must be lower than 2.0.0", version)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RunHook executes the plugin's hooks command
|
|
// and returns its unprocessed output.
|
|
func (p *Plugin) RunHook(ctx context.Context, hookData HookPluginData) ([]byte, error) {
|
|
hDataBytes, err := json.Marshal(hookData)
|
|
if err != nil {
|
|
return nil, wrapAsPluginError(err, "failed to marshall hook data")
|
|
}
|
|
|
|
pCmd := exec.CommandContext(ctx, p.Path, p.Name, metadata.HookSubcommandName, string(hDataBytes)) // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments"
|
|
pCmd.Env = os.Environ()
|
|
pCmd.Env = append(pCmd.Env, metadata.ReexecEnvvar+"="+os.Args[0])
|
|
hookCmdOutput, err := pCmd.Output()
|
|
if err != nil {
|
|
return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand")
|
|
}
|
|
|
|
return hookCmdOutput, nil
|
|
}
|
|
|
|
// pluginNameFormat is used as part of errors for invalid plugin-names.
|
|
// We should consider making this less technical ("must start with "a-z",
|
|
// and only consist of lowercase alphanumeric characters").
|
|
const pluginNameFormat = `^[a-z][a-z0-9]*$`
|
|
|
|
func isValidPluginName(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
// first character must be a-z
|
|
if c := s[0]; c < 'a' || c > 'z' {
|
|
return false
|
|
}
|
|
// followed by a-z or 0-9
|
|
for i := 1; i < len(s); i++ {
|
|
c := s[i]
|
|
if (c < 'a' || c > 'z') && (c < '0' || c > '9') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|