mukan-ignite/ignite/services/plugin/plugin.go
Mukan Erkin Törük c32551b6f7
Some checks failed
Docs Deploy / build_and_deploy (push) Has been cancelled
Generate Docs / cli (push) Has been cancelled
Generate Config Doc / cli (push) Has been cancelled
Go formatting / go-formatting (push) Has been cancelled
Check links / markdown-link-check (push) Has been cancelled
Integration / pre-test (push) Has been cancelled
Integration / test on (push) Has been cancelled
Integration / status (push) Has been cancelled
Lint / Lint Go code (push) Has been cancelled
Test / test (ubuntu-latest) (push) Has been cancelled
refactor: replace all github.com upstream refs with git.cw.tr/mukan-network
2026-05-11 03:36:24 +03:00

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"
"git.cw.tr/mukan-network/mukan-ignite/ignite/config"
pluginsconfig "git.cw.tr/mukan-network/mukan-ignite/ignite/config/plugins"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui/icons"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/env"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/errors"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/events"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/gocmd"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xfilepath"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xgit"
"git.cw.tr/mukan-network/mukan-ignite/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)
}