- Create a hello world plugin with a name as parameter, implement two versions of the plugin, load them dynamically
- Make a change to the interface, add a new method, compile the main program and run
- Make a breaking change to the interface and try the step 2
Inside a new demo
folder:
- copy basic example from plugin [github.com/hashicorp/go-plugin/examples/basic]
- add a parameter name and printing method displaying name
- implement another plugin with a different response
- change the main app to load plugins from file
Compile the plugin itself via:
go build -o ./plugin/greeter ./plugin/greeter_impl.go
Compile this driver via:
go build -o basic .
You can then launch the plugin sample via:
./basic
- Add a Hi() string, error to the interface
commons/greeter_interface.go
- Compile basic again and run
Result: Yes, it works when adding new methods, but if the new method is invoked will return an error:
2020-05-25T10:48:14.797+0800 [DEBUG] plugin.greeter: message from GreeterHello.Greet: timestamp=2020-05-25T10:48:14.796+0800
Hello someone
rpc: can't find method Plugin.Hi
- If the
ProtocolVersion
changes,increase a number
**Result: **Returns a new error and any calls to the plugin will fail
2020-05-25T10:49:37.917+0800 [DEBUG] plugin.greeter: message from plugin: foo=bar timestamp=2020-05-25T10:49:37.917+0800
2020/05/25 10:49:37 Incompatible API version with plugin. Plugin version: 1, Client versions: [2]
- Add a parameter to
Hi
methodHi(int) (string, error)
- Remove the parameter from
Greet
methodGreet() string
- Build and try again
Result:
- Step 1 can work successfully, will use a default value (zero value)
- Step 2 will break the plugin and will not work any calls.
If during the call the host
uses a default value then it will work:
func (g *GreeterRPC) Greet() string {
var resp string
err := g.client.Call("Plugin.Greet", map[string]interface{}{
"name": "default?",
}, &resp)
if err != nil {
// You usually want your interfaces to return errors. If they don't,
// there isn't much other choice here.
panic(err)
}
return resp
}
- Copy the
plugin
folder aspluginzh
folder - Implement the new method Hi
- Change the implementation to anything else (change the returned message)
- Create a method to load multiple
*plugin.Client
s - Use
plugin.Discover
method to discover plugins - Move all plugins to a folder using a "file format" .po:
bin/greeter-en.po
bin/greeter-zh.po
- Change the main method to load and manager multiple clients and make calls
func main() {
// Create an hclog.Logger
logger := hclog.New(&hclog.LoggerOptions{
Name: "plugin",
Output: os.Stdout,
Level: hclog.Debug,
})
// We're a host! Start by launching the plugin process.
plugins, err := loadPlugins(logger)
if err != nil {
log.Fatal(err)
}
for _, client := range plugins {
defer client.Kill()
// Connect via RPC
rpcClient, err := client.Client()
if err != nil {
log.Fatal(err)
}
// Request the plugin
raw, err := rpcClient.Dispense("greeter")
if err != nil {
log.Fatal(err)
}
// We should have a Greeter now! This feels like a normal interface
// implementation but is in fact over an RPC connection.
greeter := raw.(example.Greeter)
fmt.Println(greeter.Greet("someone"))
fmt.Println(greeter.Hi())
}
}
func loadPlugins(logger hclog.Logger) (plugins []*plugin.Client, err error) {
var found []string
found, err = plugin.Discover("*.po", "./bin")
fmt.Println("found", found, "err", err)
plugins = make([]*plugin.Client, 0, len(found))
for _, f := range found {
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: handshakeConfig,
Plugins: pluginMap,
Cmd: exec.Command(f),
Logger: logger,
})
plugins = append(plugins, client)
}
return
}
// handshakeConfigs are used to just do a basic handshake between
// a plugin and host. If the handshake fails, a user friendly error is shown.
// This prevents users from executing bad plugins or executing a plugin
// directory. It is a UX feature, not a security feature.
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "BASIC_PLUGIN",
MagicCookieValue: "hello",
}
// pluginMap is the map of plugins we can dispense.
var pluginMap = map[string]plugin.Plugin{
"greeter": &example.GreeterPlugin{},
}
Result: It is possible but needs to add a new layer to manage clients
- Add another interface
commons/pingpong_interface.go
// PingPonger is the interface that we're exposing as a plugin.
type PingPonger interface {
Ping() (string, error)
}
- Implement RPC server and client on interface
- Add interface methods to
pluginzh/greeter_impl.go
// Ping adds implementation for PingPonger plugin
func (g *GreeterHello) Ping() (string, error) {
return "pong!", nil
}
//[...]
func main() {
//[...]
// pluginMap is the map of plugins we can dispense.
var pluginMap = map[string]plugin.Plugin{
"greeter": &example.GreeterPlugin{Impl: greeter},
"pingponger": &example.PingPongerPlugin{Impl: greeter},
}
//[...]
}
- Add new interface to
main.go
//[...]
func main() {
// [...]
for _, client := range plugins {
//[...]
fmt.Println(greeter.Hi(2))
fmt.Println("Will try pingponger...")
raw2, err2 := rpcClient.Dispense("pingponger")
if err2 != nil {
fmt.Println("err", err2)
continue
}
pinger := raw2.(example.PingPonger)
pong, err2 := pinger.Ping()
fmt.Println("ping?", pong, "err", err2)
}
}
// pluginMap is the map of plugins we can dispense.
var pluginMap = map[string]plugin.Plugin{
"greeter": &example.GreeterPlugin{},
"pingponger": &example.PingPongerPlugin{},
}
Result: It is possible, the host needs to add this kind of logic to try multiple plugins over the same client
Hashicorp's go-plugin does not support connection over the network on linux systems, and only uses this kind of method on windows but binding to 127.0.0.1 making it impossible to access it in the network.
I forked the repo and changed few lines of code to support starting a plugin using tcp bindings:
server.go
:
func serverListener() (net.Listener, error) {
if runtime.GOOS == "windows" || os.Getenv("PLUGIN_HOST") != "" {
return serverListener_tcp()
}
return serverListener_unix()
}
// and
func serverListener_tcp() (net.Listener, error) {
//...
for port := minPort; port <= maxPort; port++ {
address := fmt.Sprintf("0.0.0.0:%d", port)
listener, err := net.Listen("tcp", address)
if err == nil {
return listener, nil
}
}
return nil, errors.New("Couldn't bind plugin TCP listener")
}
- Change the
go.mod
to replace the module - Change the
main.go
to load a plugin over the network
Inside the loadPlugins
function:
/...
if os.Getenv("NETWORK_PLUGIN") != "" {
var netTCP net.Addr
if netTCP, err = net.ResolveTCPAddr("tcp", os.Getenv("NETWORK_PLUGIN")); err != nil {
return
}
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: handshakeConfig,
Plugins: pluginMap,
Reattach: &plugin.ReattachConfig{
Protocol: plugin.ProtocolNetRPC,
Addr: netTCP,
},
Logger: logger,
})
plugins = append(plugins, client)
}
/...
- Recompile
pluginzh
using new version, start using the keywork:
BASIC_PLUGIN=hello PLUGIN_HOST=true ./bin/greeter-zh.po
- Recompile
basic
and start using theNETWORK_PLUGIN
envvar. PS: the port needs to be fetched from the plugin log
NETWORK_PLUGIN=127.0.0.1:57674 ./basic
Result: Yes, but with a custom fork, but the client should be managed separatedly.
- Add 2
Dockerfiles
, one for basic and another for thepluginzh
- Build and push to any registry
- Create and expose the plugin
kubectl run pluginzh --image=danielfbm/pluginzh --env=BASIC_PLUGIN=hello --port=7000
kubectl expose pod pluginzh --port=7000 --target-port=7000
- Run the basic
kubectl run basic --image=danielfbm/basic --env=NETWORK_PLUGIN=pluginzh:7000
PS: When using this approach the host should not kill plugins
*Result: *Yes