From a4eb34675a491a9b2ae85787f0a07d63f97ab67e Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 22 Oct 2026 18:52:00 +0200 Subject: [PATCH] feat(cli): remove the CLI plugin extension mechanism Remove the preview CLI plugin system (app/cli/pkg/plugins, the 'config plugin' subcommands, and their wiring in root.go/config.go). The reason for removal: this extension mechanism never went past the alpha/preview stage and is not used in practice, while it carried real costs - loading plugins spawns a subprocess and an RPC handshake per installed plugin on every CLI invocation, and the mechanism adds code surface that must be maintained. The control-plane integration plugin system (app/controlplane/plugins) is a separate mechanism and is unaffected. Note: 'hashicorp/go-plugin' remains a direct dependency, used by the control plane plugin system. 'hashicorp/go-hclog' moves to indirect. Chainloop-Trace-Sessions: ses_e9b7a92b5ffeC3O1vtCSs977NE Signed-off-by: Miguel Martinez Trivino --- app/cli/cmd/config.go | 2 +- app/cli/cmd/plugins.go | 449 ------------------------- app/cli/cmd/root.go | 30 +- app/cli/documentation/cli-reference.md | 166 --------- app/cli/documentation/generate.go | 9 +- app/cli/pkg/action/plugin_actions.go | 223 ------------ app/cli/pkg/plugins/client.go | 86 ----- app/cli/pkg/plugins/interface.go | 97 ------ app/cli/pkg/plugins/manager.go | 152 --------- app/cli/pkg/plugins/shared.go | 48 --- go.mod | 2 +- 11 files changed, 7 insertions(+), 1257 deletions(-) delete mode 100644 app/cli/cmd/plugins.go delete mode 100644 app/cli/pkg/action/plugin_actions.go delete mode 100644 app/cli/pkg/plugins/client.go delete mode 100644 app/cli/pkg/plugins/interface.go delete mode 100644 app/cli/pkg/plugins/manager.go delete mode 100644 app/cli/pkg/plugins/shared.go diff --git a/app/cli/cmd/config.go b/app/cli/cmd/config.go index 4db1ef9ac..7b7756312 100644 --- a/app/cli/cmd/config.go +++ b/app/cli/cmd/config.go @@ -66,6 +66,6 @@ func newConfigCmd() *cobra.Command { Short: "Configure this client", } - cmd.AddCommand(newConfigSaveCmd(), newConfigViewCmd(), newConfigResetCmd(), newPluginCmd()) + cmd.AddCommand(newConfigSaveCmd(), newConfigViewCmd(), newConfigResetCmd()) return cmd } diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go deleted file mode 100644 index 98f2050c9..000000000 --- a/app/cli/cmd/plugins.go +++ /dev/null @@ -1,449 +0,0 @@ -// -// 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" - "strconv" - "strings" - - "github.com/chainloop-dev/chainloop/app/cli/cmd/output" - "github.com/chainloop-dev/chainloop/app/cli/pkg/action" - "github.com/chainloop-dev/chainloop/app/cli/pkg/plugins" - "github.com/jedib0t/go-pretty/v6/table" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -const ( - stringFlagType = "string" - boolFlagType = "bool" - intFlagType = "int" -) - -var ( - pluginManager *plugins.Manager - registeredCommands map[string]string // Track which plugin registered which command -) - -func init() { - registeredCommands = make(map[string]string) -} - -func newPluginCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "plugin", - Short: "Manage plugins (preview)", - Long: "Manage Chainloop CLI plugins (preview)", - } - - cmd.AddCommand(newPluginListCmd()) - cmd.AddCommand(newPluginDescribeCmd()) - cmd.AddCommand(newPluginInstallCmd()) - - return cmd -} - -func createPluginCommand(_ *cobra.Command, plugin *plugins.LoadedPlugin, cmdInfo *plugins.PluginCommandInfo) *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 all flags that were set - flags := make(map[string]*plugins.SimpleFlag) - - for _, flag := range cmdInfo.Flags { - simpleFlag := &plugins.SimpleFlag{ - Name: flag.Name, - Shorthand: flag.Shorthand, - Usage: flag.Description, - } - - switch flag.Type { - case stringFlagType: - if val, err := cmd.Flags().GetString(flag.Name); err == nil { - simpleFlag.Value = val - } - case boolFlagType: - if val, err := cmd.Flags().GetBool(flag.Name); err == nil { - simpleFlag.Value = strconv.FormatBool(val) - } - case intFlagType: - if val, err := cmd.Flags().GetInt(flag.Name); err == nil { - simpleFlag.Value = strconv.Itoa(val) - } - } - flags[flag.Name] = simpleFlag - } - - // instead of processing the persistent flags, we try to get them directly from viper - cliConfig := plugins.ChainloopConfig{ - ControlPlaneAPI: viper.GetString(confOptions.controlplaneAPI.viperKey), - ControlPlaneCA: viper.GetString(confOptions.controlplaneCA.viperKey), - CASAPI: viper.GetString(confOptions.CASAPI.viperKey), - CASCA: viper.GetString(confOptions.CASCA.viperKey), - Organization: viper.GetString(confOptions.organization.viperKey), - Token: viper.GetString(confOptions.authToken.viperKey), - } - - // Create platform configuration from viper - platformConfig := plugins.PlatformConfig{ - API: viper.GetString(confOptions.platformAPI.viperKey), - } - - // Create plugin configuration with command, arguments, and flags - config := plugins.PluginExecConfig{ - Command: cmdInfo.Name, - ParentCommand: cmdInfo.ParentCommand, - Args: args, - Flags: flags, - ChainloopConfig: cliConfig, - PlatformConfig: platformConfig, - } - - // execute plugin command using the action pattern - result, err := action.NewPluginExec(ActionOpts, pluginManager).Run(ctx, plugin.Metadata.Name, cmdInfo.Name, config) - if err != nil { - return fmt.Errorf("failed to execute plugin command: %w", err) - } - - // handle result - if result.Error != "" { - return fmt.Errorf("the plugin command failed: %s", result.Error) - } - - fmt.Print(result.Output) - - // return with appropriate exit code - if result.ExitCode != 0 { - os.Exit(result.ExitCode) - } - - return nil - }, - } - - // add flags to the command - for _, flag := range cmdInfo.Flags { - switch flag.Type { - case stringFlagType: - defaultVal, _ := flag.Default.(string) - cmd.Flags().String(flag.Name, defaultVal, flag.Description) - case boolFlagType: - defaultVal, _ := flag.Default.(bool) - cmd.Flags().Bool(flag.Name, defaultVal, flag.Description) - case intFlagType: - defaultVal, _ := flag.Default.(int) - cmd.Flags().Int(flag.Name, defaultVal, flag.Description) - } - - if flag.Required { - err := cmd.MarkFlagRequired(flag.Name) - cobra.CheckErr(err) - } - } - - return cmd -} - -func newPluginListCmd() *cobra.Command { - return &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List installed plugins", - RunE: func(_ *cobra.Command, _ []string) error { - result, err := action.NewPluginList(ActionOpts, pluginManager).Run(context.Background()) - if err != nil { - return err - } - - if flagOutputFormat == output.FormatJSON { - type pluginInfo struct { - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Path string `json:"path"` - } - - var items []pluginInfo - for name, plugin := range result.Plugins { - items = append(items, pluginInfo{ - Name: name, - Version: plugin.Metadata.Version, - Description: plugin.Metadata.Description, - Path: plugin.Path, - }) - } - - return output.EncodeJSON(items) - } - - pluginListTableOutput(result.Plugins) - - return nil - }, - } -} - -func newPluginDescribeCmd() *cobra.Command { - var pluginName string - - cmd := &cobra.Command{ - Use: "describe", - Short: "Show detailed information about a plugin", - 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 - } - - if flagOutputFormat == output.FormatJSON { - type pluginDetail struct { - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Path string `json:"path"` - Commands []*plugins.PluginCommandInfo `json:"commands"` - } - - detail := pluginDetail{ - 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 output.EncodeJSON(detail) - } - - pluginInfoTableOutput(result.Plugin) - - return nil - }, - } - - cmd.Flags().StringVarP(&pluginName, "name", "", "", "Name of the plugin to describe (required)") - cobra.CheckErr(cmd.MarkFlagRequired("name")) - - return cmd -} - -func newPluginInstallCmd() *cobra.Command { - var file string - var location string - - cmd := &cobra.Command{ - Use: "install", - Short: "Install a plugin", - Long: "Install a plugin to the plugins directory from a specified URL or local file.", - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - opts := &action.PluginInstallOptions{ - File: file, - Location: location, - } - - result, err := action.NewPluginInstall(ActionOpts, pluginManager).Run(ctx, opts) - if err != nil { - return fmt.Errorf("failed to install plugin: %w", err) - } - - fmt.Printf("Plugin installed successfully to: %s\n", result.FilePath) - return nil - }, - } - - // Common flags - cmd.Flags().StringVarP(&file, "file", "f", "", "URL or path to the plugin to install") - cobra.CheckErr(cmd.MarkFlagRequired("file")) - - return cmd -} - -// findCommand recursively searches for a command by name in the command tree -func findCommand(rootCmd *cobra.Command, name string) *cobra.Command { - // Check if the root command itself matches - if rootCmd.Name() == name { - return rootCmd - } - - // Search through all subcommands recursively - for _, cmd := range rootCmd.Commands() { - if cmd.Name() == name { - return cmd - } - // Recursively search in subcommands - if found := findCommand(cmd, name); found != nil { - return found - } - } - - return nil -} - -// loadAllPlugins loads all plugins and registers their commands to the root command -func loadAllPlugins(rootCmd *cobra.Command) error { - ctx := rootCmd.Context() - - // 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 { - return nil - } - - // Register commands from all plugins, checking for conflicts - for pluginName, plugin := range allPlugins { - 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) - } - - pluginCmd := createPluginCommand(rootCmd, plugin, cmdInfo) - - // If ParentCommand is specified, try to find the parent and add as subcommand otherwise add as top-level command - if cmdInfo.ParentCommand != "" { - parentCmd := findCommand(rootCmd, cmdInfo.ParentCommand) - if parentCmd == nil { - return fmt.Errorf("parent command '%s' not found for plugin command '%s' from plugin '%s'", - cmdInfo.ParentCommand, cmdInfo.Name, pluginName) - } - parentCmd.AddCommand(pluginCmd) - } else { - rootCmd.AddCommand(pluginCmd) - } - - registeredCommands[cmdInfo.Name] = pluginName - } - } - - 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) { - if len(plugins) == 0 { - fmt.Println("No plugins installed") - return - } - - t := output.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() -} - -func pluginInfoTableOutput(plugin *plugins.LoadedPlugin) { - t := output.NewTableWriter() - t.SetTitle(fmt.Sprintf("Plugin: %s", plugin.Metadata.Name)) - t.AppendSeparator() - t.AppendRow(table.Row{"Version", plugin.Metadata.Version}) - t.AppendSeparator() - t.AppendRow(table.Row{"Description", plugin.Metadata.Description}) - t.AppendSeparator() - t.AppendRow(table.Row{"Commands", fmt.Sprintf("%d command(s)", len(plugin.Metadata.Commands))}) - t.AppendSeparator() - t.Render() - - pluginInfoFlagsTableOutput(plugin) -} - -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 - } - - for _, cmd := range plugin.Metadata.Commands { - t := output.NewTableWriter() - t.SetTitle(fmt.Sprintf("Command: %s", cmd.Name)) - t.AppendSeparator() - - flagDetails := "Flags:\n" - - // Find the longest flag name to align descriptions properly - maxFlagLen := 0 - for _, flag := range cmd.Flags { - if len(flag.Name) > maxFlagLen { - maxFlagLen = len(flag.Name) - } - } - - maxFlagLen += 2 // For the "--" prefix - for _, flag := range cmd.Flags { - shorthand := " " - if flag.Shorthand != "" { - shorthand = fmt.Sprintf("-%s,", flag.Shorthand) - } - flagName := fmt.Sprintf(" %s--%s", shorthand, flag.Name) - padding := strings.Repeat(" ", maxFlagLen-len(flag.Name)+2) - - defaultValue := "" - if flag.Default != nil { - defaultValue = fmt.Sprintf(" (default: %v)", flag.Default) - } - - flagDetails += fmt.Sprintf("%s%s%s%s\n", flagName, padding, flag.Description, defaultValue) - } - - t.AppendRow(table.Row{flagDetails}) - t.Render() - } -} diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 4e2b18ea1..68a0e8b63 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -27,7 +27,6 @@ import ( "github.com/adrg/xdg" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" - "github.com/chainloop-dev/chainloop/app/cli/pkg/plugins" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" "github.com/chainloop-dev/chainloop/pkg/grpcconn" @@ -86,20 +85,9 @@ const ( // Environment variable prefix for vipers const envPrefix = "CHAINLOOP" -// pluginLessCommands are the commands that never make use of plugins, keyed by the first -// argument on the command line. Loading plugins starts a subprocess per installed plugin, -// so it is skipped for the commands that cannot benefit from it. -var pluginLessCommands = map[string]bool{ - "completion": true, - "help": true, - telemetryCmdUse: true, -} - -// processStart is when this process began, as close to it as a package variable gets. The -// reported duration is measured from here rather than from the top of Execute because -// NewRootCmd runs first and loads plugins, which starts a subprocess and an RPC handshake -// per plugin. Timing from Execute would leave that out for exactly the users who have -// plugins installed, making their numbers incomparable with everyone else's. +// processStart is when this process began, as close to it as a package variable gets. +// The reported duration is measured from here rather than from the top of Execute +// so that setup work done in NewRootCmd is included. var processStart = time.Now() func Execute(rootCmd *cobra.Command) error { @@ -344,17 +332,6 @@ Command reference: ` + cliReferenceURL, newTraceCmd(), newTelemetryCmd(), ) - // Load plugins for root command and subcommands, except for the ones that cannot use - // them. `telemetry` is in that list because it is the detached child that delivers an - // analytics event: loading plugins there would spawn a subprocess and an RPC handshake - // per installed plugin, for a process whose whole job is one HTTP request. - if len(os.Args) == 1 || (len(os.Args) > 1 && !pluginLessCommands[os.Args[1]]) { - pluginManager = plugins.NewManager(&logger) - if err := loadAllPlugins(rootCmd); err != nil { - logger.Error().Err(err).Msg("Failed to load plugins, continuing with built-in commands only") - } - } - return rootCmd } @@ -449,7 +426,6 @@ 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/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 471e73bcf..6993988ae 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -1301,172 +1301,6 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` -### chainloop config plugin - -Manage plugins (preview) - -Synopsis - -Manage Chainloop CLI plugins (preview) - -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) ---max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) --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 config plugin describe - -Show detailed information about a plugin - -``` -chainloop config plugin describe [flags] -``` - -Options - -``` --h, --help help for describe ---name string Name of the plugin to describe (required) -``` - -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) ---max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) --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 config 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 config 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) ---max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) --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 config plugin install - -Install a plugin - -Synopsis - -Install a plugin to the plugins directory from a specified URL or local file. - -``` -chainloop config plugin install [flags] -``` - -Options - -``` --f, --file string URL or path to the plugin to install --h, --help help for install -``` - -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) ---max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) --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 config plugin list - -List installed plugins - -``` -chainloop config 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) ---max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) --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 config reset Reset the CLI configuration diff --git a/app/cli/documentation/generate.go b/app/cli/documentation/generate.go index df429c9b6..df802285f 100644 --- a/app/cli/documentation/generate.go +++ b/app/cli/documentation/generate.go @@ -58,7 +58,7 @@ func main() { var builder strings.Builder for _, subCmd := range command.Commands() { - if !subCmd.Hidden && !isPluginCommand(subCmd) { + if !subCmd.Hidden { // Start depth at 0 for subcommands generateCommandDocs(subCmd, &builder, 0) } @@ -88,7 +88,7 @@ func generateCommandDocs(cmd *cobra.Command, builder *strings.Builder, currentDe // Recursively process subcommands with increased depth for _, subCmd := range cmd.Commands() { - if !subCmd.Hidden && !isPluginCommand(subCmd) { + if !subCmd.Hidden { generateCommandDocs(subCmd, builder, currentDepth+1) } } @@ -154,11 +154,6 @@ func isUnderlineHeader(line string) bool { return regexp.MustCompile(`^[-=]+$`).MatchString(line) } -// isPluginCommand checks if a command is from a plugin by looking for the plugin signature in the Long description -func isPluginCommand(cmd *cobra.Command) bool { - return strings.Contains(cmd.Long, "Provided by plugin:") -} - // processFinalDocument cleans up the final document by removing excessive newlines and headers. func processFinalDocument(content string) string { // Clean up excessive newlines and leftover headers diff --git a/app/cli/pkg/action/plugin_actions.go b/app/cli/pkg/action/plugin_actions.go deleted file mode 100644 index dbf01528b..000000000 --- a/app/cli/pkg/action/plugin_actions.go +++ /dev/null @@ -1,223 +0,0 @@ -// -// 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" - "os" - "path/filepath" - - "github.com/chainloop-dev/chainloop/app/cli/pkg/plugins" -) - -// PluginList handles listing installed plugins -type PluginList struct { - cfg *ActionsOpts - manager *plugins.Manager -} - -// PluginInfo handles showing detailed information about a specific plugin -type PluginDescribe 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 -} - -// PluginDescribeResult represents the result of getting plugin info -type PluginDescribeResult 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 -} - -// PluginInstall handles downloading a plugin -type PluginInstall struct { - cfg *ActionsOpts - manager *plugins.Manager -} - -// PluginInstallOptions contains all options for installing a plugin -type PluginInstallOptions struct { - File string - Filename string - Location string -} - -// PluginInstallResult represents the result of installing a plugin -type PluginInstallResult struct { - FilePath string -} - -// 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(_ 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 -} - -// NewPluginDescribe creates a new NewPluginDescribe action -func NewPluginDescribe(cfg *ActionsOpts, manager *plugins.Manager) *PluginDescribe { - return &PluginDescribe{cfg: cfg, manager: manager} -} - -// 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 { - 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 &PluginDescribeResult{ - 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, config plugins.PluginExecConfig) (*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, config) - 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.Error != "" { - action.cfg.Logger.Error().Str("pluginName", pluginName).Str("command", commandName).Str("error", result.Error).Msg("Plugin returned error") - } - - action.cfg.Logger.Debug().Str("pluginName", pluginName).Str("command", commandName).Int("exitCode", result.ExitCode).Msg("Plugin command executed") - return &PluginExecResult{ - Output: result.Output, - Error: result.Error, - ExitCode: result.ExitCode, - }, nil -} - -// NewPluginInstall creates a new PluginInstall action -func NewPluginInstall(cfg *ActionsOpts, manager *plugins.Manager) *PluginInstall { - return &PluginInstall{cfg: cfg, manager: manager} -} - -// Run executes the PluginInstall action -func (action *PluginInstall) Run(_ context.Context, opts *PluginInstallOptions) (*PluginInstallResult, error) { - action.cfg.Logger.Debug().Str("file", opts.File).Msg("Downloading plugin") - - // create plugins directory if it doesn't exist - pluginsDir := plugins.GetPluginsDir("chainloop") - if err := os.MkdirAll(pluginsDir, 0755); err != nil { - action.cfg.Logger.Error().Err(err).Str("directory", pluginsDir).Msg("Failed to create plugins directory") - return nil, fmt.Errorf("failed to create plugins directory: %w", err) - } - - // determine filename - filename := filepath.Base(opts.File) - if filename == "." || filename == "/" { - action.cfg.Logger.Error().Str("file", opts.File).Msg("Invalid URL or object key, could not determine filename") - return nil, fmt.Errorf("invalid URL or object key, could not determine filename") - } - - filePath := filepath.Join(pluginsDir, filename) - - // create a temporary file for downloading - tempFilePath := filePath + ".tmp" - tempFile, err := os.Create(tempFilePath) - if err != nil { - action.cfg.Logger.Error().Err(err).Str("path", tempFilePath).Msg("Failed to create temporary file") - return nil, fmt.Errorf("failed to create temporary file: %w", err) - } - defer tempFile.Close() - - rawFileContents, err := LoadFileOrURL(opts.File) - if err != nil { - return nil, fmt.Errorf("failed to load file from a given URL: %w", err) - } - - // write to the temporary file - _, err = tempFile.Write(rawFileContents) - if err != nil { - return nil, fmt.Errorf("failed to write file to temporary file: %w", err) - } - - // close the file before renaming - tempFile.Close() - - // rename the temporary file to the final file - if err := os.Rename(tempFilePath, filePath); err != nil { - os.Remove(tempFilePath) - action.cfg.Logger.Error().Err(err).Str("tempPath", tempFilePath).Str("finalPath", filePath).Msg("Failed to rename temporary file") - return nil, fmt.Errorf("failed to rename temporary file: %w", err) - } - - // Set executable permissions for the plugin file - // This is needed, so we can run the plugin and serve the gRPC - if err := os.Chmod(filePath, 0755); err != nil { - action.cfg.Logger.Error().Err(err).Str("path", filePath).Msg("Failed to set executable permissions") - return nil, fmt.Errorf("failed to set executable permissions: %w", err) - } - - action.cfg.Logger.Debug().Str("file", opts.File).Str("path", filePath).Msg("Plugin downloaded successfully") - return &PluginInstallResult{ - FilePath: filePath, - }, nil -} diff --git a/app/cli/pkg/plugins/client.go b/app/cli/pkg/plugins/client.go deleted file mode 100644 index 21fd23423..000000000 --- a/app/cli/pkg/plugins/client.go +++ /dev/null @@ -1,86 +0,0 @@ -// -// 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 ( - "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(_ *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(_ context.Context, config PluginExecConfig) (*PluginExecResult, error) { - var resp PluginExecResult - err := m.client.Call("Plugin.Exec", config, &resp) - if err != nil { - return nil, err - } - return &resp, nil -} - -func (m *RPCClient) GetMetadata(_ 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(config PluginExecConfig, resp *PluginExecResult) error { - ctx := context.Background() - - result, err := m.Impl.Exec(ctx, config) - if err != nil { - return err - } - - *resp = PluginExecResult{ - Output: result.Output, - Error: result.Error, - ExitCode: result.ExitCode, - } - return nil -} - -func (m *RPCServer) GetMetadata(_ any, resp *PluginMetadata) error { - metadata, err := m.Impl.GetMetadata(context.Background()) - if err != nil { - return err - } - *resp = metadata - return nil -} diff --git a/app/cli/pkg/plugins/interface.go b/app/cli/pkg/plugins/interface.go deleted file mode 100644 index 99f7463dd..000000000 --- a/app/cli/pkg/plugins/interface.go +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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 ( - "context" -) - -// Plugin is the interface that plugins must implement. -type Plugin interface { - // Exec executes a command within the plugin - Exec(ctx context.Context, config PluginExecConfig) (*PluginExecResult, error) - - // GetMetadata returns plugin metadata including commands it provides - GetMetadata(ctx context.Context) (PluginMetadata, error) -} - -type PluginExecResult struct { - Output string - Error string - ExitCode int -} - -// PluginMetadata contains information about the plugin. -type PluginMetadata struct { - Name string - Version string - Description string - Commands []*PluginCommandInfo -} - -// PluginCommandInfo describes a command provided by the plugin -type PluginCommandInfo struct { - Name string - Description string - Usage string - Flags []*FlagInfo - // Optional: if specified, this command will be added as a subcommand to the parent - ParentCommand string -} - -// FlagInfo describes a command flag. -type FlagInfo struct { - Name string - Shorthand string - Description string - Type string - Default any - Required bool -} - -// PluginExecConfig is the configuration for a plugin command execution. -type PluginExecConfig struct { - Command string - // Optional: the parent command if this is a subcommand - ParentCommand string - Args []string - Flags map[string]*SimpleFlag - ChainloopConfig ChainloopConfig - PlatformConfig PlatformConfig -} - -type PlatformConfig struct { - API string -} - -type SimpleFlag struct { - Name string - Shorthand string - Usage string - Value string - DefValue string - Changed bool - NoOptDefVal string -} - -type ChainloopConfig struct { - ControlPlaneAPI string - ControlPlaneCA string - CASAPI string - CASCA string - Organization string - Token string -} diff --git a/app/cli/pkg/plugins/manager.go b/app/cli/pkg/plugins/manager.go deleted file mode 100644 index 9bced5b03..000000000 --- a/app/cli/pkg/plugins/manager.go +++ /dev/null @@ -1,152 +0,0 @@ -// -// 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 ( - "context" - "fmt" - "os" - "os/exec" - - "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. -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 { - pluginsDir := GetPluginsDir("chainloop") - - if err := os.MkdirAll(pluginsDir, 0755); err != nil { - return fmt.Errorf("failed to create plugins directory: %w", err) - } - - plugins, err := plugin.Discover("*", 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, plugin); err != nil { - m.logger.Error().Msgf("failed to load plugin: %s", plugin) - m.logger.Debug().Err(err).Msgf("plugin loading failure reason: %s", 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, - }, - // 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 - 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 - - 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 _, client := range m.pluginClients { - client.Kill() - } -} diff --git a/app/cli/pkg/plugins/shared.go b/app/cli/pkg/plugins/shared.go deleted file mode 100644 index 23250c067..000000000 --- a/app/cli/pkg/plugins/shared.go +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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 ( - "encoding/gob" - "path/filepath" - - "github.com/adrg/xdg" - "github.com/hashicorp/go-plugin" -) - -func init() { - // Register types that will be sent over RPC - gob.Register(PluginExecConfig{}) - gob.Register(PluginExecResult{}) - gob.Register(PluginMetadata{}) - gob.Register(PluginCommandInfo{}) -} - -// 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{}, -} - -func GetPluginsDir(appName string) string { - return filepath.Join(xdg.ConfigHome, appName, "plugins") -} diff --git a/go.mod b/go.mod index 96d817272..68414eb07 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,6 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 - github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.8.0 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -302,6 +301,7 @@ require ( github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect