package plugin import ( "context" "fmt" "os" "path" "path/filepath" "testing" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" hplugin "github.com/hashicorp/go-plugin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" pluginsconfig "git.cw.tr/mukan-network/mukan-ignite/ignite/config/plugins" "git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui" "git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/errors" "git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/gocmd" "git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/gomodule" ) func TestNewPlugin(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) tests := []struct { name string pluginCfg pluginsconfig.Plugin expectedPlugin Plugin }{ { name: "fail: empty path", expectedPlugin: Plugin{ Error: errors.Errorf(`missing app property "path"`), stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "fail: local plugin doesnt exists", pluginCfg: pluginsconfig.Plugin{Path: "/xxx/yyy/app"}, expectedPlugin: Plugin{ Error: errors.Errorf(`local app path "/xxx/yyy/app" not found`), stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "fail: local plugin is not a directory", pluginCfg: pluginsconfig.Plugin{Path: path.Join(wd, "testdata/fakebin")}, expectedPlugin: Plugin{ Error: errors.Errorf(fmt.Sprintf("local app path %q is not a directory", path.Join(wd, "testdata/fakebin"))), stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "ok: local plugin", pluginCfg: pluginsconfig.Plugin{Path: path.Join(wd, "testdata")}, expectedPlugin: Plugin{ srcPath: path.Join(wd, "testdata"), name: "testdata", stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "fail: remote plugin with only domain", pluginCfg: pluginsconfig.Plugin{Path: "github.com"}, expectedPlugin: Plugin{ Error: errors.Errorf(`app path "github.com" is not a valid repository URL`), stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "fail: remote plugin with incomplete URL", pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite"}, expectedPlugin: Plugin{ Error: errors.Errorf(`app path "github.com/ignite" is not a valid repository URL`), stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "ok: remote app", pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app"}, expectedPlugin: Plugin{ repoPath: "github.com/ignite/app", cloneURL: "https://github.com/ignite/app", cloneDir: ".ignite/apps/github.com/ignite/app", reference: "", srcPath: ".ignite/apps/github.com/ignite/app", name: "app", stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "ok: remote plugin with @ref", pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app@develop"}, expectedPlugin: Plugin{ repoPath: "github.com/ignite/app@develop", cloneURL: "https://github.com/ignite/app", cloneDir: ".ignite/apps/github.com/ignite/app-develop", reference: "develop", srcPath: ".ignite/apps/github.com/ignite/app-develop", name: "app", stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "ok: remote plugin with @ref containing slash", pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app@package/v1.0.0"}, expectedPlugin: Plugin{ repoPath: "github.com/ignite/app@package/v1.0.0", cloneURL: "https://github.com/ignite/app", cloneDir: ".ignite/apps/github.com/ignite/app-package-v1.0.0", reference: "package/v1.0.0", srcPath: ".ignite/apps/github.com/ignite/app-package-v1.0.0", name: "app", stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "ok: remote plugin with subpath", pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app/plugin1"}, expectedPlugin: Plugin{ repoPath: "github.com/ignite/app", cloneURL: "https://github.com/ignite/app", cloneDir: ".ignite/apps/github.com/ignite/app", reference: "", srcPath: ".ignite/apps/github.com/ignite/app/plugin1", name: "plugin1", stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "ok: remote plugin with subpath and @ref", pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app/plugin1@develop"}, expectedPlugin: Plugin{ repoPath: "github.com/ignite/app@develop", cloneURL: "https://github.com/ignite/app", cloneDir: ".ignite/apps/github.com/ignite/app-develop", reference: "develop", srcPath: ".ignite/apps/github.com/ignite/app-develop/plugin1", name: "plugin1", stdout: os.Stdout, stderr: os.Stderr, }, }, { name: "ok: remote plugin with subpath and @ref containing slash", pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app/plugin1@package/v1.0.0"}, expectedPlugin: Plugin{ repoPath: "github.com/ignite/app@package/v1.0.0", cloneURL: "https://github.com/ignite/app", cloneDir: ".ignite/apps/github.com/ignite/app-package-v1.0.0", reference: "package/v1.0.0", srcPath: ".ignite/apps/github.com/ignite/app-package-v1.0.0/plugin1", name: "plugin1", stdout: os.Stdout, stderr: os.Stderr, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.expectedPlugin.Plugin = tt.pluginCfg p := newPlugin(".ignite/apps", tt.pluginCfg) assertPlugin(t, tt.expectedPlugin, *p) }) } } // Helper to make a local git repository with gofile committed. // Returns the repo directory and the git.Repository. func makeGitRepo(t *testing.T, name string) (string, *git.Repository) { t.Helper() require := require.New(t) repoDir := t.TempDir() scaffoldPlugin(t, repoDir, "github.com/ignite/"+name, false) repo, err := git.PlainInit(repoDir, false) require.NoError(err) w, err := repo.Worktree() require.NoError(err) _, err = w.Add(".") require.NoError(err) _, err = w.Commit("msg", &git.CommitOptions{ Author: &object.Signature{ Name: "bob", Email: "bob@example.com", When: time.Now(), }, }) require.NoError(err) return repoDir, repo } type TestClientAPI struct{ ClientAPI } func (TestClientAPI) GetChainInfo(context.Context) (*ChainInfo, error) { return &ChainInfo{}, nil } func (TestClientAPI) GetIgniteInfo(context.Context) (*IgniteInfo, error) { return &IgniteInfo{}, nil } func TestPluginLoad(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) clientAPI := &TestClientAPI{} tests := []struct { name string buildPlugin func(t *testing.T) Plugin expectedError string }{ { name: "fail: plugin is already in error", buildPlugin: func(t *testing.T) Plugin { t.Helper() return Plugin{ Error: errors.New("oups"), } }, expectedError: `oups`, }, { name: "fail: no go files in srcPath", buildPlugin: func(t *testing.T) Plugin { t.Helper() return Plugin{ srcPath: path.Join(wd, "testdata"), name: "testdata", } }, expectedError: `no Go files in`, }, { name: "ok: from local", buildPlugin: func(t *testing.T) Plugin { t.Helper() path := scaffoldPlugin(t, t.TempDir(), "github.com/foo/bar", false) return Plugin{ srcPath: path, name: "bar", } }, }, { name: "ok: from git repo", buildPlugin: func(t *testing.T) Plugin { t.Helper() repoDir, _ := makeGitRepo(t, "remote") cloneDir := t.TempDir() return Plugin{ cloneURL: repoDir, cloneDir: cloneDir, srcPath: path.Join(cloneDir, "remote"), name: "remote", } }, }, { name: "fail: git repo doesnt exists", buildPlugin: func(t *testing.T) Plugin { t.Helper() cloneDir := t.TempDir() return Plugin{ repoPath: "/xxxx/yyyy", cloneURL: "/xxxx/yyyy", cloneDir: cloneDir, srcPath: path.Join(cloneDir, "app"), } }, expectedError: `cloning "/xxxx/yyyy": repository not found`, }, { name: "ok: from git repo with tag", buildPlugin: func(t *testing.T) Plugin { t.Helper() repoDir, repo := makeGitRepo(t, "remote-tag") h, err := repo.Head() require.NoError(t, err) _, err = repo.CreateTag("v1", h.Hash(), &git.CreateTagOptions{ Tagger: &object.Signature{Name: "me"}, Message: "v1", }) require.NoError(t, err) cloneDir := t.TempDir() return Plugin{ cloneURL: repoDir, reference: "v1", cloneDir: cloneDir, srcPath: path.Join(cloneDir, "remote-tag"), name: "remote-tag", } }, }, { name: "ok: from git repo with branch", buildPlugin: func(t *testing.T) Plugin { t.Helper() repoDir, repo := makeGitRepo(t, "remote-branch") w, err := repo.Worktree() require.NoError(t, err) err = w.Checkout(&git.CheckoutOptions{ Branch: plumbing.NewBranchReferenceName("branch1"), Create: true, }) require.NoError(t, err) cloneDir := t.TempDir() return Plugin{ cloneURL: repoDir, reference: "branch1", cloneDir: cloneDir, srcPath: path.Join(cloneDir, "remote-branch"), name: "remote-branch", } }, }, { name: "ok: from git repo with hash", buildPlugin: func(t *testing.T) Plugin { t.Helper() repoDir, repo := makeGitRepo(t, "remote-hash") h, err := repo.Head() require.NoError(t, err) cloneDir := t.TempDir() return Plugin{ cloneURL: repoDir, reference: h.Hash().String(), cloneDir: cloneDir, srcPath: path.Join(cloneDir, "remote-hash"), name: "remote-hash", } }, }, { name: "fail: git ref not found", buildPlugin: func(t *testing.T) Plugin { t.Helper() repoDir, _ := makeGitRepo(t, "remote-no-ref") cloneDir := t.TempDir() return Plugin{ cloneURL: repoDir, reference: "doesnt_exists", cloneDir: cloneDir, srcPath: path.Join(cloneDir, "remote-no-ref"), name: "remote-no-ref", } }, expectedError: `cloning ".*": reference not found`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() require := require.New(t) assert := assert.New(t) p := tt.buildPlugin(t) defer p.KillClient() p.load(context.Background()) if tt.expectedError != "" { require.Error(p.Error, "expected error %q", tt.expectedError) require.Regexp(tt.expectedError, p.Error.Error()) return } require.NoError(p.Error) require.NotNil(p.Interface) manifest, err := p.Interface.Manifest(ctx) require.NoError(err) assert.Equal(p.name, manifest.Name) assert.NoError(p.Interface.Execute(ctx, &ExecutedCommand{OsArgs: []string{"ignite", p.name, "hello"}}, clientAPI)) assert.NoError(p.Interface.ExecuteHookPre(ctx, &ExecutedHook{}, clientAPI)) assert.NoError(p.Interface.ExecuteHookPost(ctx, &ExecutedHook{}, clientAPI)) assert.NoError(p.Interface.ExecuteHookCleanUp(ctx, &ExecutedHook{}, clientAPI)) }) } } func TestPluginLoadSharedHost(t *testing.T) { tests := []struct { name string instances int sharesHost bool }{ { name: "ok: from local sharedhost is on 1 instance", instances: 1, sharesHost: true, }, { name: "ok: from local sharedhost is on 2 instances", instances: 2, sharesHost: true, }, { name: "ok: from local sharedhost is on 4 instances", instances: 4, sharesHost: true, }, { name: "ok: from local sharedhost is off 4 instances", instances: 4, sharesHost: false, }, } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { var ( require = require.New(t) assert = assert.New(t) // scaffold an unique plugin for all instances path = scaffoldPlugin(t, t.TempDir(), fmt.Sprintf("github.com/foo/bar-%d", i), tt.sharesHost) plugins []*Plugin ) // Load one plugin per instance for i := 0; i < tt.instances; i++ { p := Plugin{ Plugin: pluginsconfig.Plugin{Path: path}, srcPath: path, name: filepath.Base(path), } p.load(context.Background()) require.NoError(p.Error) plugins = append(plugins, &p) } // Ensure all plugins are killed at the end of test case defer func() { for i := len(plugins) - 1; i >= 0; i-- { plugins[i].KillClient() if tt.sharesHost && i > 0 { assert.False(plugins[i].client.Exited(), "non host app can't kill host app") assert.True(checkConfCache(plugins[i].Path), "non host app doesn't remove config cache when killed") } else { assert.True(plugins[i].client.Exited(), "app should be killed") } assert.False(plugins[i].isHost, "killed plugins are no longer host") } assert.False(checkConfCache(plugins[0].Path), "once host is killed the cache should be cleared") }() var hostConf *hplugin.ReattachConfig for i := 0; i < len(plugins); i++ { if tt.sharesHost { assert.True(checkConfCache(plugins[i].Path), "sharedHost must have a cache entry") if i == 0 { // first plugin is the host assert.True(plugins[i].isHost, "first app is the host") // Assert reattach config has been saved hostConf = plugins[i].client.ReattachConfig() ref, err := readConfigCache(plugins[i].Path) if assert.NoError(err) { assert.Equal(hostConf, &ref, "wrong cache entry for app host") } } else { // plugins after first aren't host assert.False(plugins[i].isHost, "app %d can't be host", i) assert.Equal(hostConf, plugins[i].client.ReattachConfig(), "ReattachConfig different from host app") } } else { assert.False(plugins[i].isHost, "app %d can't be host if sharedHost is disabled", i) assert.False(checkConfCache(plugins[i].Path), "app %d can't have a cache entry if sharedHost is disabled", i) } } }) } } func TestPluginClean(t *testing.T) { tests := []struct { name string plugin *Plugin expectRemove bool }{ { name: "dont clean local app", plugin: &Plugin{ Plugin: pluginsconfig.Plugin{Path: "/local"}, }, }, { name: "dont clean plugin with errors", plugin: &Plugin{Error: errors.New("oups")}, }, { name: "ok", plugin: &Plugin{ cloneURL: "https://github.com/ignite/app", }, expectRemove: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmp, err := os.MkdirTemp("", "cloneDir") require.NoError(t, err) tt.plugin.cloneDir = tmp err = tt.plugin.clean() require.NoError(t, err) if tt.expectRemove { _, err := os.Stat(tmp) assert.True(t, os.IsNotExist(err), "cloneDir not removed") } }) } } func TestPluginOutdatedBinary(t *testing.T) { t.Run("returns true when source and binary mtimes are equal", func(t *testing.T) { tmp := t.TempDir() srcFile := filepath.Join(tmp, "main.go") binFile := filepath.Join(tmp, "app.ign") require.NoError(t, os.WriteFile(srcFile, []byte("package main\n"), 0o644)) require.NoError(t, os.WriteFile(binFile, []byte("binary"), 0o755)) equalTime := time.Now().Add(-time.Minute).Truncate(time.Second) require.NoError(t, os.Chtimes(srcFile, equalTime, equalTime)) require.NoError(t, os.Chtimes(binFile, equalTime, equalTime)) p := Plugin{ srcPath: tmp, name: "app", } require.True(t, p.outdatedBinary()) }) } // scaffoldPlugin runs Scaffold and updates the go.mod so it uses the // current ignite/cli sources. func scaffoldPlugin(t *testing.T, dir, name string, sharedHost bool) string { t.Helper() require := require.New(t) session := cliui.New(cliui.WithoutUserInteraction(true)) path, err := Scaffold(context.Background(), session, dir, name, sharedHost) require.NoError(err) // We want the scaffolded plugin to use the current version of ignite/cli, // for that we need to update the plugin go.mod and add a replace to target // current ignite/cli gomod, err := gomodule.ParseAt(path) require.NoError(err) // use GOMOD env to get current directory module path modpath, err := gocmd.Env(gocmd.EnvGOMOD) require.NoError(err) modpath = filepath.Dir(modpath) err = gomod.AddReplace("git.cw.tr/mukan-network/mukan-ignite", "", modpath, "") require.NoError(err) // Save go.mod data, err := gomod.Format() require.NoError(err) err = os.WriteFile(filepath.Join(path, "go.mod"), data, 0o644) require.NoError(err) return path } func assertPlugin(t *testing.T, want, have Plugin) { t.Helper() if want.Error != nil { require.Errorf(t, have.Error, "expected error %q", want.Error) assert.Regexp(t, want.Error.Error(), have.Error.Error()) } else { require.NoErrorf(t, have.Error, "expected no error, got %v", have.Error) } // Errors aren't comparable with assert.Equal, because of the different stacks want.Error = nil have.Error = nil assert.Equal(t, want, have) }