diff --git a/app/cli/cmd/plugins.go b/app/cli/cmd/plugins.go new file mode 100644 index 000000000..31e051e72 --- /dev/null +++ b/app/cli/cmd/plugins.go @@ -0,0 +1,357 @@ +// +// 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/app/cli/internal/action" + "github.com/chainloop-dev/chainloop/app/cli/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", + Long: "Manage Chainloop plugins for extended functionality", + } + + cmd.AddCommand(newPluginListCmd()) + cmd.AddCommand(newPluginDescribeCmd()) + + 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 stringFlagType: + if val, err := cmd.Flags().GetString(flag.Name); err == nil { + arguments[flag.Name] = val + } + case boolFlagType: + if val, err := cmd.Flags().GetBool(flag.Name); err == nil { + arguments[flag.Name] = val + } + case intFlagType: + if val, err := cmd.Flags().GetInt(flag.Name); err == nil { + arguments[flag.Name] = val + } + } + } + + // 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 { + 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 + 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 and their commands", + RunE: func(_ *cobra.Command, _ []string) error { + result, err := action.NewPluginList(actionOpts, pluginManager).Run(context.Background()) + if err != nil { + return err + } + + 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 result.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) + } + + pluginListTableOutput(result.Plugins, result.CommandsMap) + + 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 == 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: 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(result.Plugin) + + return nil + }, + } + + cmd.Flags().StringVarP(&pluginName, "name", "", "", "Name of the plugin to describe (required)") + cobra.CheckErr(cmd.MarkFlagRequired("name")) + + return cmd +} + +// 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 { + 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) + } + + // Register the command + pluginCmd := createPluginCommand(plugin, cmdInfo) + 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, commandsMap map[string]string) { + if len(plugins) == 0 { + fmt.Println("No plugins installed") + return + } + + 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 commandsMap { + t.AppendRow(table.Row{plugin, cmd}) + t.AppendSeparator() + } + t.Render() +} + +func pluginInfoTableOutput(plugin *plugins.LoadedPlugin) { + 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) +} + +func pluginInfoCommandsTableOutput(plugin *plugins.LoadedPlugin) { + 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() +} + +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"}) + 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() +} diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index aca56ef3d..cb515766f 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -21,15 +21,15 @@ 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" + "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" @@ -56,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" @@ -97,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, @@ -251,8 +250,17 @@ 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" { + pluginManager = plugins.NewManager() + if err := loadAllPlugins(rootCmd); err != nil { + logger.Error().Err(err).Msg("Failed to load plugins, continuing with built-in commands only") + } + } + return rootCmd } @@ -300,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) @@ -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/app/cli/common/common.go b/app/cli/common/common.go new file mode 100644 index 000000000..5d626e7de --- /dev/null +++ b/app/cli/common/common.go @@ -0,0 +1,41 @@ +// +// 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" + "runtime" + + "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) +} + +// IsWindows returns true if the current OS is Windows +func IsWindows() bool { + return runtime.GOOS == "windows" +} diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 20ac04df0..8ca6bf0e3 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2535,6 +2535,132 @@ 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 describe + +Show detailed information about a plugin + +``` +chainloop 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) +-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 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 diff --git a/app/cli/internal/action/plugin_actions.go b/app/cli/internal/action/plugin_actions.go new file mode 100644 index 000000000..0b227a376 --- /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 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 +} + +// 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, 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/client.go b/app/cli/plugins/client.go new file mode 100644 index 000000000..d20d62b29 --- /dev/null +++ b/app/cli/plugins/client.go @@ -0,0 +1,116 @@ +// +// 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, 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(_ 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(_ 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/app/cli/plugins/interface.go b/app/cli/plugins/interface.go new file mode 100644 index 000000000..7c12edfec --- /dev/null +++ b/app/cli/plugins/interface.go @@ -0,0 +1,70 @@ +// +// 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, 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/app/cli/plugins/manager.go b/app/cli/plugins/manager.go new file mode 100644 index 000000000..ef9ba6eea --- /dev/null +++ b/app/cli/plugins/manager.go @@ -0,0 +1,154 @@ +// +// 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/chainloop-dev/chainloop/app/cli/common" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-plugin" +) + +// Manager handles loading and managing plugins. +type Manager struct { + plugins map[string]*LoadedPlugin + pluginClients map[string]*plugin.Client +} + +// 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() *Manager { + return &Manager{ + plugins: make(map[string]*LoadedPlugin), + pluginClients: make(map[string]*plugin.Client), + } +} + +// LoadPlugins loads all plugins from the plugins directory. +func (m *Manager) LoadPlugins(ctx context.Context) error { + pluginsDir := common.GetPluginsDir() + + if err := os.MkdirAll(pluginsDir, 0755); err != nil { + return fmt.Errorf("failed to create plugins directory: %w", err) + } + + // Use appropriate glob pattern based on OS + glob := "*" + if common.IsWindows() { + glob = "*.exe" + } + + 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, plugin); err != nil { + 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/plugins/shared.go b/app/cli/plugins/shared.go new file mode 100644 index 000000000..f4eb7c8d0 --- /dev/null +++ b/app/cli/plugins/shared.go @@ -0,0 +1,48 @@ +// +// 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" + + "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{}, +} 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