// 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) }