Some checks are pending
Docs Deploy / build_and_deploy (push) Waiting to run
Generate Docs / cli (push) Waiting to run
Generate Config Doc / cli (push) Waiting to run
Go formatting / go-formatting (push) Waiting to run
Check links / markdown-link-check (push) Waiting to run
Integration / pre-test (push) Waiting to run
Integration / test on (push) Blocked by required conditions
Integration / status (push) Blocked by required conditions
Lint / Lint Go code (push) Waiting to run
Test / test (ubuntu-latest) (push) Waiting to run
432 lines
11 KiB
Go
432 lines
11 KiB
Go
// Package plugin implements ignite plugin management.
|
|
// An ignite plugin is a binary which communicates with the ignite binary
|
|
// via RPC thanks to the github.com/hashicorp/go-plugin library.
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-hclog"
|
|
hplugin "github.com/hashicorp/go-plugin"
|
|
|
|
"github.com/ignite/cli/v29/ignite/config"
|
|
pluginsconfig "github.com/ignite/cli/v29/ignite/config/plugins"
|
|
"github.com/ignite/cli/v29/ignite/pkg/cliui/icons"
|
|
"github.com/ignite/cli/v29/ignite/pkg/env"
|
|
"github.com/ignite/cli/v29/ignite/pkg/errors"
|
|
"github.com/ignite/cli/v29/ignite/pkg/events"
|
|
"github.com/ignite/cli/v29/ignite/pkg/gocmd"
|
|
"github.com/ignite/cli/v29/ignite/pkg/xfilepath"
|
|
"github.com/ignite/cli/v29/ignite/pkg/xgit"
|
|
"github.com/ignite/cli/v29/ignite/pkg/xurl"
|
|
)
|
|
|
|
// PluginsPath holds the plugin cache directory.
|
|
var PluginsPath = xfilepath.Mkdir(xfilepath.Join(
|
|
config.DirPath,
|
|
xfilepath.Path("apps"),
|
|
))
|
|
|
|
// Plugin represents a ignite plugin.
|
|
type Plugin struct {
|
|
// Embed the plugin configuration.
|
|
pluginsconfig.Plugin
|
|
|
|
// Interface allows to communicate with the plugin via RPC.
|
|
Interface Interface
|
|
|
|
// If any error occurred during the plugin load, it's stored here.
|
|
Error error
|
|
|
|
name string
|
|
repoPath string
|
|
cloneURL string
|
|
cloneDir string
|
|
reference string
|
|
srcPath string
|
|
|
|
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
|
|
isSharedHost bool
|
|
|
|
ev events.Bus
|
|
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
}
|
|
|
|
// Option configures Plugin.
|
|
type Option func(*Plugin)
|
|
|
|
// CollectEvents collects events from the chain.
|
|
func CollectEvents(ev events.Bus) Option {
|
|
return func(p *Plugin) {
|
|
p.ev = ev
|
|
}
|
|
}
|
|
|
|
func RedirectStdout(w io.Writer) Option {
|
|
return func(p *Plugin) {
|
|
p.stdout = w
|
|
}
|
|
}
|
|
|
|
func RedirectStderr(w io.Writer) Option {
|
|
return func(p *Plugin) {
|
|
p.stderr = w
|
|
}
|
|
}
|
|
|
|
// Load loads the plugins found in the chain config.
|
|
//
|
|
// There's 2 kinds of plugins, local or remote.
|
|
// Local plugins have their path starting with a `/`, while remote plugins don't.
|
|
// Local plugins are useful for development purpose.
|
|
// Remote plugins require to be fetched first, in $HOME/.ignite/apps folder,
|
|
// then they are loaded from there.
|
|
//
|
|
// If an error occurs during a plugin load, it's not returned but rather stored in
|
|
// the `Plugin.Error` field. This prevents the loading of other plugins to be interrupted.
|
|
func Load(ctx context.Context, plugins []pluginsconfig.Plugin, options ...Option) ([]*Plugin, error) {
|
|
pluginsDir, err := PluginsPath()
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
var loaded []*Plugin
|
|
for _, cp := range plugins {
|
|
p := newPlugin(pluginsDir, cp, options...)
|
|
p.load(ctx)
|
|
|
|
loaded = append(loaded, p)
|
|
}
|
|
return loaded, nil
|
|
}
|
|
|
|
// Update removes the cache directory of plugins and fetch them again.
|
|
func Update(plugins ...*Plugin) error {
|
|
for _, p := range plugins {
|
|
if err := p.clean(); err != nil {
|
|
return err
|
|
}
|
|
p.fetch()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// newPlugin creates a Plugin from configuration.
|
|
func newPlugin(pluginsDir string, cp pluginsconfig.Plugin, options ...Option) *Plugin {
|
|
var (
|
|
p = &Plugin{
|
|
Plugin: cp,
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
}
|
|
pluginPath = cp.Path
|
|
)
|
|
if pluginPath == "" {
|
|
p.Error = errors.Errorf(`missing app property "path"`)
|
|
return p
|
|
}
|
|
|
|
// Apply the options
|
|
for _, apply := range options {
|
|
apply(p)
|
|
}
|
|
|
|
// This is a local plugin, check if the directory exists
|
|
if filepath.IsAbs(pluginPath) {
|
|
|
|
st, err := os.Stat(pluginPath)
|
|
if err != nil {
|
|
p.Error = errors.Wrapf(err, "local app path %q not found", pluginPath)
|
|
return p
|
|
}
|
|
if !st.IsDir() {
|
|
p.Error = errors.Errorf("local app path %q is not a directory", pluginPath)
|
|
return p
|
|
}
|
|
p.srcPath = pluginPath
|
|
p.name = path.Base(pluginPath)
|
|
return p
|
|
}
|
|
|
|
// This is a remote plugin, parse the URL
|
|
if i := strings.LastIndex(pluginPath, "@"); i != -1 {
|
|
// path contains a reference
|
|
p.reference = pluginPath[i+1:]
|
|
pluginPath = pluginPath[:i]
|
|
}
|
|
parts := strings.Split(pluginPath, "/")
|
|
if len(parts) < 3 {
|
|
p.Error = errors.Errorf("app path %q is not a valid repository URL", pluginPath)
|
|
return p
|
|
}
|
|
p.repoPath = path.Join(parts[:3]...)
|
|
p.cloneURL, _ = xurl.HTTPS(p.repoPath)
|
|
|
|
if len(p.reference) > 0 {
|
|
ref := strings.ReplaceAll(p.reference, "/", "-")
|
|
p.cloneDir = path.Join(pluginsDir, fmt.Sprintf("%s-%s", p.repoPath, ref))
|
|
p.repoPath += "@" + p.reference
|
|
} else {
|
|
p.cloneDir = path.Join(pluginsDir, p.repoPath)
|
|
}
|
|
|
|
// Plugin can have a subpath within its repository.
|
|
// For example, "github.com/ignite/apps/app1" where "app1" is the subpath.
|
|
repoSubPath := path.Join(parts[3:]...)
|
|
|
|
p.srcPath = path.Join(p.cloneDir, repoSubPath)
|
|
p.name = path.Base(pluginPath)
|
|
|
|
return p
|
|
}
|
|
|
|
// KillClient kills the running plugin client.
|
|
func (p *Plugin) KillClient() {
|
|
if p.isSharedHost && !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
|
|
}
|
|
}
|
|
|
|
// Manifest returns plugin's manigest.
|
|
// The manifest is available after the plugin has been loaded.
|
|
func (p Plugin) Manifest() *Manifest {
|
|
return p.manifest
|
|
}
|
|
|
|
func (p Plugin) binaryName() string {
|
|
return fmt.Sprintf("%s.ign", p.name)
|
|
}
|
|
|
|
func (p Plugin) binaryPath() string {
|
|
return path.Join(p.srcPath, p.binaryName())
|
|
}
|
|
|
|
// load tries to fill p.Interface, ensuring the plugin is usable.
|
|
func (p *Plugin) load(ctx context.Context) {
|
|
if p.Error != nil {
|
|
return
|
|
}
|
|
_, err := os.Stat(p.srcPath)
|
|
if err != nil {
|
|
// srcPath found, need to fetch the plugin
|
|
p.fetch()
|
|
if p.Error != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
if p.IsLocalPath() {
|
|
// trigger rebuild for local plugin if binary is outdated
|
|
if p.outdatedBinary() {
|
|
p.build(ctx)
|
|
}
|
|
} else {
|
|
// Check if binary is already build
|
|
_, err = os.Stat(p.binaryPath())
|
|
if err != nil {
|
|
// binary not found, need to build it
|
|
p.build(ctx)
|
|
}
|
|
}
|
|
if p.Error != nil {
|
|
return
|
|
}
|
|
// pluginMap is the map of plugins we can dispense.
|
|
pluginMap := map[string]hplugin.Plugin{
|
|
p.name: NewGRPC(nil),
|
|
}
|
|
// Create an hclog.Logger
|
|
logLevel := hclog.Error
|
|
if env.IsDebug() {
|
|
logLevel = hclog.Trace
|
|
}
|
|
logger := hclog.New(&hclog.LoggerOptions{
|
|
Name: fmt.Sprintf("app %s", p.Path),
|
|
Output: p.stderr,
|
|
Level: logLevel,
|
|
})
|
|
|
|
// Common plugin client configuration values
|
|
cfg := &hplugin.ClientConfig{
|
|
HandshakeConfig: HandshakeConfig(),
|
|
Plugins: pluginMap,
|
|
Logger: logger,
|
|
SyncStdout: p.stdout,
|
|
SyncStderr: p.stderr,
|
|
AllowedProtocols: []hplugin.Protocol{hplugin.ProtocolGRPC},
|
|
}
|
|
|
|
if checkConfCache(p.Path) {
|
|
rconf, err := readConfigCache(p.Path)
|
|
if err != nil {
|
|
p.Error = err
|
|
return
|
|
}
|
|
|
|
// Attach to an existing plugin process
|
|
cfg.Reattach = &rconf
|
|
p.client = hplugin.NewClient(cfg)
|
|
} else {
|
|
// Launch a new plugin process
|
|
cfg.Cmd = exec.Command(p.binaryPath()) //nolint:gosec
|
|
p.client = hplugin.NewClient(cfg)
|
|
}
|
|
|
|
// Connect via gRPC
|
|
rpcClient, err := p.client.Client()
|
|
if err != nil {
|
|
p.Error = errors.Wrapf(err, "connecting")
|
|
return
|
|
}
|
|
|
|
// Request the plugin
|
|
raw, err := rpcClient.Dispense(p.name)
|
|
if err != nil {
|
|
p.Error = errors.Wrapf(err, "dispensing")
|
|
return
|
|
}
|
|
|
|
// We should have an Interface now! This feels like a normal interface
|
|
// implementation but is in fact over an gRPC connection.
|
|
p.Interface = raw.(Interface)
|
|
|
|
m, err := p.Interface.Manifest(ctx)
|
|
if err != nil {
|
|
p.Error = errors.Wrapf(err, "manifest load")
|
|
return
|
|
}
|
|
|
|
p.isSharedHost = m.SharedHost
|
|
|
|
// Cache the manifest to avoid extra plugin requests
|
|
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.
|
|
func (p *Plugin) fetch() {
|
|
if p.IsLocalPath() {
|
|
return
|
|
}
|
|
if p.Error != nil {
|
|
return
|
|
}
|
|
p.ev.Send(fmt.Sprintf("Fetching app %q", p.cloneURL), events.ProgressStart())
|
|
defer p.ev.Send(fmt.Sprintf("%s App fetched %q", icons.OK, p.cloneURL), events.ProgressFinish())
|
|
|
|
urlref := strings.Join([]string{p.cloneURL, p.reference}, "@")
|
|
err := xgit.Clone(context.Background(), urlref, p.cloneDir)
|
|
if err != nil {
|
|
p.Error = errors.Wrapf(err, "cloning %q", p.repoPath)
|
|
}
|
|
}
|
|
|
|
// build compiles the plugin binary.
|
|
func (p *Plugin) build(ctx context.Context) {
|
|
if p.Error != nil {
|
|
return
|
|
}
|
|
p.ev.Send(fmt.Sprintf("Building app %q", p.Path), events.ProgressStart())
|
|
defer p.ev.Send(fmt.Sprintf("%s App built %q", icons.OK, p.Path), events.ProgressFinish())
|
|
|
|
if err := gocmd.ModTidy(ctx, p.srcPath); err != nil {
|
|
p.Error = errors.Wrapf(err, "go mod tidy")
|
|
return
|
|
}
|
|
if err := gocmd.Build(ctx, p.binaryName(), p.srcPath, nil); err != nil {
|
|
p.Error = errors.Wrapf(err, "go build")
|
|
return
|
|
}
|
|
}
|
|
|
|
// clean removes the plugin cache (only for remote plugins).
|
|
func (p *Plugin) clean() error {
|
|
if p.Error != nil {
|
|
// Dont try to clean plugins with error
|
|
return nil
|
|
}
|
|
if p.IsLocalPath() {
|
|
// Not a remote plugin, nothing to clean
|
|
return nil
|
|
}
|
|
// Clean the cloneDir, next time the ignite command will be invoked, the
|
|
// plugin will be fetched again.
|
|
err := os.RemoveAll(p.cloneDir)
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
// outdatedBinary returns true if the plugin binary is older than the other
|
|
// files in p.srcPath.
|
|
// Also returns true if the plugin binary is absent.
|
|
func (p *Plugin) outdatedBinary() bool {
|
|
var (
|
|
binaryTime time.Time
|
|
mostRecent time.Time
|
|
)
|
|
err := filepath.Walk(p.srcPath, func(path string, info fs.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
if path == p.binaryPath() {
|
|
binaryTime = info.ModTime()
|
|
return nil
|
|
}
|
|
t := info.ModTime()
|
|
if mostRecent.IsZero() || t.After(mostRecent) {
|
|
mostRecent = t
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("error while walking app source path %q\n", p.srcPath)
|
|
return false
|
|
}
|
|
// Rebuild when source files are newer OR have the same timestamp as the binary.
|
|
// In some environments (such as fresh CI checkouts), mtimes can be normalized
|
|
// to identical values, and strict "after" checks may incorrectly reuse stale binaries.
|
|
return !mostRecent.Before(binaryTime)
|
|
}
|