diff --git a/changelog.md b/changelog.md index fb3b938dca..979a9d4190 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,7 @@ ### Features +- [#3238](https://github.com/ignite/cli/pull/3238) Add `Sharedhost` plugin option - [#3214](https://github.com/ignite/cli/pull/3214) Global plugins config. - [#3142](https://github.com/ignite/cli/pull/3142) Add `ignite network request param-change` command. - [#3181](https://github.com/ignite/cli/pull/3181) Addition of `add` `remove` commands for `plugins` diff --git a/docs/docs/contributing/01-plugins.md b/docs/docs/contributing/01-plugins.md index 8a80c63d42..39184d727d 100644 --- a/docs/docs/contributing/01-plugins.md +++ b/docs/docs/contributing/01-plugins.md @@ -25,7 +25,7 @@ plugins: ``` Now the next time the `ignite` command is run under your project, the declared -plugin will be fetched, compiled and ran. This will result in more avaiable +plugin will be fetched, compiled and ran. This will result in more available commands, and/or hooks attached to existing commands. ### Listing installed plugins @@ -129,6 +129,19 @@ type Manifest struct { // Hooks contains the hooks that will be attached to the existing ignite // commands. Hooks []Hook + // SharedHost enables sharing a single plugin server across all running instances + // of a plugin. Useful if a plugin adds or extends long running commands + // + // Example: if a plugin defines a hook on `ignite chain serve`, a plugin server is instanciated + // when the command is run. Now if you want to interact with that instance from commands + // defined in that plugin, you need to enable `SharedHost`, or else the commands will just + // instantiate separate plugin servers. + // + // When enabled, all plugins of the same `Path` loaded from the same configuration will + // attach it's rpc client to a an existing rpc server. + // + // If a plugin instance has no other running plugin servers, it will create one and it will be the host. + SharedHost bool `yaml:"shared_host"` } ``` @@ -142,6 +155,11 @@ If your plugin adds features to existing commands, feeds the `Hooks` field. Of course a plugin can declare `Commands` *and* `Hooks`. +A plugin may also share a host process by setting `SharedHost` to `true`. +`SharedHost` is desirable if a plugin hooks into, or declares long running commands. +Commands executed from the same plugin context interact with the same plugin server. +Allowing all executing commands to share the same server instance, giving shared execution context. + ### Adding new command Plugin commands are custom commands added to the ignite cli by a registered diff --git a/ignite/cmd/plugin.go b/ignite/cmd/plugin.go index 7ecb3beb02..f50882e090 100644 --- a/ignite/cmd/plugin.go +++ b/ignite/cmd/plugin.go @@ -561,7 +561,7 @@ func NewPluginScaffold() *cobra.Command { return err } moduleName := args[0] - path, err := plugin.Scaffold(wd, moduleName) + path, err := plugin.Scaffold(wd, moduleName, false) if err != nil { return err } diff --git a/ignite/services/plugin/cache.go b/ignite/services/plugin/cache.go new file mode 100644 index 0000000000..6d38303d40 --- /dev/null +++ b/ignite/services/plugin/cache.go @@ -0,0 +1,88 @@ +package plugin + +import ( + "encoding/gob" + "fmt" + "net" + "path" + + hplugin "github.com/hashicorp/go-plugin" + + "github.com/ignite/cli/ignite/pkg/cache" +) + +const ( + cacheFileName = "ignite_plugin_cache.db" + cacheNamespace = "plugin.rpc.context" +) + +var storageCache *cache.Cache[hplugin.ReattachConfig] + +func init() { + gob.Register(hplugin.ReattachConfig{}) + gob.Register(&net.UnixAddr{}) +} + +func writeConfigCache(pluginPath string, conf hplugin.ReattachConfig) error { + if pluginPath == "" { + return fmt.Errorf("provided path is invalid: %s", pluginPath) + } + if conf.Addr == nil { + return fmt.Errorf("plugin Address info cannot be empty") + } + cache, err := newCache() + if err != nil { + return err + } + return cache.Put(pluginPath, conf) +} + +func readConfigCache(pluginPath string) (hplugin.ReattachConfig, error) { + if pluginPath == "" { + return hplugin.ReattachConfig{}, fmt.Errorf("provided path is invalid: %s", pluginPath) + } + cache, err := newCache() + if err != nil { + return hplugin.ReattachConfig{}, err + } + return cache.Get(pluginPath) +} + +func checkConfCache(pluginPath string) bool { + if pluginPath == "" { + return false + } + cache, err := newCache() + if err != nil { + return false + } + _, err = cache.Get(pluginPath) + return err == nil +} + +func deleteConfCache(pluginPath string) error { + if pluginPath == "" { + return fmt.Errorf("provided path is invalid: %s", pluginPath) + } + cache, err := newCache() + if err != nil { + return err + } + return cache.Delete(pluginPath) +} + +func newCache() (*cache.Cache[hplugin.ReattachConfig], error) { + cacheRootDir, err := PluginsPath() + if err != nil { + return nil, err + } + if storageCache == nil { + storage, err := cache.NewStorage(path.Join(cacheRootDir, cacheFileName)) + if err != nil { + return nil, err + } + c := cache.New[hplugin.ReattachConfig](storage, cacheNamespace) + storageCache = &c + } + return storageCache, nil +} diff --git a/ignite/services/plugin/cache_test.go b/ignite/services/plugin/cache_test.go new file mode 100644 index 0000000000..c11ca8b033 --- /dev/null +++ b/ignite/services/plugin/cache_test.go @@ -0,0 +1,109 @@ +package plugin + +import ( + "net" + "testing" + + hplugin "github.com/hashicorp/go-plugin" + "github.com/stretchr/testify/require" +) + +func TestReadWriteConfigCache(t *testing.T) { + t.Run("Should cache plugin config and read from cache", func(t *testing.T) { + const path = "/path/to/awesome/plugin" + unixFD, _ := net.ResolveUnixAddr("unix", "/var/folders/5k/sv4bxrs102n_6rr7430jc7j80000gn/T/plugin193424090") + + rc := hplugin.ReattachConfig{ + Protocol: hplugin.ProtocolNetRPC, + ProtocolVersion: hplugin.CoreProtocolVersion, + Addr: unixFD, + Pid: 24464, + } + + err := writeConfigCache(path, rc) + require.NoError(t, err) + + c, err := readConfigCache(path) + require.NoError(t, err) + require.Equal(t, rc, c) + }) + + t.Run("Should error writing bad plugin config to cache", func(t *testing.T) { + const path = "/path/to/awesome/plugin" + rc := hplugin.ReattachConfig{ + Protocol: hplugin.ProtocolNetRPC, + ProtocolVersion: hplugin.CoreProtocolVersion, + Addr: nil, + Pid: 24464, + } + + err := writeConfigCache(path, rc) + require.Error(t, err) + }) + + t.Run("Should error with invalid plugin path", func(t *testing.T) { + const path = "" + rc := hplugin.ReattachConfig{ + Protocol: hplugin.ProtocolNetRPC, + ProtocolVersion: hplugin.CoreProtocolVersion, + Addr: nil, + Pid: 24464, + } + + err := writeConfigCache(path, rc) + require.Error(t, err) + }) +} + +func TestDeleteConfCache(t *testing.T) { + t.Run("Delete plugin config after write to cache should remove from cache", func(t *testing.T) { + const path = "/path/to/awesome/plugin" + unixFD, _ := net.ResolveUnixAddr("unix", "/var/folders/5k/sv4bxrs102n_6rr7430jc7j80000gn/T/plugin193424090") + + rc := hplugin.ReattachConfig{ + Protocol: hplugin.ProtocolNetRPC, + ProtocolVersion: hplugin.CoreProtocolVersion, + Addr: unixFD, + Pid: 24464, + } + + err := writeConfigCache(path, rc) + require.NoError(t, err) + + err = deleteConfCache(path) + require.NoError(t, err) + + // there should be an error after deleting the config from the cache + _, err = readConfigCache(path) + require.Error(t, err) + }) + + t.Run("Delete plugin config should return error given empty path", func(t *testing.T) { + const path = "" + err := deleteConfCache(path) + require.Error(t, err) + }) +} + +func TestCheckConfCache(t *testing.T) { + const path = "/path/to/awesome/plugin" + unixFD, _ := net.ResolveUnixAddr("unix", "/var/folders/5k/sv4bxrs102n_6rr7430jc7j80000gn/T/plugin193424090") + + rc := hplugin.ReattachConfig{ + Protocol: hplugin.ProtocolNetRPC, + ProtocolVersion: hplugin.CoreProtocolVersion, + Addr: unixFD, + Pid: 24464, + } + + t.Run("Cache should be hydrated", func(t *testing.T) { + err := writeConfigCache(path, rc) + require.NoError(t, err) + require.Equal(t, true, checkConfCache(path)) + }) + + t.Run("Cache should be empty", func(t *testing.T) { + _ = deleteConfCache(path) + require.Equal(t, false, checkConfCache(path)) + }) +} diff --git a/ignite/services/plugin/interface.go b/ignite/services/plugin/interface.go index f5320b6101..e47c357fa4 100644 --- a/ignite/services/plugin/interface.go +++ b/ignite/services/plugin/interface.go @@ -59,6 +59,19 @@ type Manifest struct { // Hooks contains the hooks that will be attached to the existing ignite // commands. Hooks []Hook + // SharedHost enables sharing a single plugin server across all running instances + // of a plugin. Useful if a plugin adds or extends long running commands + // + // Example: if a plugin defines a hook on `ignite chain serve`, a plugin server is instanciated + // when the command is run. Now if you want to interact with that instance from commands + // defined in that plugin, you need to enable `SharedHost`, or else the commands will just + // instantiate separate plugin servers. + // + // When enabled, all plugins of the same `Path` loaded from the same configuration will + // attach it's rpc client to a an existing rpc server. + // + // If a plugin instance has no other running plugin servers, it will create one and it will be the host. + SharedHost bool `yaml:"shared_host"` } // Command represents a plugin command. diff --git a/ignite/services/plugin/plugin.go b/ignite/services/plugin/plugin.go index 5d5531e44b..d1ced5a970 100644 --- a/ignite/services/plugin/plugin.go +++ b/ignite/services/plugin/plugin.go @@ -54,6 +54,12 @@ type Plugin struct { client *hplugin.Client + // holds a cache of the plugin manifest to prevent mant calls over the rpc boundary + manifest Manifest + // If a plugin's ShareHost flag is set to true, isHost is used to discern if a + // plugin instance is controlling the rpc server. + isHost bool + ev events.Bus } @@ -164,9 +170,20 @@ func newPlugin(pluginsDir string, cp pluginsconfig.Plugin, options ...Option) *P // KillClient kills the running plugin client. func (p *Plugin) KillClient() { + if p.manifest.SharedHost && !p.isHost { + // Don't send kill signal to a shared-host plugin when this process isn't + // the one who initiated it. + return + } + if p.client != nil { p.client.Kill() } + + if p.isHost { + deleteConfCache(p.Path) + p.isHost = false + } } // IsGlobal returns whether the plugin is installed globally or locally for a chain. @@ -195,6 +212,7 @@ func (p *Plugin) load(ctx context.Context) { return } } + if p.isLocal() { // trigger rebuild for local plugin if binary is outdated if p.outdatedBinary() { @@ -225,17 +243,37 @@ func (p *Plugin) load(ctx context.Context) { Output: os.Stderr, Level: logLevel, }) - // We're a host! Start by launching the plugin process. - p.client = hplugin.NewClient(&hplugin.ClientConfig{ - HandshakeConfig: handshakeConfig, - Plugins: pluginMap, - Logger: logger, - Cmd: exec.Command(p.binaryPath()), - SyncStderr: os.Stderr, - SyncStdout: os.Stdout, - }) - // Connect via RPC + if checkConfCache(p.Path) { + rconf, err := readConfigCache(p.Path) + if err != nil { + p.Error = err + return + } + + // We're attaching to an existing server, supply attachment configuration + p.client = hplugin.NewClient(&hplugin.ClientConfig{ + HandshakeConfig: handshakeConfig, + Plugins: pluginMap, + Logger: logger, + Reattach: &rconf, + SyncStderr: os.Stderr, + SyncStdout: os.Stdout, + }) + + } else { + // We're a host! Start by launching the plugin process. + p.client = hplugin.NewClient(&hplugin.ClientConfig{ + HandshakeConfig: handshakeConfig, + Plugins: pluginMap, + Logger: logger, + Cmd: exec.Command(p.binaryPath()), + SyncStderr: os.Stderr, + SyncStdout: os.Stdout, + }) + } + + // :Connect via RPC rpcClient, err := p.client.Client() if err != nil { p.Error = errors.Wrapf(err, "connecting") @@ -252,6 +290,27 @@ func (p *Plugin) load(ctx context.Context) { // We should have an Interface now! This feels like a normal interface // implementation but is in fact over an RPC connection. p.Interface = raw.(Interface) + + m, err := p.Interface.Manifest() + if err != nil { + p.Error = errors.Wrapf(err, "manifest load") + } + + p.manifest = m + + // write the rpc context to cache if the plugin is declared as host. + // writing it to cache as lost operation within load to assure rpc client's reattach config + // is hydrated. + if m.SharedHost && !checkConfCache(p.Path) { + err := writeConfigCache(p.Path, *p.client.ReattachConfig()) + if err != nil { + p.Error = err + return + } + + // set the plugin's rpc server as host so other plugin clients may share + p.isHost = true + } } // fetch clones the plugin repository at the expected reference. diff --git a/ignite/services/plugin/plugin_test.go b/ignite/services/plugin/plugin_test.go index 420269b210..b92328dbea 100644 --- a/ignite/services/plugin/plugin_test.go +++ b/ignite/services/plugin/plugin_test.go @@ -12,6 +12,7 @@ import ( "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + hplugin "github.com/hashicorp/go-plugin" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -136,36 +137,12 @@ func TestPluginLoad(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) - // scaffoldPlugin runs Scaffold and updates the go.mod so it uses the - // current ignite/cli sources. - scaffoldPlugin := func(t *testing.T, dir, name string) string { - require := require.New(t) - path, err := Scaffold(dir, name) - require.NoError(err) - // We want the scaffolded plugin to use the current version of ignite/cli, - // for that we need to update the plugin go.mod and add a replace to target - // current ignite/cli - gomod, err := gomodule.ParseAt(path) - require.NoError(err) - // use GOMOD env to get current directory module path - modpath, err := gocmd.Env(gocmd.EnvGOMOD) - require.NoError(err) - modpath = filepath.Dir(modpath) - gomod.AddReplace("github.com/ignite/cli", "", modpath, "") - // Save go.mod - data, err := gomod.Format() - require.NoError(err) - err = os.WriteFile(filepath.Join(path, "go.mod"), data, 0o644) - require.NoError(err) - return path - } - // Helper to make a local git repository with gofile committed. // Returns the repo directory and the git.Repository makeGitRepo := func(t *testing.T, name string) (string, *git.Repository) { require := require.New(t) repoDir := t.TempDir() - scaffoldPlugin(t, repoDir, "github.com/ignite/"+name) + scaffoldPlugin(t, repoDir, "github.com/ignite/"+name, false) require.NoError(err) repo, err := git.PlainInit(repoDir, false) require.NoError(err) @@ -210,8 +187,10 @@ func TestPluginLoad(t *testing.T) { { name: "ok: from local", buildPlugin: func(t *testing.T) Plugin { + path := scaffoldPlugin(t, t.TempDir(), "github.com/foo/bar", false) return Plugin{ - srcPath: scaffoldPlugin(t, t.TempDir(), "github.com/foo/bar"), + Plugin: pluginsconfig.Plugin{Path: path}, + srcPath: path, binaryName: "bar", } }, @@ -223,6 +202,7 @@ func TestPluginLoad(t *testing.T) { cloneDir := t.TempDir() return Plugin{ + Plugin: pluginsconfig.Plugin{Path: path.Join(cloneDir, "remote")}, cloneURL: repoDir, cloneDir: cloneDir, srcPath: path.Join(cloneDir, "remote"), @@ -236,6 +216,7 @@ func TestPluginLoad(t *testing.T) { cloneDir := t.TempDir() return Plugin{ + Plugin: pluginsconfig.Plugin{Path: path.Join(cloneDir, "plugin")}, repoPath: "/xxxx/yyyy", cloneURL: "/xxxx/yyyy", cloneDir: cloneDir, @@ -259,6 +240,7 @@ func TestPluginLoad(t *testing.T) { cloneDir := t.TempDir() return Plugin{ + Plugin: pluginsconfig.Plugin{Path: path.Join(cloneDir, "remote-tag")}, cloneURL: repoDir, reference: "v1", cloneDir: cloneDir, @@ -282,6 +264,7 @@ func TestPluginLoad(t *testing.T) { cloneDir := t.TempDir() return Plugin{ + Plugin: pluginsconfig.Plugin{Path: path.Join(cloneDir, "remote-branch")}, cloneURL: repoDir, reference: "branch1", cloneDir: cloneDir, @@ -300,6 +283,7 @@ func TestPluginLoad(t *testing.T) { cloneDir := t.TempDir() return Plugin{ + Plugin: pluginsconfig.Plugin{Path: path.Join(cloneDir, "remote-branch")}, cloneURL: repoDir, reference: h.Hash().String(), cloneDir: cloneDir, @@ -316,6 +300,7 @@ func TestPluginLoad(t *testing.T) { cloneDir := t.TempDir() return Plugin{ + Plugin: pluginsconfig.Plugin{Path: path.Join(cloneDir, "remote-no-ref")}, cloneURL: repoDir, reference: "doesnt_exists", cloneDir: cloneDir, @@ -340,6 +325,7 @@ func TestPluginLoad(t *testing.T) { require.Regexp(tt.expectedError, p.Error.Error()) return } + require.NoError(p.Error) require.NotNil(p.Interface) manifest, err := p.Interface.Manifest() @@ -353,6 +339,97 @@ func TestPluginLoad(t *testing.T) { } } +func TestPluginLoadSharedHost(t *testing.T) { + tests := []struct { + name string + instances int + sharesHost bool + }{ + { + name: "ok: from local sharedhost is on 1 instance", + instances: 1, + sharesHost: true, + }, + { + name: "ok: from local sharedhost is on 2 instances", + instances: 2, + sharesHost: true, + }, + { + name: "ok: from local sharedhost is on 4 instances", + instances: 4, + sharesHost: true, + }, + { + name: "ok: from local sharedhost is off 4 instances", + instances: 4, + sharesHost: false, + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + require = require.New(t) + assert = assert.New(t) + // scaffold an unique plugin for all instances + path = scaffoldPlugin(t, t.TempDir(), + fmt.Sprintf("github.com/foo/bar-%d", i), tt.sharesHost) + plugins []*Plugin + ) + // Load one plugin per instance + for i := 0; i < tt.instances; i++ { + p := Plugin{ + Plugin: pluginsconfig.Plugin{Path: path}, + srcPath: path, + binaryName: filepath.Base(path), + } + p.load(context.Background()) + require.NoError(p.Error) + plugins = append(plugins, &p) + } + // Ensure all plugins are killed at the end of test case + defer func() { + for i := len(plugins) - 1; i >= 0; i-- { + plugins[i].KillClient() + if tt.sharesHost && i > 0 { + assert.False(plugins[i].client.Exited(), "non host plugin can't kill host plugin") + assert.True(checkConfCache(plugins[i].Path), "non host plugin doesn't remove config cache when killed") + } else { + assert.True(plugins[i].client.Exited(), "plugin should be killed") + } + assert.False(plugins[i].isHost, "killed plugins are no longer host") + } + assert.False(checkConfCache(plugins[0].Path), "once host is killed the cache should be cleared") + }() + + var hostConf *hplugin.ReattachConfig + for i := 0; i < len(plugins); i++ { + if tt.sharesHost { + assert.True(checkConfCache(plugins[i].Path), "sharedHost must have a cache entry") + if i == 0 { + // first plugin is the host + assert.True(plugins[i].isHost, "first plugin is the host") + // Assert reattach config has been saved + hostConf = plugins[i].client.ReattachConfig() + ref, err := readConfigCache(plugins[i].Path) + if assert.NoError(err) { + assert.Equal(hostConf, &ref, "wrong cache entry for plugin host") + } + } else { + // plugins after first aren't host + assert.False(plugins[i].isHost, "plugin %d can't be host", i) + assert.Equal(hostConf, plugins[i].client.ReattachConfig(), "ReattachConfig different from host plugin") + } + } else { + assert.False(plugins[i].isHost, "plugin %d can't be host if sharedHost is disabled", i) + assert.False(checkConfCache(plugins[i].Path), "plugin %d can't have a cache entry if sharedHost is disabled", i) + } + } + }) + } +} + func TestPluginClean(t *testing.T) { tests := []struct { name string @@ -394,6 +471,30 @@ func TestPluginClean(t *testing.T) { } } +// scaffoldPlugin runs Scaffold and updates the go.mod so it uses the +// current ignite/cli sources. +func scaffoldPlugin(t *testing.T, dir, name string, sharedHost bool) string { + require := require.New(t) + path, err := Scaffold(dir, name, sharedHost) + require.NoError(err) + // We want the scaffolded plugin to use the current version of ignite/cli, + // for that we need to update the plugin go.mod and add a replace to target + // current ignite/cli + gomod, err := gomodule.ParseAt(path) + require.NoError(err) + // use GOMOD env to get current directory module path + modpath, err := gocmd.Env(gocmd.EnvGOMOD) + require.NoError(err) + modpath = filepath.Dir(modpath) + gomod.AddReplace("github.com/ignite/cli", "", modpath, "") + // Save go.mod + data, err := gomod.Format() + require.NoError(err) + err = os.WriteFile(filepath.Join(path, "go.mod"), data, 0o644) + require.NoError(err) + return path +} + func assertPlugin(t *testing.T, want, have Plugin) { if want.Error != nil { require.Error(t, have.Error) diff --git a/ignite/services/plugin/scaffold.go b/ignite/services/plugin/scaffold.go index 8af951dafb..0767c62db7 100644 --- a/ignite/services/plugin/scaffold.go +++ b/ignite/services/plugin/scaffold.go @@ -21,7 +21,7 @@ import ( var fsPluginSource embed.FS // Scaffold generates a plugin structure under dir/path.Base(moduleName). -func Scaffold(dir, moduleName string) (string, error) { +func Scaffold(dir, moduleName string, sharedHost bool) (string, error) { var ( name = filepath.Base(moduleName) finalDir = path.Join(dir, name) @@ -42,6 +42,8 @@ func Scaffold(dir, moduleName string) (string, error) { ctx := plush.NewContext() ctx.Set("ModuleName", moduleName) ctx.Set("Name", name) + ctx.Set("SharedHost", sharedHost) + g.Transformer(xgenny.Transformer(ctx)) r := genny.WetRunner(ctx) err := r.With(g) diff --git a/ignite/services/plugin/scaffold_test.go b/ignite/services/plugin/scaffold_test.go index b7a6668c14..8d7f122df3 100644 --- a/ignite/services/plugin/scaffold_test.go +++ b/ignite/services/plugin/scaffold_test.go @@ -9,7 +9,7 @@ import ( func TestScaffold(t *testing.T) { tmp := t.TempDir() - path, err := Scaffold(tmp, "github.com/foo/bar") + path, err := Scaffold(tmp, "github.com/foo/bar", false) require.NoError(t, err) require.DirExists(t, path) diff --git a/ignite/services/plugin/template/main.go.plush b/ignite/services/plugin/template/main.go.plush index 7f14fddd90..dbdbcd3e63 100644 --- a/ignite/services/plugin/template/main.go.plush +++ b/ignite/services/plugin/template/main.go.plush @@ -45,6 +45,7 @@ func (p) Manifest() (plugin.Manifest, error) { }, // Add hooks here Hooks: []plugin.Hook{}, + SharedHost: <%= SharedHost %>, }, nil }