mirror of https://gitlab.com/gitlab-org/cli.git
refactor(plugins): use plugin metadata protocol
Plugins are now required to implement a --metadata flag that returns a JSON representation of the plugin's usage. For added security, the user now must use the plugin enable subcommand, before the plugin is recognized by glab. TODO: implement plugin parsing from plugin (metadata) directory TODO: implement checksum verification of the plugin a la direnv TODO: implement symlink verification - no symlinks allowed for plugins!
This commit is contained in:
parent
a4558a3499
commit
bd6a14d344
|
@ -20,7 +20,6 @@ import (
|
|||
"gitlab.com/gitlab-org/cli/commands/alias/expand"
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
||||
"gitlab.com/gitlab-org/cli/commands/help"
|
||||
"gitlab.com/gitlab-org/cli/commands/plugin"
|
||||
"gitlab.com/gitlab-org/cli/commands/update"
|
||||
"gitlab.com/gitlab-org/cli/internal/config"
|
||||
"gitlab.com/gitlab-org/cli/internal/run"
|
||||
|
@ -84,19 +83,19 @@ func main() {
|
|||
|
||||
rootCmd := commands.NewCmdRoot(cmdFactory, version, buildDate)
|
||||
|
||||
plugins, err := plugin.List()
|
||||
io := cmdFactory.IO
|
||||
if err != nil {
|
||||
io.Logf("%s failed to load plugins successfully", io.Color().DotWarnIcon())
|
||||
}
|
||||
|
||||
rootCmd.AddGroup(&cobra.Group{
|
||||
ID: "Plugins",
|
||||
})
|
||||
|
||||
for _, plugin := range plugins {
|
||||
rootCmd.AddCommand(plugin.NewCommand(cmdFactory))
|
||||
}
|
||||
// plugins, err := plugin.List()
|
||||
// io := cmdFactory.IO
|
||||
// if err != nil {
|
||||
// io.Logf("%s failed to load plugins successfully", io.Color().DotWarnIcon())
|
||||
// }
|
||||
//
|
||||
// rootCmd.AddGroup(&cobra.Group{
|
||||
// ID: "Plugins",
|
||||
// })
|
||||
//
|
||||
// for _, plugin := range plugins {
|
||||
// rootCmd.AddCommand(plugin.NewCommand(cmdFactory))
|
||||
// }
|
||||
|
||||
// Set Debug mode from config if not previously set by debugMode
|
||||
if !debug {
|
||||
|
|
|
@ -0,0 +1,236 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitlab.com/gitlab-org/cli/commands/cmdutils"
|
||||
"gitlab.com/gitlab-org/cli/internal/config"
|
||||
"gitlab.com/gitlab-org/cli/pkg/tableprinter"
|
||||
)
|
||||
|
||||
func Command(f *cmdutils.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Manage glab plugins",
|
||||
Long: "Extend the functionality of glab with plugins",
|
||||
}
|
||||
cmd.AddCommand(listCmd(f))
|
||||
cmd.AddCommand(enableCmd(f))
|
||||
cmd.AddCommand(disableCmd(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listCmd(f *cmdutils.Factory) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List enabled plugins",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := tableprinter.NewTablePrinter()
|
||||
p.MaxColWidth = 48
|
||||
p.Wrap = true
|
||||
|
||||
pluginDir := config.PluginDir()
|
||||
matches, err := filepath.Glob(filepath.Join(pluginDir, "*.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading plugins from plugin directory: %w", err)
|
||||
}
|
||||
|
||||
p.AddRow("Name", "Version", "Description", "Checksum")
|
||||
var data PluginMetadata
|
||||
for _, match := range matches {
|
||||
f, err := os.Open(match)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening plugin metadata file %s: %w", match, err)
|
||||
}
|
||||
if err := json.NewDecoder(f).Decode(&data); err != nil {
|
||||
return fmt.Errorf("reading plugin metadata: %w", err)
|
||||
}
|
||||
|
||||
p.AddRow(data.Name, data.Version, data.Description, data.Checksum)
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("closing plugin metadata file %s: %w", match, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(f.IO.StdOut, "\n%s\n", p)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func enableCmd(f *cmdutils.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "enable [plugin]",
|
||||
Short: "Enables a glab plugin",
|
||||
Long: "Plugin must start with 'glab-', include only alphanumeric and '-' characters, and be in your PATH",
|
||||
Example: "glab plugin enable ping",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
plugin := Plugin(args[0])
|
||||
return enable(cmd.Parent(), plugin)
|
||||
},
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
type PluginMetadata struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
func enable(rootCmd *cobra.Command, plugin Plugin) error {
|
||||
// --------------------------------------------------------------------
|
||||
// BEFORE MERGING THE FOLLOWING MUST BE DONE!!
|
||||
// --------------------------------------------------------------------
|
||||
//
|
||||
// 1. Plugins cannot override a core command! ✅
|
||||
// 2. Plugins must not symlink! =) We don't want someone enabling
|
||||
// glab-kube only to trigger some other malware.
|
||||
// 3. We need to establish a known checksum upon installation.
|
||||
// If the user updates the binary, then we disable the plugin
|
||||
// and ask the user to re-enable because it's been updated.
|
||||
// This is quite strict, but given the implications, it's
|
||||
// probably for the best. If this is too annoying, then
|
||||
// a compromise could be made where we allow-list plugins, or
|
||||
// change this so that we fully manage them (download and install).
|
||||
// This would allow use to enforce things like upstream
|
||||
// checksum or signature verification. This is really complex
|
||||
// for an initial iteration which is why the first one only
|
||||
// prompts for re-enablement.
|
||||
//
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// full-name vs pretty-name
|
||||
// --------------------------------------------------------------------
|
||||
// A plugin has two name variants - the full name and the pretty name.
|
||||
// The full name includes the "glab-" prefix that's required for the
|
||||
// plugin, and the pretty name has the prefix omitted. Using "glab-ping"
|
||||
// an example, we'd get the folowing:
|
||||
//
|
||||
// FullName: glab-ping
|
||||
// PrettyName : ping
|
||||
//
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
// TODO: debug why it always returns
|
||||
if plugin.PrettyName() == rootCmd.Name() {
|
||||
return fmt.Errorf("%q cannot be used because command collides with root command", plugin.FullName())
|
||||
}
|
||||
|
||||
for _, subCmd := range rootCmd.Commands() {
|
||||
if plugin.PrettyName() == subCmd.Name() {
|
||||
return fmt.Errorf("%q cannot be used because it collides with existing subcommand %q", plugin.FullName(), subCmd.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Plugins are expected to implement a --metadata flag that emits
|
||||
// the plugin metadata stored in the local plugin registry..
|
||||
pluginMetaCmd := exec.Command(plugin.FullName(), "--metadata")
|
||||
pluginMetaCmd.WaitDelay = 750 * time.Millisecond
|
||||
stdout, err := pluginMetaCmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("attaching to plugin stdout %s: %w", plugin, err)
|
||||
}
|
||||
|
||||
err = pluginMetaCmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("gathering plugin metadata: %w", err)
|
||||
}
|
||||
|
||||
var data PluginMetadata
|
||||
err = json.NewDecoder(stdout).Decode(&data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing plugin metadata: %w", err)
|
||||
}
|
||||
|
||||
pluginDir := config.PluginDir()
|
||||
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
|
||||
return fmt.Errorf("creating plugin directory: %w", err)
|
||||
}
|
||||
|
||||
pluginMetadataPath := filepath.Join(pluginDir, plugin.PrettyName()+".json")
|
||||
|
||||
// Ensure that we record the checksum
|
||||
pluginChecksum, err := checksum(plugin.FullName())
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating %s plugin checksum: %w", plugin, err)
|
||||
}
|
||||
data.Checksum = pluginChecksum
|
||||
|
||||
// Strictly use the pretty name as the plugin name
|
||||
// in case the plugin returns a different name
|
||||
// - intentionally or not.
|
||||
data.Name = plugin.PrettyName()
|
||||
|
||||
pluginMetadataFile, err := os.Create(pluginMetadataPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating %s plugin metadata in %s: %w", plugin, pluginMetadataPath, err)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(pluginMetadataFile)
|
||||
enc.SetIndent("", " ")
|
||||
err = enc.Encode(&data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("saving %s plugin metadata to %s", plugin, pluginMetadataPath)
|
||||
}
|
||||
|
||||
err = pluginMetadataFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("closing %s plugin metadata file: %w", plugin, err)
|
||||
}
|
||||
|
||||
err = pluginMetaCmd.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting on %s plugin to close: %w", plugin, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksum(plugin string) (string, error) {
|
||||
h := sha256.New()
|
||||
absPluginPath, err := exec.LookPath(plugin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pluginFile, err := os.Open(absPluginPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = io.Copy(h, pluginFile)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
err = pluginFile.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b []byte
|
||||
b = h.Sum(b)
|
||||
return "sha256:" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func disableCmd(f *cmdutils.Factory) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable [plugin]",
|
||||
Short: "Disables a glab plugin",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("work in progress")
|
||||
},
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
@ -18,14 +19,13 @@ import (
|
|||
|
||||
const numSearchers = 4
|
||||
const glabPrefix = "glab-"
|
||||
const windowsPrefix = "windows/"
|
||||
|
||||
type Plugin string
|
||||
|
||||
func (p Plugin) NewCommand(f *cmdutils.Factory) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: p.Name(),
|
||||
Short: fmt.Sprintf("The %s plugin", p.Name()),
|
||||
Use: p.PrettyName(),
|
||||
Short: fmt.Sprintf("The %s plugin", p.PrettyName()),
|
||||
Hidden: false,
|
||||
GroupID: "Plugins",
|
||||
DisableFlagParsing: true,
|
||||
|
@ -35,21 +35,31 @@ func (p Plugin) NewCommand(f *cmdutils.Factory) *cobra.Command {
|
|||
}
|
||||
}
|
||||
|
||||
func (p Plugin) Name() string {
|
||||
elems := strings.Split(p.String(), "-")
|
||||
return elems[len(elems)-1]
|
||||
// FullName is the name of the plugin including the `glab-` prefix.
|
||||
func (p Plugin) FullName() string {
|
||||
return glabPrefix + string(p)
|
||||
}
|
||||
|
||||
// PrettyName is the name of the plugin without the `glab-` prefix.
|
||||
func (p Plugin) PrettyName() string {
|
||||
return strings.TrimPrefix(string(p), glabPrefix)
|
||||
}
|
||||
|
||||
// CommandPath returns the args that make up the path to the plugin command.
|
||||
func (p Plugin) CommandPath() []string {
|
||||
args := strings.Split(string(p), "-")
|
||||
path := []string{"glab"}
|
||||
path = append(path, args...)
|
||||
return path
|
||||
}
|
||||
|
||||
// String returns the full name of the plugin.
|
||||
func (p Plugin) String() string {
|
||||
return p.bin()
|
||||
}
|
||||
|
||||
func (p Plugin) bin() string {
|
||||
return string(p)
|
||||
return p.FullName()
|
||||
}
|
||||
|
||||
func (p Plugin) run(io *iostreams.IOStreams, args ...string) error {
|
||||
bin, err := exec.LookPath(p.bin())
|
||||
bin, err := exec.LookPath(p.FullName())
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving %s full path: %w", p, err)
|
||||
}
|
||||
|
@ -107,6 +117,8 @@ func List() ([]Plugin, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure that they're return in alphanumerical order.
|
||||
slices.Sort(plugins)
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ import (
|
|||
jobCmd "gitlab.com/gitlab-org/cli/commands/job"
|
||||
labelCmd "gitlab.com/gitlab-org/cli/commands/label"
|
||||
mrCmd "gitlab.com/gitlab-org/cli/commands/mr"
|
||||
"gitlab.com/gitlab-org/cli/commands/plugin"
|
||||
projectCmd "gitlab.com/gitlab-org/cli/commands/project"
|
||||
releaseCmd "gitlab.com/gitlab-org/cli/commands/release"
|
||||
scheduleCmd "gitlab.com/gitlab-org/cli/commands/schedule"
|
||||
|
@ -132,6 +133,7 @@ func NewCmdRoot(f *cmdutils.Factory, version, buildDate string) *cobra.Command {
|
|||
rootCmd.AddCommand(scheduleCmd.NewCmdSchedule(f))
|
||||
rootCmd.AddCommand(snippetCmd.NewCmdSnippet(f))
|
||||
rootCmd.AddCommand(askCmd.NewCmd(f))
|
||||
rootCmd.AddCommand(plugin.Command(f))
|
||||
|
||||
rootCmd.Flags().BoolP("version", "v", false, "show glab version information")
|
||||
return rootCmd
|
||||
|
|
|
@ -35,6 +35,11 @@ func ConfigDir() string {
|
|||
return filepath.Join(usrConfigHome, "glab-cli")
|
||||
}
|
||||
|
||||
// PluginDir returns the plugin directory
|
||||
func PluginDir() string {
|
||||
return filepath.Join(ConfigDir(), "/plugins")
|
||||
}
|
||||
|
||||
// ConfigFile returns the config file path
|
||||
func ConfigFile() string {
|
||||
return path.Join(ConfigDir(), "config.yml")
|
||||
|
|
Loading…
Reference in New Issue