Skip to content

Commit

Permalink
feat: Shared Plugin RPC Host (#3238)
Browse files Browse the repository at this point in the history
* start to plugin host impl wip

* comments

* minor bug updates

* updates per plugin config migration

* small updates to sharedHost plugin option

* update to isolate plugin cache

* rename of utils to cache

* print line removing

* tidy

* removing print out

* additional comments

* shared host load test

* addition of caching tests for plugins

* fmt

* changelog

* additional plugin cache tests

* update shared host tests

* review comments

* addition cache test cases

* fix typo

* fix comment

* update plugin `KillClient` for shared hosts

* changelog update

* fix test

by omitting SharedHost from the yml when its false

* update: migration of `sharedHost` flag to plugin manifest

* scaffolding and plugin host check changes

* update tests

* format and lint

* update plugin cache test

* update property comment

* update plugin cache to use full plugin path

* cleanup of plugin tests

* lint

* update plugin sharedHost tests

* update to plugin docs for `Sharedhost` flag

* test: fix TestPluginLoadSharedHost

* refac: plugin cach

* Update docs/docs/contributing/01-plugins.md

* refac: plugin cache func should be private

* test: improve plugin kill assertions

Co-authored-by: Thomas Bruyelle <[email protected]>
Co-authored-by: Thomas Bruyelle <[email protected]>
  • Loading branch information
3 people authored Jan 3, 2023
1 parent e2cc76c commit 015b140
Show file tree
Hide file tree
Showing 11 changed files with 432 additions and 40 deletions.
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
20 changes: 19 additions & 1 deletion docs/docs/contributing/01-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}
```

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ignite/cmd/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
88 changes: 88 additions & 0 deletions ignite/services/plugin/cache.go
Original file line number Diff line number Diff line change
@@ -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
}
109 changes: 109 additions & 0 deletions ignite/services/plugin/cache_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
13 changes: 13 additions & 0 deletions ignite/services/plugin/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 69 additions & 10 deletions ignite/services/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
Expand All @@ -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.
Expand Down
Loading

0 comments on commit 015b140

Please sign in to comment.