From a5746dff02978f68a29b3774af4e71a93fe1ab3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Wed, 4 Jun 2025 16:00:58 +0200 Subject: [PATCH 01/15] #2090 - Introduce extensibility to Chainloop CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 354 +++++++++++++++++++++++++++++++++++++++ app/cli/cmd/root.go | 9 + pkg/plugins/client.go | 101 +++++++++++ pkg/plugins/interface.go | 55 ++++++ pkg/plugins/manager.go | 157 +++++++++++++++++ pkg/plugins/shared.go | 33 ++++ 6 files changed, 709 insertions(+) create mode 100644 app/cli/cmd/plugins.go create mode 100644 pkg/plugins/client.go create mode 100644 pkg/plugins/interface.go create mode 100644 pkg/plugins/manager.go create mode 100644 pkg/plugins/shared.go diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go new file mode 100644 index 000000000..d7e47b7cc --- /dev/null +++ b/app/cli/cmd/plugins.go @@ -0,0 +1,354 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/chainloop-dev/chainloop/pkg/plugins" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" +) + +var ( + pluginManager *plugins.Manager + registeredCommands map[string]string // Track which plugin registered which command +) + +func init() { + pluginManager = plugins.NewManager(logger) + registeredCommands = make(map[string]string) +} + +func newPluginCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin", + Short: "Manage plugins", + Long: "Manage Chainloop plugins for extended functionality", + } + + cmd.AddCommand(newPluginListCmd()) + cmd.AddCommand(newPluginInfoCmd()) + + return cmd +} + +func createPluginCommand(plugin *plugins.LoadedPlugin, cmdInfo plugins.CommandInfo) *cobra.Command { + cmd := &cobra.Command{ + Use: cmdInfo.Name, + Short: cmdInfo.Description, + Long: fmt.Sprintf("%s\n\nProvided by plugin: %s v%s", cmdInfo.Description, plugin.Metadata.Name, plugin.Metadata.Version), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Collect arguments + arguments := make(map[string]interface{}) + + // Collect flag values + for _, flag := range cmdInfo.Flags { + switch flag.Type { + case "string": + if val, err := cmd.Flags().GetString(flag.Name); err == nil { + arguments[flag.Name] = val + } + case "bool": + if val, err := cmd.Flags().GetBool(flag.Name); err == nil { + arguments[flag.Name] = val + } + case "int": + if val, err := cmd.Flags().GetInt(flag.Name); err == nil { + arguments[flag.Name] = val + } + } + } + + // Add positional arguments + arguments["args"] = args + + // Add GitHub environment variables to arguments if they exist + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + arguments["github_token"] = token + } + if org := os.Getenv("GITHUB_ORG"); org != "" { + arguments["github_org"] = org + } + if repo := os.Getenv("GITHUB_REPO"); repo != "" { + arguments["github_repo"] = repo + } + if apiURL := os.Getenv("GITHUB_API_URL"); apiURL != "" { + arguments["github_api_url"] = apiURL + } else { + arguments["github_api_url"] = "https://api.github.com" + } + + // Execute plugin command + result, err := plugin.Plugin.Exec(ctx, cmdInfo.Name, arguments) + if err != nil { + return fmt.Errorf("plugin execution failed: %w", err) + } + + // Handle result + if result.GetError() != "" { + return fmt.Errorf("%s", result.GetError()) + } + + fmt.Print(result.GetOutput()) + + // Return with appropriate exit code + if result.GetExitCode() != 0 { + os.Exit(result.GetExitCode()) + } + + return nil + }, + } + + // Add flags + for _, flag := range cmdInfo.Flags { + switch flag.Type { + case "string": + defaultVal, _ := flag.Default.(string) + cmd.Flags().String(flag.Name, defaultVal, flag.Description) + case "bool": + defaultVal, _ := flag.Default.(bool) + cmd.Flags().Bool(flag.Name, defaultVal, flag.Description) + case "int": + defaultVal, _ := flag.Default.(int) + cmd.Flags().Int(flag.Name, defaultVal, flag.Description) + } + + if flag.Required { + cmd.MarkFlagRequired(flag.Name) + } + } + + return cmd +} + +func newPluginListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List installed plugins and their commands", + RunE: func(cmd *cobra.Command, args []string) error { + plugins := pluginManager.GetAllPlugins() + + if flagOutputFormat == formatJSON { + type pluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Path string `json:"path"` + Commands []string `json:"commands"` + } + + var items []pluginInfo + for name, plugin := range plugins { + var commands []string + for _, cmd := range plugin.Metadata.Commands { + commands = append(commands, cmd.Name) + } + + items = append(items, pluginInfo{ + Name: name, + Version: plugin.Metadata.Version, + Description: plugin.Metadata.Description, + Path: plugin.Path, + Commands: commands, + }) + } + + return encodeJSON(items) + } + + return pluginListTableOutput(plugins) + }, + } +} + +func newPluginInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info [plugin-name]", + Short: "Show detailed information about a plugin", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + pluginName := args[0] + plugin, ok := pluginManager.GetPlugin(pluginName) + if !ok { + return fmt.Errorf("plugin '%s' not found", pluginName) + } + + if flagOutputFormat == formatJSON { + type pluginDetail struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Path string `json:"path"` + Commands []plugins.CommandInfo `json:"commands"` + } + + detail := pluginDetail{ + Name: plugin.Metadata.Name, + Version: plugin.Metadata.Version, + Description: plugin.Metadata.Description, + Path: plugin.Path, + Commands: plugin.Metadata.Commands, + } + + return encodeJSON(detail) + } + + return pluginInfoTableOutput(plugin) + }, + } +} + +// loadAllPlugins loads all plugins and registers their commands to the root command +func loadAllPlugins(rootCmd *cobra.Command) error { + ctx := context.Background() + + // Load all plugins from the plugins directory + if err := pluginManager.LoadPlugins(ctx); err != nil { + return fmt.Errorf("failed to load plugins: %w", err) + } + + // Get all loaded plugins + allPlugins := pluginManager.GetAllPlugins() + if len(allPlugins) == 0 { + logger.Debug().Msg("No plugins found in plugins directory") + return nil + } + + logger.Debug().Int("count", len(allPlugins)).Msg("Found plugins") + + // Register commands from all plugins, checking for conflicts + for pluginName, plugin := range allPlugins { + logger.Debug(). + Str("name", pluginName). + Str("version", plugin.Metadata.Version). + Msg("Processing plugin") + + for _, cmdInfo := range plugin.Metadata.Commands { + if existingPlugin, exists := registeredCommands[cmdInfo.Name]; exists { + return fmt.Errorf("command conflict: command '%s' is provided by both '%s' and '%s' plugins", + cmdInfo.Name, existingPlugin, pluginName) + } + + // Register the command + pluginCmd := createPluginCommand(plugin, cmdInfo) + rootCmd.AddCommand(pluginCmd) + registeredCommands[cmdInfo.Name] = pluginName + + logger.Debug(). + Str("command", cmdInfo.Name). + Str("plugin", pluginName). + Msg("Registered plugin command") + } + } + + logger.Debug(). + Int("commands", len(registeredCommands)). + Int("plugins", len(allPlugins)). + Msg("Successfully registered plugin commands") + + return nil +} + +// cleanupPlugins should be called during application shutdown +func cleanupPlugins() { + if pluginManager != nil { + pluginManager.Shutdown() + } +} + +// Table output functions +func pluginListTableOutput(plugins map[string]*plugins.LoadedPlugin) error { + if len(plugins) == 0 { + fmt.Println("No plugins installed") + return nil + } + + t := newTableWriter() + t.AppendHeader(table.Row{"Name", "Version", "Description", "Commands"}) + + for name, plugin := range plugins { + commandStr := fmt.Sprintf("%d command(s)", len(plugin.Metadata.Commands)) + if len(plugin.Metadata.Commands) == 0 { + commandStr = "no commands" + } + + t.AppendRow(table.Row{name, plugin.Metadata.Version, plugin.Metadata.Description, commandStr}) + t.AppendSeparator() + } + + t.Render() + + t = newTableWriter() + t.AppendHeader(table.Row{"Plugin", "Command"}) + for cmd, plugin := range registeredCommands { + t.AppendRow(table.Row{plugin, cmd}) + t.AppendSeparator() + } + t.Render() + + return nil +} + +func pluginInfoTableOutput(plugin *plugins.LoadedPlugin) error { + t := newTableWriter() + + t.AppendHeader(table.Row{"Name", "Version", "Description", "Commands"}) + t.AppendRow(table.Row{plugin.Metadata.Name, plugin.Metadata.Version, plugin.Metadata.Description, fmt.Sprintf("%d command(s)", len(plugin.Metadata.Commands))}) + + t.Render() + + pluginInfoCommandsTableOutput(plugin) + pluginInfoFlagsTableOutput(plugin) + + return nil +} + +func pluginInfoCommandsTableOutput(plugin *plugins.LoadedPlugin) error { + t := newTableWriter() + + t.AppendHeader(table.Row{"Plugin", "Command", "Description", "Usage"}) + for _, cmd := range plugin.Metadata.Commands { + t.AppendRow(table.Row{plugin.Metadata.Name, cmd.Name, cmd.Description, cmd.Usage}) + t.AppendSeparator() + } + + t.Render() + + return nil +} + +func pluginInfoFlagsTableOutput(plugin *plugins.LoadedPlugin) error { + t := newTableWriter() + + t.AppendHeader(table.Row{"Plugin", "Command", "Flag", "Description", "Type", "Default", "Required"}) + for _, cmd := range plugin.Metadata.Commands { + for _, flag := range cmd.Flags { + t.AppendRow(table.Row{plugin.Metadata.Name, cmd.Name, flag.Name, flag.Description, flag.Type, flag.Default, flag.Required}) + t.AppendSeparator() + } + } + + t.Render() + + return nil +} diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index aca56ef3d..75d9c50ce 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -251,8 +251,16 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { newAttestationCmd(), newArtifactCmd(), newConfigCmd(), newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(), newReferrerDiscoverCmd(), + newPluginCmd(), ) + // Load plugins if we are not running a subcommand + if len(os.Args) > 1 && os.Args[1] != "completion" && os.Args[1] != "help" { + if err := loadAllPlugins(rootCmd); err != nil { + logger.Debug().Err(err).Msg("Failed to load plugins, continuing with built-in commands only") + } + } + return rootCmd } @@ -337,6 +345,7 @@ func newActionOpts(logger zerolog.Logger, conn *grpc.ClientConn, token string) * } func cleanup(conn *grpc.ClientConn) error { + cleanupPlugins() if conn != nil { if err := conn.Close(); err != nil { return err diff --git a/pkg/plugins/client.go b/pkg/plugins/client.go new file mode 100644 index 000000000..548f7083d --- /dev/null +++ b/pkg/plugins/client.go @@ -0,0 +1,101 @@ +package plugins + +import ( + "context" + "net/rpc" + + "github.com/hashicorp/go-plugin" +) + +// ChainloopCliPlugin is the implementation of plugin.Plugin. +type ChainloopCliPlugin struct { + Impl Plugin +} + +func (p *ChainloopCliPlugin) Server(*plugin.MuxBroker) (interface{}, error) { + return &RPCServer{Impl: p.Impl}, nil +} + +func (ChainloopCliPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) { + return &RPCClient{client: c}, nil +} + +// RPCClient is an implementation of Plugin that talks over RPC. +type RPCClient struct { + client *rpc.Client +} + +func (m *RPCClient) Exec(ctx context.Context, command string, arguments map[string]any) (ExecResult, error) { + var resp ExecResponse + err := m.client.Call("Plugin.Exec", map[string]any{ + "command": command, + "arguments": arguments, + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (m *RPCClient) GetMetadata(ctx context.Context) (PluginMetadata, error) { + var resp PluginMetadata + err := m.client.Call("Plugin.GetMetadata", new(any), &resp) + return resp, err +} + +// RPCServer is the RPC server that RPCClient talks to, conforming to the requirements of net/rpc. +type RPCServer struct { + Impl Plugin +} + +func (m *RPCServer) Exec(args map[string]any, resp *ExecResponse) error { + ctx := context.Background() + command := args["command"].(string) + arguments := args["arguments"].(map[string]any) + + result, err := m.Impl.Exec(ctx, command, arguments) + if err != nil { + return err + } + + *resp = ExecResponse{ + Output: result.GetOutput(), + Error: result.GetError(), + ExitCode: result.GetExitCode(), + Data: result.GetData(), + } + return nil +} + +func (m *RPCServer) GetMetadata(args any, resp *PluginMetadata) error { + metadata, err := m.Impl.GetMetadata(context.Background()) + if err != nil { + return err + } + *resp = metadata + return nil +} + +// ExecResponse is a concrete implementation of ExecResult for RPC. +type ExecResponse struct { + Output string + Error string + ExitCode int + Data map[string]any +} + +func (r *ExecResponse) GetOutput() string { + return r.Output +} + +func (r *ExecResponse) GetError() string { + return r.Error +} + +func (r *ExecResponse) GetExitCode() int { + return r.ExitCode +} + +func (r *ExecResponse) GetData() map[string]any { + return r.Data +} diff --git a/pkg/plugins/interface.go b/pkg/plugins/interface.go new file mode 100644 index 000000000..cb1acd085 --- /dev/null +++ b/pkg/plugins/interface.go @@ -0,0 +1,55 @@ +package plugins + +import ( + "context" +) + +// Plugin is the interface that plugins must implement. +type Plugin interface { + // Exec executes a command within the plugin + Exec(ctx context.Context, command string, arguments map[string]any) (ExecResult, error) + + // GetMetadata returns plugin metadata including commands it provides + GetMetadata(ctx context.Context) (PluginMetadata, error) +} + +// ExecResult represents the result of executing a plugin command +type ExecResult interface { + // GetOutput returns the command output + GetOutput() string + + // GetError returns any error message + GetError() string + + // GetExitCode returns the exit code + GetExitCode() int + + // GetData returns any structured data + GetData() map[string]any +} + +// PluginMetadata contains information about the plugin. +type PluginMetadata struct { + Name string + Version string + Description string + Commands []CommandInfo +} + +// CommandInfo describes a command provided by the plugin +type CommandInfo struct { + Name string + Description string + Usage string + Flags []FlagInfo +} + +// FlagInfo describes a command flag +type FlagInfo struct { + Name string + Shorthand string + Description string + Type string + Default any + Required bool +} diff --git a/pkg/plugins/manager.go b/pkg/plugins/manager.go new file mode 100644 index 000000000..5ea3e5562 --- /dev/null +++ b/pkg/plugins/manager.go @@ -0,0 +1,157 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/hashicorp/go-plugin" + "github.com/rs/zerolog" +) + +// Manager handles loading and managing plugins. +type Manager struct { + plugins map[string]*LoadedPlugin + pluginClients map[string]*plugin.Client + logger zerolog.Logger +} + +// LoadedPlugin represents a loaded plugin with its metadata. +type LoadedPlugin struct { + Path string + Plugin Plugin + Metadata PluginMetadata +} + +// NewManager creates a new plugin manager. +func NewManager(logger zerolog.Logger) *Manager { + return &Manager{ + plugins: make(map[string]*LoadedPlugin), + pluginClients: make(map[string]*plugin.Client), + logger: logger, + } +} + +// LoadPlugins loads all plugins from the plugins directory. +func (m *Manager) LoadPlugins(ctx context.Context) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + pluginsDir := filepath.Join(homeDir, ".config", "chainloop", "plugins") // TODO: make this configurable + + if err := os.MkdirAll(pluginsDir, 0755); err != nil { + return fmt.Errorf("failed to create plugins directory: %w", err) + } + + entries, err := os.ReadDir(pluginsDir) + if err != nil { + return fmt.Errorf("failed to read plugins directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + pluginPath := filepath.Join(pluginsDir, entry.Name()) + + info, err := entry.Info() + if err != nil { + m.logger.Err(err).Str("pluginPath", pluginPath).Msg("failed to get info for plugin") + continue + } + + // On Windows, check for .exe extension + if runtime.GOOS == "windows" && filepath.Ext(pluginPath) != ".exe" { + continue + } + + // On Unix, check if executable + if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { + continue + } + + // Load the plugin + if err := m.loadPlugin(ctx, pluginPath); err != nil { + m.logger.Err(err).Str("pluginPath", pluginPath).Msg("failed to load plugin") + continue + } + } + + return nil +} + +// loadPlugin loads a single plugin. +func (m *Manager) loadPlugin(ctx context.Context, path string) error { + client := plugin.NewClient(&plugin.ClientConfig{ + HandshakeConfig: Handshake, + Plugins: PluginMap, + Cmd: exec.Command(path), + AllowedProtocols: []plugin.Protocol{ + plugin.ProtocolNetRPC, + }, + }) + + // Connect via RPC + rpcClient, err := client.Client() + if err != nil { + client.Kill() + return fmt.Errorf("failed to create RPC client: %w", err) + } + + // Request the plugin + raw, err := rpcClient.Dispense("chainloop") + if err != nil { + client.Kill() + return fmt.Errorf("failed to dispense plugin: %w", err) + } + + // Cast to our interface + chainloopPlugin, ok := raw.(Plugin) + if !ok { + client.Kill() + return fmt.Errorf("plugin does not implement Plugin interface") + } + + // Get plugin metadata + metadata, err := chainloopPlugin.GetMetadata(ctx) + if err != nil { + client.Kill() + return fmt.Errorf("failed to get plugin metadata: %w", err) + } + + // Store the plugin + m.plugins[metadata.Name] = &LoadedPlugin{ + Path: path, + Plugin: chainloopPlugin, + Metadata: metadata, + } + m.pluginClients[metadata.Name] = client + + m.logger.Debug().Str("pluginName", metadata.Name).Str("pluginVersion", metadata.Version).Msg("loaded plugin") + return nil +} + +// GetPlugin returns a loaded plugin by name. +func (m *Manager) GetPlugin(name string) (*LoadedPlugin, bool) { + plugin, ok := m.plugins[name] + return plugin, ok +} + +// GetAllPlugins returns all loaded plugins. +func (m *Manager) GetAllPlugins() map[string]*LoadedPlugin { + return m.plugins +} + +// Shutdown closes all plugin connections. +func (m *Manager) Shutdown() { + for name, client := range m.pluginClients { + m.logger.Debug().Str("pluginName", name).Msg("shutting down plugin") + client.Kill() + } +} diff --git a/pkg/plugins/shared.go b/pkg/plugins/shared.go new file mode 100644 index 000000000..af72cba45 --- /dev/null +++ b/pkg/plugins/shared.go @@ -0,0 +1,33 @@ +package plugins + +import ( + "encoding/gob" + + "github.com/hashicorp/go-plugin" +) + +func init() { + // Register types that will be sent over RPC + gob.Register(map[string]any{}) + gob.Register([]any{}) + gob.Register(ExecResponse{}) + gob.Register(PluginMetadata{}) + gob.Register(CommandInfo{}) + gob.Register(FlagInfo{}) + gob.Register([]CommandInfo{}) + gob.Register([]FlagInfo{}) + gob.Register([]string{}) + gob.Register([]map[string]any{}) +} + +// Handshake is a common handshake that is shared by CLI plugins and the host. +var Handshake = plugin.HandshakeConfig{ + ProtocolVersion: 1, + MagicCookieKey: "CHAINLOOP_CLI_PLUGIN", + MagicCookieValue: "chainloop-cli-plugin-v1", +} + +// PluginMap is the map of plugins. +var PluginMap = map[string]plugin.Plugin{ + "chainloop": &ChainloopCliPlugin{}, +} From bf7b20ff5cee1bc92846820949b40298f1865568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Wed, 4 Jun 2025 21:13:16 +0200 Subject: [PATCH 02/15] Add generated files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/documentation/cli-reference.mdx | 125 ++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 20ac04df0..355c7dc6e 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2535,6 +2535,131 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +## chainloop plugin + +Manage plugins + +Synopsis + +Manage Chainloop plugins for extended functionality + +Options + +``` +-h, --help help for plugin +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop plugin help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type plugin help [path to command] for full details. + +``` +chainloop plugin help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop plugin info + +Show detailed information about a plugin + +``` +chainloop plugin info [plugin-name] [flags] +``` + +Options + +``` +-h, --help help for info +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop plugin list + +List installed plugins and their commands + +``` +chainloop plugin list [flags] +``` + +Options + +``` +-h, --help help for list +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + ## chainloop version Command line version From 052084a67e35f83e4ab174872e56d35e445c6690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Wed, 4 Jun 2025 21:40:58 +0200 Subject: [PATCH 03/15] Lint related changes and clean up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 67 ++++++++++++++++------------------------ pkg/plugins/client.go | 14 +++++++++ pkg/plugins/interface.go | 15 +++++++++ pkg/plugins/manager.go | 15 +++++++++ pkg/plugins/shared.go | 15 +++++++++ 5 files changed, 86 insertions(+), 40 deletions(-) diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index d7e47b7cc..07fc86210 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -25,6 +25,12 @@ import ( "github.com/spf13/cobra" ) +const ( + stringFlagType = "string" + boolFlagType = "bool" + intFlagType = "int" +) + var ( pluginManager *plugins.Manager registeredCommands map[string]string // Track which plugin registered which command @@ -62,15 +68,15 @@ func createPluginCommand(plugin *plugins.LoadedPlugin, cmdInfo plugins.CommandIn // Collect flag values for _, flag := range cmdInfo.Flags { switch flag.Type { - case "string": + case stringFlagType: if val, err := cmd.Flags().GetString(flag.Name); err == nil { arguments[flag.Name] = val } - case "bool": + case boolFlagType: if val, err := cmd.Flags().GetBool(flag.Name); err == nil { arguments[flag.Name] = val } - case "int": + case intFlagType: if val, err := cmd.Flags().GetInt(flag.Name); err == nil { arguments[flag.Name] = val } @@ -80,22 +86,6 @@ func createPluginCommand(plugin *plugins.LoadedPlugin, cmdInfo plugins.CommandIn // Add positional arguments arguments["args"] = args - // Add GitHub environment variables to arguments if they exist - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - arguments["github_token"] = token - } - if org := os.Getenv("GITHUB_ORG"); org != "" { - arguments["github_org"] = org - } - if repo := os.Getenv("GITHUB_REPO"); repo != "" { - arguments["github_repo"] = repo - } - if apiURL := os.Getenv("GITHUB_API_URL"); apiURL != "" { - arguments["github_api_url"] = apiURL - } else { - arguments["github_api_url"] = "https://api.github.com" - } - // Execute plugin command result, err := plugin.Plugin.Exec(ctx, cmdInfo.Name, arguments) if err != nil { @@ -121,19 +111,20 @@ func createPluginCommand(plugin *plugins.LoadedPlugin, cmdInfo plugins.CommandIn // Add flags for _, flag := range cmdInfo.Flags { switch flag.Type { - case "string": + case stringFlagType: defaultVal, _ := flag.Default.(string) cmd.Flags().String(flag.Name, defaultVal, flag.Description) - case "bool": + case boolFlagType: defaultVal, _ := flag.Default.(bool) cmd.Flags().Bool(flag.Name, defaultVal, flag.Description) - case "int": + case intFlagType: defaultVal, _ := flag.Default.(int) cmd.Flags().Int(flag.Name, defaultVal, flag.Description) } if flag.Required { - cmd.MarkFlagRequired(flag.Name) + err := cmd.MarkFlagRequired(flag.Name) + cobra.CheckErr(err) } } @@ -145,7 +136,7 @@ func newPluginListCmd() *cobra.Command { Use: "list", Aliases: []string{"ls"}, Short: "List installed plugins and their commands", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { plugins := pluginManager.GetAllPlugins() if flagOutputFormat == formatJSON { @@ -176,7 +167,9 @@ func newPluginListCmd() *cobra.Command { return encodeJSON(items) } - return pluginListTableOutput(plugins) + pluginListTableOutput(plugins) + + return nil }, } } @@ -186,7 +179,7 @@ func newPluginInfoCmd() *cobra.Command { Use: "info [plugin-name]", Short: "Show detailed information about a plugin", Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { pluginName := args[0] plugin, ok := pluginManager.GetPlugin(pluginName) if !ok { @@ -213,7 +206,9 @@ func newPluginInfoCmd() *cobra.Command { return encodeJSON(detail) } - return pluginInfoTableOutput(plugin) + pluginInfoTableOutput(plugin) + + return nil }, } } @@ -277,10 +272,10 @@ func cleanupPlugins() { } // Table output functions -func pluginListTableOutput(plugins map[string]*plugins.LoadedPlugin) error { +func pluginListTableOutput(plugins map[string]*plugins.LoadedPlugin) { if len(plugins) == 0 { fmt.Println("No plugins installed") - return nil + return } t := newTableWriter() @@ -305,11 +300,9 @@ func pluginListTableOutput(plugins map[string]*plugins.LoadedPlugin) error { t.AppendSeparator() } t.Render() - - return nil } -func pluginInfoTableOutput(plugin *plugins.LoadedPlugin) error { +func pluginInfoTableOutput(plugin *plugins.LoadedPlugin) { t := newTableWriter() t.AppendHeader(table.Row{"Name", "Version", "Description", "Commands"}) @@ -319,11 +312,9 @@ func pluginInfoTableOutput(plugin *plugins.LoadedPlugin) error { pluginInfoCommandsTableOutput(plugin) pluginInfoFlagsTableOutput(plugin) - - return nil } -func pluginInfoCommandsTableOutput(plugin *plugins.LoadedPlugin) error { +func pluginInfoCommandsTableOutput(plugin *plugins.LoadedPlugin) { t := newTableWriter() t.AppendHeader(table.Row{"Plugin", "Command", "Description", "Usage"}) @@ -333,11 +324,9 @@ func pluginInfoCommandsTableOutput(plugin *plugins.LoadedPlugin) error { } t.Render() - - return nil } -func pluginInfoFlagsTableOutput(plugin *plugins.LoadedPlugin) error { +func pluginInfoFlagsTableOutput(plugin *plugins.LoadedPlugin) { t := newTableWriter() t.AppendHeader(table.Row{"Plugin", "Command", "Flag", "Description", "Type", "Default", "Required"}) @@ -349,6 +338,4 @@ func pluginInfoFlagsTableOutput(plugin *plugins.LoadedPlugin) error { } t.Render() - - return nil } diff --git a/pkg/plugins/client.go b/pkg/plugins/client.go index 548f7083d..3df4cffc5 100644 --- a/pkg/plugins/client.go +++ b/pkg/plugins/client.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package plugins import ( diff --git a/pkg/plugins/interface.go b/pkg/plugins/interface.go index cb1acd085..7c12edfec 100644 --- a/pkg/plugins/interface.go +++ b/pkg/plugins/interface.go @@ -1,3 +1,18 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package plugins import ( diff --git a/pkg/plugins/manager.go b/pkg/plugins/manager.go index 5ea3e5562..8fb984697 100644 --- a/pkg/plugins/manager.go +++ b/pkg/plugins/manager.go @@ -1,3 +1,18 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package plugins import ( diff --git a/pkg/plugins/shared.go b/pkg/plugins/shared.go index af72cba45..f4eb7c8d0 100644 --- a/pkg/plugins/shared.go +++ b/pkg/plugins/shared.go @@ -1,3 +1,18 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package plugins import ( From 281b1a9753cefb968009c2bd462a63ebb24268bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Wed, 4 Jun 2025 21:55:40 +0200 Subject: [PATCH 04/15] Additional cleanup for linter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 2 +- pkg/plugins/client.go | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index 07fc86210..5a312b321 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -136,7 +136,7 @@ func newPluginListCmd() *cobra.Command { Use: "list", Aliases: []string{"ls"}, Short: "List installed plugins and their commands", - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { plugins := pluginManager.GetAllPlugins() if flagOutputFormat == formatJSON { diff --git a/pkg/plugins/client.go b/pkg/plugins/client.go index 3df4cffc5..d20d62b29 100644 --- a/pkg/plugins/client.go +++ b/pkg/plugins/client.go @@ -1,10 +1,11 @@ +// // Copyright 2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, @@ -30,7 +31,7 @@ func (p *ChainloopCliPlugin) Server(*plugin.MuxBroker) (interface{}, error) { return &RPCServer{Impl: p.Impl}, nil } -func (ChainloopCliPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) { +func (ChainloopCliPlugin) Client(_ *plugin.MuxBroker, c *rpc.Client) (interface{}, error) { return &RPCClient{client: c}, nil } @@ -39,7 +40,7 @@ type RPCClient struct { client *rpc.Client } -func (m *RPCClient) Exec(ctx context.Context, command string, arguments map[string]any) (ExecResult, error) { +func (m *RPCClient) Exec(_ context.Context, command string, arguments map[string]any) (ExecResult, error) { var resp ExecResponse err := m.client.Call("Plugin.Exec", map[string]any{ "command": command, @@ -51,7 +52,7 @@ func (m *RPCClient) Exec(ctx context.Context, command string, arguments map[stri return &resp, nil } -func (m *RPCClient) GetMetadata(ctx context.Context) (PluginMetadata, error) { +func (m *RPCClient) GetMetadata(_ context.Context) (PluginMetadata, error) { var resp PluginMetadata err := m.client.Call("Plugin.GetMetadata", new(any), &resp) return resp, err @@ -81,7 +82,7 @@ func (m *RPCServer) Exec(args map[string]any, resp *ExecResponse) error { return nil } -func (m *RPCServer) GetMetadata(args any, resp *PluginMetadata) error { +func (m *RPCServer) GetMetadata(_ any, resp *PluginMetadata) error { metadata, err := m.Impl.GetMetadata(context.Background()) if err != nil { return err From 49229c526492c90bf95029a542b9615c669ace51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Sun, 8 Jun 2025 18:14:37 +0200 Subject: [PATCH 05/15] Move the plugin to app/cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 2 +- {pkg => app/cli}/plugins/client.go | 0 {pkg => app/cli}/plugins/interface.go | 0 {pkg => app/cli}/plugins/manager.go | 0 {pkg => app/cli}/plugins/shared.go | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename {pkg => app/cli}/plugins/client.go (100%) rename {pkg => app/cli}/plugins/interface.go (100%) rename {pkg => app/cli}/plugins/manager.go (100%) rename {pkg => app/cli}/plugins/shared.go (100%) diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index 5a312b321..b12c15491 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -20,7 +20,7 @@ import ( "fmt" "os" - "github.com/chainloop-dev/chainloop/pkg/plugins" + "github.com/chainloop-dev/chainloop/app/cli/plugins" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" ) diff --git a/pkg/plugins/client.go b/app/cli/plugins/client.go similarity index 100% rename from pkg/plugins/client.go rename to app/cli/plugins/client.go diff --git a/pkg/plugins/interface.go b/app/cli/plugins/interface.go similarity index 100% rename from pkg/plugins/interface.go rename to app/cli/plugins/interface.go diff --git a/pkg/plugins/manager.go b/app/cli/plugins/manager.go similarity index 100% rename from pkg/plugins/manager.go rename to app/cli/plugins/manager.go diff --git a/pkg/plugins/shared.go b/app/cli/plugins/shared.go similarity index 100% rename from pkg/plugins/shared.go rename to app/cli/plugins/shared.go From 36a476b284c8985c842cc8e0b1f2aa97a9ba949c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Mon, 9 Jun 2025 22:33:24 +0200 Subject: [PATCH 06/15] Refactoring related to how code is structured - logging and action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 71 +++++------ app/cli/cmd/root.go | 4 +- app/cli/internal/action/plugin_actions.go | 136 ++++++++++++++++++++++ app/cli/plugins/manager.go | 33 +++--- 4 files changed, 181 insertions(+), 63 deletions(-) create mode 100644 app/cli/internal/action/plugin_actions.go diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index b12c15491..1b3d5785d 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -20,6 +20,7 @@ import ( "fmt" "os" + "github.com/chainloop-dev/chainloop/app/cli/internal/action" "github.com/chainloop-dev/chainloop/app/cli/plugins" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" @@ -37,7 +38,6 @@ var ( ) func init() { - pluginManager = plugins.NewManager(logger) registeredCommands = make(map[string]string) } @@ -86,22 +86,23 @@ func createPluginCommand(plugin *plugins.LoadedPlugin, cmdInfo plugins.CommandIn // Add positional arguments arguments["args"] = args - // Execute plugin command - result, err := plugin.Plugin.Exec(ctx, cmdInfo.Name, arguments) + // Execute plugin command using the action pattern + result, err := action.NewPluginExec(actionOpts, pluginManager).Run(ctx, plugin.Metadata.Name, cmdInfo.Name, arguments) if err != nil { - return fmt.Errorf("plugin execution failed: %w", err) + return fmt.Errorf("failed to execute plugin command: %w", err) } // Handle result - if result.GetError() != "" { - return fmt.Errorf("%s", result.GetError()) + if result.Error != "" { + return fmt.Errorf("the plugin command failed: %s", result.Error) } - fmt.Print(result.GetOutput()) + // TODO: for now just print the output + fmt.Print(result.Output) // Return with appropriate exit code - if result.GetExitCode() != 0 { - os.Exit(result.GetExitCode()) + if result.ExitCode != 0 { + os.Exit(result.ExitCode) } return nil @@ -137,7 +138,10 @@ func newPluginListCmd() *cobra.Command { Aliases: []string{"ls"}, Short: "List installed plugins and their commands", RunE: func(_ *cobra.Command, _ []string) error { - plugins := pluginManager.GetAllPlugins() + result, err := action.NewPluginList(actionOpts, pluginManager).Run(context.Background()) + if err != nil { + return err + } if flagOutputFormat == formatJSON { type pluginInfo struct { @@ -149,7 +153,7 @@ func newPluginListCmd() *cobra.Command { } var items []pluginInfo - for name, plugin := range plugins { + for name, plugin := range result.Plugins { var commands []string for _, cmd := range plugin.Metadata.Commands { commands = append(commands, cmd.Name) @@ -167,7 +171,7 @@ func newPluginListCmd() *cobra.Command { return encodeJSON(items) } - pluginListTableOutput(plugins) + pluginListTableOutput(result.Plugins, result.CommandsMap) return nil }, @@ -175,15 +179,15 @@ func newPluginListCmd() *cobra.Command { } func newPluginInfoCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "info [plugin-name]", Short: "Show detailed information about a plugin", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { pluginName := args[0] - plugin, ok := pluginManager.GetPlugin(pluginName) - if !ok { - return fmt.Errorf("plugin '%s' not found", pluginName) + result, err := action.NewPluginInfo(actionOpts, pluginManager).Run(context.Background(), pluginName) + if err != nil { + return err } if flagOutputFormat == formatJSON { @@ -196,21 +200,22 @@ func newPluginInfoCmd() *cobra.Command { } detail := pluginDetail{ - Name: plugin.Metadata.Name, - Version: plugin.Metadata.Version, - Description: plugin.Metadata.Description, - Path: plugin.Path, - Commands: plugin.Metadata.Commands, + Name: result.Plugin.Metadata.Name, + Version: result.Plugin.Metadata.Version, + Description: result.Plugin.Metadata.Description, + Path: result.Plugin.Path, + Commands: result.Plugin.Metadata.Commands, } return encodeJSON(detail) } - pluginInfoTableOutput(plugin) + pluginInfoTableOutput(result.Plugin) return nil }, } + return cmd } // loadAllPlugins loads all plugins and registers their commands to the root command @@ -225,19 +230,11 @@ func loadAllPlugins(rootCmd *cobra.Command) error { // Get all loaded plugins allPlugins := pluginManager.GetAllPlugins() if len(allPlugins) == 0 { - logger.Debug().Msg("No plugins found in plugins directory") return nil } - logger.Debug().Int("count", len(allPlugins)).Msg("Found plugins") - // Register commands from all plugins, checking for conflicts for pluginName, plugin := range allPlugins { - logger.Debug(). - Str("name", pluginName). - Str("version", plugin.Metadata.Version). - Msg("Processing plugin") - for _, cmdInfo := range plugin.Metadata.Commands { if existingPlugin, exists := registeredCommands[cmdInfo.Name]; exists { return fmt.Errorf("command conflict: command '%s' is provided by both '%s' and '%s' plugins", @@ -248,19 +245,9 @@ func loadAllPlugins(rootCmd *cobra.Command) error { pluginCmd := createPluginCommand(plugin, cmdInfo) rootCmd.AddCommand(pluginCmd) registeredCommands[cmdInfo.Name] = pluginName - - logger.Debug(). - Str("command", cmdInfo.Name). - Str("plugin", pluginName). - Msg("Registered plugin command") } } - logger.Debug(). - Int("commands", len(registeredCommands)). - Int("plugins", len(allPlugins)). - Msg("Successfully registered plugin commands") - return nil } @@ -272,7 +259,7 @@ func cleanupPlugins() { } // Table output functions -func pluginListTableOutput(plugins map[string]*plugins.LoadedPlugin) { +func pluginListTableOutput(plugins map[string]*plugins.LoadedPlugin, commandsMap map[string]string) { if len(plugins) == 0 { fmt.Println("No plugins installed") return @@ -295,7 +282,7 @@ func pluginListTableOutput(plugins map[string]*plugins.LoadedPlugin) { t = newTableWriter() t.AppendHeader(table.Row{"Plugin", "Command"}) - for cmd, plugin := range registeredCommands { + for cmd, plugin := range commandsMap { t.AppendRow(table.Row{plugin, cmd}) t.AppendSeparator() } diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 75d9c50ce..8b6e79881 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -30,6 +30,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/action" "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry" "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry/posthog" + "github.com/chainloop-dev/chainloop/app/cli/plugins" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/pkg/grpcconn" "github.com/golang-jwt/jwt/v4" @@ -256,8 +257,9 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { // Load plugins if we are not running a subcommand if len(os.Args) > 1 && os.Args[1] != "completion" && os.Args[1] != "help" { + pluginManager = plugins.NewManager() if err := loadAllPlugins(rootCmd); err != nil { - logger.Debug().Err(err).Msg("Failed to load plugins, continuing with built-in commands only") + logger.Error().Err(err).Msg("Failed to load plugins, continuing with built-in commands only") } } diff --git a/app/cli/internal/action/plugin_actions.go b/app/cli/internal/action/plugin_actions.go new file mode 100644 index 000000000..3c7ad6364 --- /dev/null +++ b/app/cli/internal/action/plugin_actions.go @@ -0,0 +1,136 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/plugins" +) + +// PluginList handles listing installed plugins +type PluginList struct { + cfg *ActionsOpts + manager *plugins.Manager +} + +// PluginInfo handles showing detailed information about a specific plugin +type PluginInfo struct { + cfg *ActionsOpts + manager *plugins.Manager +} + +// PluginExec handles executing a command provided by a plugin +type PluginExec struct { + cfg *ActionsOpts + manager *plugins.Manager +} + +// PluginListResult represents the result of listing plugins +type PluginListResult struct { + Plugins map[string]*plugins.LoadedPlugin + CommandsMap map[string]string // Maps command names to plugin names +} + +// PluginInfoResult represents the result of getting plugin info +type PluginInfoResult struct { + Plugin *plugins.LoadedPlugin +} + +// PluginExecResult represents the result of executing a plugin command +type PluginExecResult struct { + Output string + Error string + ExitCode int + Data map[string]any +} + +// NewPluginList creates a new PluginList action +func NewPluginList(cfg *ActionsOpts, manager *plugins.Manager) *PluginList { + return &PluginList{cfg: cfg, manager: manager} +} + +// Run executes the PluginList action +func (action *PluginList) Run(ctx context.Context) (*PluginListResult, error) { + action.cfg.Logger.Debug().Msg("Listing all plugins") + plugins := action.manager.GetAllPlugins() + + // Create a map of command names to plugin names + commandsMap := make(map[string]string) + for pluginName, plugin := range plugins { + for _, cmd := range plugin.Metadata.Commands { + commandsMap[cmd.Name] = pluginName + } + } + + action.cfg.Logger.Debug().Int("pluginCount", len(plugins)).Int("commandCount", len(commandsMap)).Msg("Found plugins and commands") + return &PluginListResult{ + Plugins: plugins, + CommandsMap: commandsMap, + }, nil +} + +// NewPluginInfo creates a new PluginInfo action +func NewPluginInfo(cfg *ActionsOpts, manager *plugins.Manager) *PluginInfo { + return &PluginInfo{cfg: cfg, manager: manager} +} + +// Run executes the PluginInfo action +func (action *PluginInfo) Run(ctx context.Context, pluginName string) (*PluginInfoResult, error) { + action.cfg.Logger.Debug().Str("pluginName", pluginName).Msg("Getting plugin info") + plugin, ok := action.manager.GetPlugin(pluginName) + if !ok { + return nil, fmt.Errorf("plugin '%s' not found", pluginName) + } + + action.cfg.Logger.Debug().Str("pluginName", pluginName).Str("version", plugin.Metadata.Version).Int("commandCount", len(plugin.Metadata.Commands)).Msg("Found plugin") + return &PluginInfoResult{ + Plugin: plugin, + }, nil +} + +// NewPluginExec creates a new PluginExec action +func NewPluginExec(cfg *ActionsOpts, manager *plugins.Manager) *PluginExec { + return &PluginExec{cfg: cfg, manager: manager} +} + +// Run executes the PluginExec action +func (action *PluginExec) Run(ctx context.Context, pluginName string, commandName string, arguments map[string]interface{}) (*PluginExecResult, error) { + action.cfg.Logger.Debug().Str("pluginName", pluginName).Str("command", commandName).Msg("Executing plugin command") + plugin, ok := action.manager.GetPlugin(pluginName) + if !ok { + return nil, fmt.Errorf("plugin '%s' not found", pluginName) + } + + result, err := plugin.Plugin.Exec(ctx, commandName, arguments) + if err != nil { + action.cfg.Logger.Error().Err(err).Str("pluginName", pluginName).Str("command", commandName).Msg("Plugin execution failed") + return nil, fmt.Errorf("plugin execution failed: %w", err) + } + + if result.GetError() != "" { + action.cfg.Logger.Error().Str("pluginName", pluginName).Str("command", commandName).Str("error", result.GetError()).Msg("Plugin returned error") + } + + action.cfg.Logger.Debug().Str("pluginName", pluginName).Str("command", commandName).Int("exitCode", result.GetExitCode()).Msg("Plugin command executed") + return &PluginExecResult{ + Output: result.GetOutput(), + Error: result.GetError(), + ExitCode: result.GetExitCode(), + Data: result.GetData(), + }, nil +} diff --git a/app/cli/plugins/manager.go b/app/cli/plugins/manager.go index 8fb984697..0c2b29b57 100644 --- a/app/cli/plugins/manager.go +++ b/app/cli/plugins/manager.go @@ -23,15 +23,14 @@ import ( "path/filepath" "runtime" + "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-plugin" - "github.com/rs/zerolog" ) // Manager handles loading and managing plugins. type Manager struct { plugins map[string]*LoadedPlugin pluginClients map[string]*plugin.Client - logger zerolog.Logger } // LoadedPlugin represents a loaded plugin with its metadata. @@ -42,11 +41,10 @@ type LoadedPlugin struct { } // NewManager creates a new plugin manager. -func NewManager(logger zerolog.Logger) *Manager { +func NewManager() *Manager { return &Manager{ plugins: make(map[string]*LoadedPlugin), pluginClients: make(map[string]*plugin.Client), - logger: logger, } } @@ -75,25 +73,13 @@ func (m *Manager) LoadPlugins(ctx context.Context) error { pluginPath := filepath.Join(pluginsDir, entry.Name()) - info, err := entry.Info() - if err != nil { - m.logger.Err(err).Str("pluginPath", pluginPath).Msg("failed to get info for plugin") - continue - } - // On Windows, check for .exe extension if runtime.GOOS == "windows" && filepath.Ext(pluginPath) != ".exe" { continue } - // On Unix, check if executable - if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { - continue - } - - // Load the plugin + // Load the plugin - if there is an error just skip it - we can think of a better strategy later if err := m.loadPlugin(ctx, pluginPath); err != nil { - m.logger.Err(err).Str("pluginPath", pluginPath).Msg("failed to load plugin") continue } } @@ -110,6 +96,15 @@ func (m *Manager) loadPlugin(ctx context.Context, path string) error { AllowedProtocols: []plugin.Protocol{ plugin.ProtocolNetRPC, }, + // By default the go-plugin logger is set to TRACE level, which is very verbose. + // We can't set it to the level set by the command at this point, because we need to + // load the commands first before running the cobra command where the debug flag is set + // We set it to WARN level, so we don't get too much noise from the plugins. + Logger: hclog.New(&hclog.LoggerOptions{ + Output: hclog.DefaultOutput, + Level: hclog.Warn, + Name: "plugin", + }), }) // Connect via RPC @@ -148,7 +143,6 @@ func (m *Manager) loadPlugin(ctx context.Context, path string) error { } m.pluginClients[metadata.Name] = client - m.logger.Debug().Str("pluginName", metadata.Name).Str("pluginVersion", metadata.Version).Msg("loaded plugin") return nil } @@ -165,8 +159,7 @@ func (m *Manager) GetAllPlugins() map[string]*LoadedPlugin { // Shutdown closes all plugin connections. func (m *Manager) Shutdown() { - for name, client := range m.pluginClients { - m.logger.Debug().Str("pluginName", name).Msg("shutting down plugin") + for _, client := range m.pluginClients { client.Kill() } } From c0deb714cd270e52ccb0a15fa6a49857b4986a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Mon, 9 Jun 2025 22:39:36 +0200 Subject: [PATCH 07/15] Fix lint issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/internal/action/plugin_actions.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/cli/internal/action/plugin_actions.go b/app/cli/internal/action/plugin_actions.go index 3c7ad6364..be7885878 100644 --- a/app/cli/internal/action/plugin_actions.go +++ b/app/cli/internal/action/plugin_actions.go @@ -65,7 +65,7 @@ func NewPluginList(cfg *ActionsOpts, manager *plugins.Manager) *PluginList { } // Run executes the PluginList action -func (action *PluginList) Run(ctx context.Context) (*PluginListResult, error) { +func (action *PluginList) Run(_ context.Context) (*PluginListResult, error) { action.cfg.Logger.Debug().Msg("Listing all plugins") plugins := action.manager.GetAllPlugins() @@ -90,7 +90,7 @@ func NewPluginInfo(cfg *ActionsOpts, manager *plugins.Manager) *PluginInfo { } // Run executes the PluginInfo action -func (action *PluginInfo) Run(ctx context.Context, pluginName string) (*PluginInfoResult, error) { +func (action *PluginInfo) Run(_ context.Context, pluginName string) (*PluginInfoResult, error) { action.cfg.Logger.Debug().Str("pluginName", pluginName).Msg("Getting plugin info") plugin, ok := action.manager.GetPlugin(pluginName) if !ok { From 9b9ae9192220ad77620e5a8466017fa1db267186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Mon, 9 Jun 2025 22:46:54 +0200 Subject: [PATCH 08/15] Mod tidy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 94148b94f..9184704d5 100644 --- a/go.mod +++ b/go.mod @@ -74,6 +74,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/google/go-github/v66 v66.0.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 + github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/in-toto/attestation v1.1.0 github.com/invopop/jsonschema v0.7.0 @@ -156,7 +157,6 @@ require ( github.com/google/renameio/v2 v2.0.0 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect From 036bb7b51deb6a1fc39aa9015f84c752426e4183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Tue, 10 Jun 2025 21:23:09 +0200 Subject: [PATCH 09/15] Replace plugin info with plugin describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 23 ++++++++++++++++------- app/cli/internal/action/plugin_actions.go | 18 +++++++++--------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index 1b3d5785d..e95883475 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -49,7 +49,7 @@ func newPluginCmd() *cobra.Command { } cmd.AddCommand(newPluginListCmd()) - cmd.AddCommand(newPluginInfoCmd()) + cmd.AddCommand(newPluginDescribeCmd()) return cmd } @@ -178,14 +178,19 @@ func newPluginListCmd() *cobra.Command { } } -func newPluginInfoCmd() *cobra.Command { +func newPluginDescribeCmd() *cobra.Command { + var pluginName string + cmd := &cobra.Command{ - Use: "info [plugin-name]", + Use: "describe", Short: "Show detailed information about a plugin", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - pluginName := args[0] - result, err := action.NewPluginInfo(actionOpts, pluginManager).Run(context.Background(), pluginName) + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if pluginName == "" { + return fmt.Errorf("plugin name is required") + } + + result, err := action.NewPluginDescribe(actionOpts, pluginManager).Run(context.Background(), pluginName) if err != nil { return err } @@ -215,6 +220,10 @@ func newPluginInfoCmd() *cobra.Command { return nil }, } + + cmd.Flags().StringVarP(&pluginName, "name", "", "", "Name of the plugin to describe (required)") + cmd.MarkFlagRequired("name") + return cmd } diff --git a/app/cli/internal/action/plugin_actions.go b/app/cli/internal/action/plugin_actions.go index be7885878..0b227a376 100644 --- a/app/cli/internal/action/plugin_actions.go +++ b/app/cli/internal/action/plugin_actions.go @@ -29,7 +29,7 @@ type PluginList struct { } // PluginInfo handles showing detailed information about a specific plugin -type PluginInfo struct { +type PluginDescribe struct { cfg *ActionsOpts manager *plugins.Manager } @@ -46,8 +46,8 @@ type PluginListResult struct { CommandsMap map[string]string // Maps command names to plugin names } -// PluginInfoResult represents the result of getting plugin info -type PluginInfoResult struct { +// PluginDescribeResult represents the result of getting plugin info +type PluginDescribeResult struct { Plugin *plugins.LoadedPlugin } @@ -84,13 +84,13 @@ func (action *PluginList) Run(_ context.Context) (*PluginListResult, error) { }, nil } -// NewPluginInfo creates a new PluginInfo action -func NewPluginInfo(cfg *ActionsOpts, manager *plugins.Manager) *PluginInfo { - return &PluginInfo{cfg: cfg, manager: manager} +// NewPluginDescribe creates a new NewPluginDescribe action +func NewPluginDescribe(cfg *ActionsOpts, manager *plugins.Manager) *PluginDescribe { + return &PluginDescribe{cfg: cfg, manager: manager} } -// Run executes the PluginInfo action -func (action *PluginInfo) Run(_ context.Context, pluginName string) (*PluginInfoResult, error) { +// Run executes the NewPluginDescribe action +func (action *PluginDescribe) Run(_ context.Context, pluginName string) (*PluginDescribeResult, error) { action.cfg.Logger.Debug().Str("pluginName", pluginName).Msg("Getting plugin info") plugin, ok := action.manager.GetPlugin(pluginName) if !ok { @@ -98,7 +98,7 @@ func (action *PluginInfo) Run(_ context.Context, pluginName string) (*PluginInfo } action.cfg.Logger.Debug().Str("pluginName", pluginName).Str("version", plugin.Metadata.Version).Int("commandCount", len(plugin.Metadata.Commands)).Msg("Found plugin") - return &PluginInfoResult{ + return &PluginDescribeResult{ Plugin: plugin, }, nil } From 879c8b04f49cdc0de611b9ab736da9b7585975e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Tue, 10 Jun 2025 21:28:59 +0200 Subject: [PATCH 10/15] Fix lint error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index e95883475..3eb406afc 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -222,7 +222,7 @@ func newPluginDescribeCmd() *cobra.Command { } cmd.Flags().StringVarP(&pluginName, "name", "", "", "Name of the plugin to describe (required)") - cmd.MarkFlagRequired("name") + cobra.CheckErr(cmd.MarkFlagRequired("name")) return cmd } From 4fa8568b60fa51185822d41fe1efce60c62ae620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Tue, 10 Jun 2025 21:31:03 +0200 Subject: [PATCH 11/15] Docs update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/documentation/cli-reference.mdx | 27 +++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 355c7dc6e..8ca6bf0e3 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2565,23 +2565,19 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` -### chainloop plugin help - -Help about any command +### chainloop plugin describe -Synopsis - -Help provides help for any command in the application. -Simply type plugin help [path to command] for full details. +Show detailed information about a plugin ``` -chainloop plugin help [command] [flags] +chainloop plugin describe [flags] ``` Options ``` --h, --help help for help +-h, --help help for describe +--name string Name of the plugin to describe (required) ``` Options inherited from parent commands @@ -2600,18 +2596,23 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` -### chainloop plugin info +### chainloop plugin help -Show detailed information about a plugin +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type plugin help [path to command] for full details. ``` -chainloop plugin info [plugin-name] [flags] +chainloop plugin help [command] [flags] ``` Options ``` --h, --help help for info +-h, --help help for help ``` Options inherited from parent commands From c5351ac0636e5f985c05e5c8f4a28304dc372a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Tue, 10 Jun 2025 22:24:11 +0200 Subject: [PATCH 12/15] Extract configuration to common MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/root.go | 8 +++----- app/cli/common/common.go | 35 +++++++++++++++++++++++++++++++++++ app/cli/plugins/manager.go | 8 ++------ 3 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 app/cli/common/common.go diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 8b6e79881..cb515766f 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -21,12 +21,11 @@ import ( "errors" "fmt" "os" - "path/filepath" "strings" "sync" "time" - "github.com/adrg/xdg" + "github.com/chainloop-dev/chainloop/app/cli/common" "github.com/chainloop-dev/chainloop/app/cli/internal/action" "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry" "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry/posthog" @@ -57,7 +56,6 @@ const ( useAPIToken = "withAPITokenAuth" // Ask for confirmation when user token is used and API token is preferred confirmWhenUserToken = "confirmWhenUserToken" - appName = "chainloop" //nolint:gosec tokenEnvVarName = "CHAINLOOP_TOKEN" userAudience = "user-auth.chainloop" @@ -98,7 +96,7 @@ func Execute(l zerolog.Logger) error { func NewRootCmd(l zerolog.Logger) *cobra.Command { rootCmd := &cobra.Command{ - Use: appName, + Use: common.AppName, Short: "Chainloop Command Line Interface", SilenceErrors: true, SilenceUsage: true, @@ -310,7 +308,7 @@ func initConfigFile() { } // If no config file was passed as a flag we use the default one - configPath := filepath.Join(xdg.ConfigHome, appName) + configPath := common.GetConfigDir() // Create the file if it does not exist if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { err := os.MkdirAll(configPath, os.ModePerm) diff --git a/app/cli/common/common.go b/app/cli/common/common.go new file mode 100644 index 000000000..b39afaae6 --- /dev/null +++ b/app/cli/common/common.go @@ -0,0 +1,35 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "path/filepath" + + "github.com/adrg/xdg" +) + +const ( + AppName = "chainloop" + PluginsDir = "plugins" +) + +func GetPluginsDir() string { + return filepath.Join(xdg.ConfigHome, AppName, PluginsDir) +} + +func GetConfigDir() string { + return filepath.Join(xdg.ConfigHome, AppName) +} diff --git a/app/cli/plugins/manager.go b/app/cli/plugins/manager.go index 0c2b29b57..cd7e8ecff 100644 --- a/app/cli/plugins/manager.go +++ b/app/cli/plugins/manager.go @@ -23,6 +23,7 @@ import ( "path/filepath" "runtime" + "github.com/chainloop-dev/chainloop/app/cli/common" "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-plugin" ) @@ -50,12 +51,7 @@ func NewManager() *Manager { // LoadPlugins loads all plugins from the plugins directory. func (m *Manager) LoadPlugins(ctx context.Context) error { - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to get user home directory: %w", err) - } - - pluginsDir := filepath.Join(homeDir, ".config", "chainloop", "plugins") // TODO: make this configurable + pluginsDir := common.GetPluginsDir() if err := os.MkdirAll(pluginsDir, 0755); err != nil { return fmt.Errorf("failed to create plugins directory: %w", err) From 12090e3bdb1d2c2de3edd66c5cbf8462377db17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Tue, 10 Jun 2025 22:42:09 +0200 Subject: [PATCH 13/15] Use Discover from go-plugin instead of manually reading the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/common/common.go | 6 ++++++ app/cli/plugins/manager.go | 27 ++++++++++----------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/cli/common/common.go b/app/cli/common/common.go index b39afaae6..5d626e7de 100644 --- a/app/cli/common/common.go +++ b/app/cli/common/common.go @@ -17,6 +17,7 @@ package common import ( "path/filepath" + "runtime" "github.com/adrg/xdg" ) @@ -33,3 +34,8 @@ func GetPluginsDir() string { func GetConfigDir() string { return filepath.Join(xdg.ConfigHome, AppName) } + +// IsWindows returns true if the current OS is Windows +func IsWindows() bool { + return runtime.GOOS == "windows" +} diff --git a/app/cli/plugins/manager.go b/app/cli/plugins/manager.go index cd7e8ecff..ef9ba6eea 100644 --- a/app/cli/plugins/manager.go +++ b/app/cli/plugins/manager.go @@ -20,8 +20,6 @@ import ( "fmt" "os" "os/exec" - "path/filepath" - "runtime" "github.com/chainloop-dev/chainloop/app/cli/common" "github.com/hashicorp/go-hclog" @@ -57,25 +55,20 @@ func (m *Manager) LoadPlugins(ctx context.Context) error { return fmt.Errorf("failed to create plugins directory: %w", err) } - entries, err := os.ReadDir(pluginsDir) - if err != nil { - return fmt.Errorf("failed to read plugins directory: %w", err) + // Use appropriate glob pattern based on OS + glob := "*" + if common.IsWindows() { + glob = "*.exe" } - for _, entry := range entries { - if entry.IsDir() { - continue - } - - pluginPath := filepath.Join(pluginsDir, entry.Name()) - - // On Windows, check for .exe extension - if runtime.GOOS == "windows" && filepath.Ext(pluginPath) != ".exe" { - continue - } + plugins, err := plugin.Discover(glob, pluginsDir) + if err != nil { + return fmt.Errorf("failed to discover plugins: %w", err) + } + for _, plugin := range plugins { // Load the plugin - if there is an error just skip it - we can think of a better strategy later - if err := m.loadPlugin(ctx, pluginPath); err != nil { + if err := m.loadPlugin(ctx, plugin); err != nil { continue } } From 002a53ea376e167d16556417fc7c7b577ec1381d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Wed, 11 Jun 2025 20:53:39 +0200 Subject: [PATCH 14/15] Avoid print empty describe table when no flags are provided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index 3eb406afc..21872405c 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -323,6 +323,21 @@ func pluginInfoCommandsTableOutput(plugin *plugins.LoadedPlugin) { } func pluginInfoFlagsTableOutput(plugin *plugins.LoadedPlugin) { + if len(plugin.Metadata.Commands) == 0 { + return + } + + flagsPresent := false + for _, cmd := range plugin.Metadata.Commands { + if len(cmd.Flags) > 0 { + flagsPresent = true + } + } + + if !flagsPresent { + return + } + t := newTableWriter() t.AppendHeader(table.Row{"Plugin", "Command", "Flag", "Description", "Type", "Default", "Required"}) From d11f2963a3232626c14b9f86d89ba3e8a791a87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ku=C4=87?= Date: Wed, 11 Jun 2025 21:43:47 +0200 Subject: [PATCH 15/15] Add default flags and pass them to the plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rafał Kuć --- app/cli/cmd/plugins.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go index 21872405c..31e051e72 100644 --- a/app/cli/cmd/plugins.go +++ b/app/cli/cmd/plugins.go @@ -24,6 +24,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/plugins" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" + "github.com/spf13/viper" ) const ( @@ -86,6 +87,11 @@ func createPluginCommand(plugin *plugins.LoadedPlugin, cmdInfo plugins.CommandIn // Add positional arguments arguments["args"] = args + // Pass the persistent flags from the root command to the plugin command + arguments["chainloop_cp_url"] = viper.GetString(confOptions.controlplaneAPI.viperKey) + arguments["chainloop_cas_url"] = viper.GetString(confOptions.CASAPI.viperKey) + arguments["chainloop_api_token"] = apiToken + // Execute plugin command using the action pattern result, err := action.NewPluginExec(actionOpts, pluginManager).Run(ctx, plugin.Metadata.Name, cmdInfo.Name, arguments) if err != nil { @@ -97,7 +103,6 @@ func createPluginCommand(plugin *plugins.LoadedPlugin, cmdInfo plugins.CommandIn return fmt.Errorf("the plugin command failed: %s", result.Error) } - // TODO: for now just print the output fmt.Print(result.Output) // Return with appropriate exit code