Skip to content

Commit

Permalink
Merge branch 'main' into feat/network-plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
tbruyelle committed Jan 3, 2023
2 parents e77c11e + 015b140 commit 8b1af57
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 @@ -567,7 +567,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 @@ -60,6 +60,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"`
}

// ImportCobraCommand allows to hydrate m with a standard root cobra commands.
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
}
}

func (p *Plugin) binaryPath() string {
Expand All @@ -186,6 +203,7 @@ func (p *Plugin) load(ctx context.Context) {
return
}
}

if p.IsLocalPath() {
// trigger rebuild for local plugin if binary is outdated
if p.outdatedBinary() {
Expand Down Expand Up @@ -216,17 +234,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 @@ -243,6 +281,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 8b1af57

Please sign in to comment.