Skip to content

Commit

Permalink
feat: Add in-progress spinner for plugin installation (#641)
Browse files Browse the repository at this point in the history
This commit introduces an in-progress spinner to enhance
the user experience during plugin installation.

The plugin installation spinner has been updated for various use cases:
- Standalone plugin installation
- Target-specific plugin installation
- Plugin synchronization
- Installation of plugins from a plugin group
  • Loading branch information
chandrareddyp authored Jan 23, 2024
1 parent c5d58f2 commit afd7c07
Show file tree
Hide file tree
Showing 12 changed files with 474 additions and 57 deletions.
361 changes: 361 additions & 0 deletions go.work.sum

Large diffs are not rendered by default.

85 changes: 69 additions & 16 deletions pkg/pluginmanager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"

"github.com/Masterminds/semver"
"github.com/pkg/errors"
Expand Down Expand Up @@ -701,21 +703,26 @@ func GetPluginGroup(groupIDAndVersion string, options ...PluginManagerOptions) (
return pg, nil
}

func logPluginInstallationMessage(p *discovery.Discovered, version string, isPluginInCache, isPluginAlreadyInstalled bool) {
func getPluginInstallationMessage(p *discovery.Discovered, version string, isPluginInCache, isPluginAlreadyInstalled bool) (installingMsg, installedMsg, errorMsg string) {
withTarget := ""
if p.Target != configtypes.TargetUnknown {
withTarget = fmt.Sprintf("with target '%v' ", p.Target)
}

if isPluginInCache {
if !isPluginAlreadyInstalled {
log.Infof("Installing plugin '%v:%v' %v(from cache)", p.Name, version, withTarget)
installingMsg = fmt.Sprintf("Installing plugin '%v:%v' %v(from cache)", p.Name, version, withTarget)
installedMsg = fmt.Sprintf("Installed plugin '%v:%v' %v(from cache)", p.Name, version, withTarget)
} else {
log.Infof("Plugin '%v:%v' %vis already installed. Reinitializing...", p.Name, version, withTarget)
installingMsg = fmt.Sprintf("Plugin '%v:%v' %vis already installed. Reinitializing...", p.Name, version, withTarget)
installedMsg = fmt.Sprintf("Reinitialized plugin '%v:%v' %v", p.Name, version, withTarget)
}
} else {
log.Infof("Installing plugin '%v:%v' %v", p.Name, version, withTarget)
installingMsg = fmt.Sprintf("Installing plugin '%v:%v' %v", p.Name, version, withTarget)
installedMsg = fmt.Sprintf("Installed plugin '%v:%v' %v", p.Name, version, withTarget)
}
errorMsg = fmt.Sprintf("Failed to install plugin '%v:%v' %v", p.Name, version, withTarget)
return installingMsg, installedMsg, errorMsg
}

func installOrUpgradePlugin(p *discovery.Discovered, version string, installTestPlugin bool) error {
Expand All @@ -737,26 +744,72 @@ func installOrUpgradePlugin(p *discovery.Discovered, version string, installTest
}

// Log message based on different installation conditions
logPluginInstallationMessage(p, version, plugin != nil, isPluginAlreadyInstalled)
installingMsg, installedMsg, errMsg := getPluginInstallationMessage(p, version, plugin != nil, isPluginAlreadyInstalled)

var spinnerErr, pluginErr error
var sow component.OutputWriterSpinner
var signalChannel chan os.Signal

// Initialize the spinner if the spinner is allowed
if component.IsTTYEnabled() {
// Set spinner options
spinnerOptions := component.OutputWriterSpinnerOptions{
SpinnerOptions: []component.OutputWriterSpinnerOption{
component.WithSpinnerFinalText(installedMsg, log.LogTypeINFO),
},
}
// Create a channel to receive OS signals
signalChannel = make(chan os.Signal, 1)

if plugin == nil {
binary, err := fetchAndVerifyPlugin(p, version)
if err != nil {
return err
// Register the channel to receive interrupt signals (e.g., Ctrl+C)
signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM)

// Initialize the spinner
sow, spinnerErr = component.NewOutputWriterSpinnerWithSpinnerOptions(os.Stderr, component.TableOutputType, installingMsg, true, spinnerOptions)
if spinnerErr != nil {
log.V(6).Infof("Unable to initialize spinner: %v", spinnerErr.Error())
log.Info(installingMsg)
}
if sow != nil {
defer sow.StopSpinner()

plugin, err = installAndDescribePlugin(p, version, binary)
if err != nil {
return err
// Start a goroutine that listens for interrupt signals
go func() {
// Wait for an interrupt signal
<-signalChannel

// Handle the interrupt signal by setting the final text and stopping the spinner
sow.SetFinalText(errMsg, log.LogTypeERROR)
sow.StopSpinner()

// Exit the program with exit code 130 (interrupted)
os.Exit(128 + int(syscall.SIGINT))
}()
}
} else {
log.Info(installingMsg)
}
if installTestPlugin {
if err := doInstallTestPlugin(p, plugin.InstallationPath, version); err != nil {
return err

var binary []byte
if plugin == nil {
binary, pluginErr = fetchAndVerifyPlugin(p, version)
if pluginErr == nil {
plugin, pluginErr = installAndDescribePlugin(p, version, binary)
}
}
if pluginErr == nil && installTestPlugin {
pluginErr = doInstallTestPlugin(p, plugin.InstallationPath, version)
}

return updatePluginInfoAndInitializePlugin(p, plugin)
if pluginErr == nil {
pluginErr = updatePluginInfoAndInitializePlugin(p, plugin)
}
if pluginErr != nil {
if sow != nil {
sow.SetFinalText(errMsg, log.LogTypeERROR)
}
}
return pluginErr
}

func getPluginFromCache(p *discovery.Discovered, version string) *cli.PluginInfo {
Expand Down
10 changes: 5 additions & 5 deletions test/e2e/airgapped/airgapped_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ var _ = framework.CLICoreDescribe("[Tests:E2E][Feature:Airgapped-Plugin-Download
// Test case: (Negative) try to install plugins that are not migrated to the airgapped repository
It("installing plugins that are not migrated to the airgapped repository should throw an error", func() {
// All plugins should get installed from the group
err := tf.PluginCmd.InstallPlugin("isolated-cluster", "", "")
_, _, err = tf.PluginCmd.InstallPlugin("isolated-cluster", "", "")
Expect(err).NotTo(BeNil())
err = tf.PluginCmd.InstallPlugin("pinniped-auth", "", "")
_, _, err = tf.PluginCmd.InstallPlugin("pinniped-auth", "", "")
Expect(err).NotTo(BeNil())
})
})
Expand Down Expand Up @@ -302,9 +302,9 @@ var _ = framework.CLICoreDescribe("[Tests:E2E][Feature:Airgapped-Plugin-Download
// Test case: validate that all plugins that are not part of any plugin-groups can be installed as well
It("validate that all plugins not part of any plugin-groups can be installed as well", func() {
// All plugins should get installed from the group
err := tf.PluginCmd.InstallPlugin("isolated-cluster", "", "")
_, _, err := tf.PluginCmd.InstallPlugin("isolated-cluster", "", "")
Expect(err).To(BeNil())
err = tf.PluginCmd.InstallPlugin("pinniped-auth", "", "")
_, _, err = tf.PluginCmd.InstallPlugin("pinniped-auth", "", "")
Expect(err).To(BeNil())

// Verify above plugins got installed with `tanzu plugin list`
Expand All @@ -316,7 +316,7 @@ var _ = framework.CLICoreDescribe("[Tests:E2E][Feature:Airgapped-Plugin-Download

// Test case: validate thaa plugin using a sha can be installed
It("validate that a plugin using a sha can be installed", func() {
err := tf.PluginCmd.InstallPlugin("plugin-with-sha", "", "")
_, _, err := tf.PluginCmd.InstallPlugin("plugin-with-sha", "", "")
Expect(err).To(BeNil())

// Verify above plugin got installed with `tanzu plugin list`
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/cataloge2e/catalog_update_parallel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ var _ = framework.CLICoreDescribe("[Tests:E2E][Feature:Catalog-Update]", func()
wg.Add(1)
go func(pluginGroup *framework.PluginGroupGet) {
defer wg.Done()
_ = tf.PluginCmd.InstallPlugin(pluginGroup.PluginName, pluginGroup.PluginTarget, pluginGroup.PluginVersion)
_, _, _ = tf.PluginCmd.InstallPlugin(pluginGroup.PluginName, pluginGroup.PluginTarget, pluginGroup.PluginVersion)
}(pluginGroupGet[i])
}
wg.Wait()
Expand Down
20 changes: 10 additions & 10 deletions test/e2e/coexistence/cli_coexistence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Installing few plugins using legacy Tanzu CLI")
for _, plugin := range PluginsForLegacyTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install for legacy tanzu cli")
str, err := tf.PluginCmd.DescribePluginLegacy(plugin.Name, plugin.Target)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe for legacy tanzu cli")
Expand Down Expand Up @@ -104,7 +104,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Installing few plugins using new Tanzu CLI")
for _, plugin := range PluginsForNewTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version, framework.WithTanzuBinary(framework.TzPrefix))
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version, framework.WithTanzuBinary(framework.TzPrefix))
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install for new tanzu cli")
str, err := tf.PluginCmd.DescribePlugin(plugin.Name, plugin.Target, framework.WithTanzuBinary(framework.TzPrefix), framework.GetJsonOutputFormatAdditionalFlagFunction())
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe for new tanzu cli")
Expand Down Expand Up @@ -135,7 +135,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Installing few plugins using legacy Tanzu CLI")
for _, plugin := range PluginsForLegacyTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using legacy tanzu cli")
str, err := tf.PluginCmd.DescribePluginLegacy(plugin.Name, plugin.Target)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe using legacy tanzu cli")
Expand Down Expand Up @@ -174,7 +174,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Installing few plugins using new Tanzu CLI")
for _, plugin := range PluginsForNewTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using new tanzu cli")
str, err := tf.PluginCmd.DescribePlugin(plugin.Name, plugin.Target, framework.GetJsonOutputFormatAdditionalFlagFunction())

Expand All @@ -201,7 +201,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Install few plugins using legacy Tanzu CLI")
for _, plugin := range PluginsForLegacyTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using legacy tanzu cli")
str, err := tf.PluginCmd.DescribePluginLegacy(plugin.Name, plugin.Target)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe using legacy tanzu cli")
Expand Down Expand Up @@ -240,7 +240,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Install few plugins using new Tanzu CLI")
for _, plugin := range PluginsForNewTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using new tanzu cli")
str, err := tf.PluginCmd.DescribePlugin(plugin.Name, plugin.Target, framework.GetJsonOutputFormatAdditionalFlagFunction())
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe using new tanzu cli")
Expand Down Expand Up @@ -276,7 +276,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Install few plugins using legacy Tanzu CLI")
for _, plugin := range PluginsForLegacyTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using legacy tanzu cli")
str, err := tf.PluginCmd.DescribePluginLegacy(plugin.Name, plugin.Target)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe using legacy tanzu cli")
Expand Down Expand Up @@ -315,7 +315,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Install few plugins using new Tanzu CLI")
for _, plugin := range PluginsForNewTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version, framework.WithTanzuBinary(framework.TzPrefix))
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version, framework.WithTanzuBinary(framework.TzPrefix))
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using new tanzu cli")
str, err := tf.PluginCmd.DescribePlugin(plugin.Name, plugin.Target, framework.WithTanzuBinary(framework.TzPrefix), framework.GetJsonOutputFormatAdditionalFlagFunction())
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe using new tanzu cli")
Expand Down Expand Up @@ -351,7 +351,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Install few plugins using legacy Tanzu CLI")
for _, plugin := range PluginsForLegacyTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using legacy tanzu cli")
str, err := tf.PluginCmd.DescribePluginLegacy(plugin.Name, plugin.Target)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe using legacy tanzu cli")
Expand Down Expand Up @@ -390,7 +390,7 @@ var _ = ginkgo.Describe("CLI Coexistence Tests", func() {
ginkgo.By("Install few plugins using new Tanzu CLI")
for _, plugin := range PluginsForNewTanzuCLICoexistenceTests {
target := plugin.Target
err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
_, _, err := tf.PluginCmd.InstallPlugin(plugin.Name, target, plugin.Version)
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin install using new tanzu cli")
str, err := tf.PluginCmd.DescribePlugin(plugin.Name, plugin.Target, framework.GetJsonOutputFormatAdditionalFlagFunction())
gomega.Expect(err).To(gomega.BeNil(), "should not get any error for plugin describe using new tanzu cli")
Expand Down
10 changes: 5 additions & 5 deletions test/e2e/framework/plugin_lifecycle_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type PluginBasicOps interface {
// SearchPlugins searches all plugins for given filter (keyword|regex) by running 'tanzu plugin search' command
SearchPlugins(filter string, opts ...E2EOption) ([]*PluginInfo, string, string, error)
// InstallPlugin installs given plugin and flags
InstallPlugin(pluginName, target, versions string, opts ...E2EOption) error
InstallPlugin(pluginName, target, versions string, opts ...E2EOption) (stdOut, stdErr string, err error)
// Sync performs sync operation and returns stdOut, stdErr and error
Sync(opts ...E2EOption) (string, string, error)
// DescribePlugin describes given plugin and flags, returns the plugin description as PluginDescribe
Expand Down Expand Up @@ -214,19 +214,19 @@ func (po *pluginCmdOps) GetPluginGroup(groupName string, flagsWithValues string,
return pluginList, err
}

func (po *pluginCmdOps) InstallPlugin(pluginName, target, versions string, opts ...E2EOption) error {
func (po *pluginCmdOps) InstallPlugin(pluginName, target, versions string, opts ...E2EOption) (stdout, stdErr string, err error) {
installPluginCmd := fmt.Sprintf(InstallPluginCmd, "%s", pluginName)
if len(strings.TrimSpace(target)) > 0 {
installPluginCmd += " --target " + target
}
if len(strings.TrimSpace(versions)) > 0 {
installPluginCmd += " --version " + versions
}
out, stdErr, err := po.cmdExe.TanzuCmdExec(installPluginCmd, opts...)
stdOutBuff, stdErrBuff, err := po.cmdExe.TanzuCmdExec(installPluginCmd, opts...)
if err != nil {
log.Errorf(ErrorLogForCommandWithErrStdErrAndStdOut, installPluginCmd, err.Error(), stdErr.String(), out.String())
log.Errorf(ErrorLogForCommandWithErrStdErrAndStdOut, installPluginCmd, err.Error(), stdOutBuff.String(), stdErrBuff.String())
}
return err
return stdOutBuff.String(), stdErrBuff.String(), err
}

func (po *pluginCmdOps) InstallPluginsFromGroup(pluginNameORAll, groupName string, opts ...E2EOption) (stdout string, stdErr string, err error) {
Expand Down
17 changes: 8 additions & 9 deletions test/e2e/plugin_lifecycle/plugin_group_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ import (
)

const (
PluginGroupInstallation = "The following plugins will be installed from plugin group '%s"
PluginGroupTableHeaderRegExp = "NAME\\s+TARGET\\s+VERSION"
PluginGroupTableRowPluginRegExp = "%s\\s+%s\\s+%s"
PluginGroupPluginInstallingRegExp = "Installing plugin '%s:.+' with target '%s'|Plugin '%s:.+' with target '%s'"
PluginGroupPluginInstalledRegExp = "Installing plugin '%s:.+' with target '%s'"
PluginGroupInstallation = "The following plugins will be installed from plugin group '%s"
PluginGroupTableHeaderRegExp = "NAME\\s+TARGET\\s+VERSION"
PluginGroupTableRowPluginRegExp = "%s\\s+%s\\s+%s"
PluginGroupPluginInstalledRegExp = "Installed plugin '%s:.+' with target '%s'|Reinitialized plugin '%s:.+' with target '%s'"
)

// This test suite covers plugin group life cycle use cases for central repository
Expand Down Expand Up @@ -114,7 +113,7 @@ var _ = framework.CLICoreDescribe("[Tests:E2E][Feature:Plugin-Group-lifecycle]",
Expect(len(pluginsList)).Should(BeNumerically("==", 1), "plugins list should return only one essential plugin after plugin clean")
})
// Test case: a. with command 'tanzu plugin install all --group group_name': when no plugins in a group has installed already: install a plugin from each plugin group and validate the installation with plugin describe
It("install all plugins in each group", func() {
It("install all plugins in each group with all option", func() {
for pg := range pluginGroupToPluginListMap {
_, stdErr, err := tf.PluginCmd.InstallPluginsFromGroup("all", pg)
Expect(err).To(BeNil(), "should not get any error for plugin install from plugin group")
Expand All @@ -123,13 +122,13 @@ var _ = framework.CLICoreDescribe("[Tests:E2E][Feature:Plugin-Group-lifecycle]",
Expect(stdErr).To(MatchRegexp(PluginGroupTableHeaderRegExp))
plugins := pluginGroupToPluginListMap[pg]
for i := range plugins {
// Validate the plugin list output
Expect(stdErr).To(MatchRegexp(fmt.Sprintf(PluginGroupTableRowPluginRegExp, plugins[i].Name, plugins[i].Target, plugins[i].Version)))
Expect(stdErr).To(MatchRegexp(fmt.Sprintf(PluginGroupPluginInstallingRegExp, plugins[i].Name, plugins[i].Target, plugins[i].Name, plugins[i].Target)))
// Validate the plugin installed output
pd, err := tf.PluginCmd.DescribePlugin(plugins[i].Name, plugins[i].Target, framework.GetJsonOutputFormatAdditionalFlagFunction())
Expect(err).To(BeNil(), framework.PluginDescribeShouldNotThrowErr)
Expect(len(pd)).To(Equal(1), framework.PluginDescShouldExist)
Expect(pd[0].Name).To(Equal(plugins[i].Name), framework.PluginNameShouldMatch)

}
}
})
Expand Down Expand Up @@ -159,7 +158,7 @@ var _ = framework.CLICoreDescribe("[Tests:E2E][Feature:Plugin-Group-lifecycle]",
// Use cases: This context covers NEGATIVE USE CASES:
// a. incorrect plugin group: clean, install a plugin with incorrect plugin group name
// b. incorrect plugin name: install a plugin with incorrect name and correct plugin group name.
Context("plugin install from group: perform all plugin installation in a group", func() {
Context("plugin install from group: perform all plugin installation in a group - Negative use cases", func() {
// Test case: clean plugins if any installed already
It("clean plugins if any installed already", func() {
err := tf.PluginCmd.CleanPlugins()
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/plugin_lifecycle/plugin_lifecycle_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func testWithoutPluginDiscoverySources(tf *framework.Framework) {
Expect(err.Error()).To(ContainSubstring(errorNoDiscoverySourcesFound))
Expect(len(plugins)).Should(BeNumerically("==", 0))

err = tf.PluginCmd.InstallPlugin("unknowPlugin", "", "")
_, _, err = tf.PluginCmd.InstallPlugin("unknowPlugin", "", "")
Expect(err.Error()).To(ContainSubstring(errorNoDiscoverySourcesFound))

_, _, err = tf.PluginCmd.InstallPluginsFromGroup("", "unknowGroup")
Expand Down
Loading

0 comments on commit afd7c07

Please sign in to comment.