github.com/cloudberrydb/gpbackup@v1.0.3-0.20240118031043-5410fd45eed6/utils/plugin_test.go (about)

     1  package utils_test
     2  
     3  import (
     4  	"fmt"
     5  	"io/ioutil"
     6  	"os"
     7  	"path/filepath"
     8  	"regexp"
     9  	"strconv"
    10  	"strings"
    11  
    12  	"github.com/blang/semver"
    13  	"github.com/cloudberrydb/gp-common-go-libs/cluster"
    14  	"github.com/cloudberrydb/gp-common-go-libs/iohelper"
    15  	"github.com/cloudberrydb/gp-common-go-libs/operating"
    16  	"github.com/cloudberrydb/gp-common-go-libs/testhelper"
    17  	"github.com/cloudberrydb/gpbackup/testutils"
    18  	"github.com/cloudberrydb/gpbackup/utils"
    19  	"github.com/pkg/errors"
    20  
    21  	. "github.com/onsi/ginkgo/v2"
    22  	. "github.com/onsi/gomega"
    23  )
    24  
    25  var _ = Describe("utils/plugin tests", func() {
    26  	var testCluster *cluster.Cluster
    27  	var executor testutils.TestExecutorMultiple
    28  	var subject utils.PluginConfig
    29  	var tempDir string
    30  
    31  	BeforeEach(func() {
    32  		operating.InitializeSystemFunctions()
    33  		tempDir, _ = ioutil.TempDir("", "temp")
    34  		operating.System.Stdout = stdout
    35  		subject = utils.PluginConfig{
    36  			ExecutablePath: "/a/b/myPlugin",
    37  			ConfigPath:     "/tmp/my_plugin_config.yaml",
    38  			Options:        make(map[string]string),
    39  		}
    40  		subject.Options = make(map[string]string)
    41  		executor = testutils.TestExecutorMultiple{
    42  			ClusterOutputs: make([]*cluster.RemoteOutput, 2),
    43  		}
    44  		executor.ClusterOutputs[0] = &cluster.RemoteOutput{
    45  			Commands: []cluster.ShellCommand{
    46  				cluster.ShellCommand{Content: -1, Stdout: utils.RequiredPluginVersion},
    47  				cluster.ShellCommand{Content: 0, Stdout: utils.RequiredPluginVersion},
    48  				cluster.ShellCommand{Content: 1, Stdout: utils.RequiredPluginVersion},
    49  			},
    50  		}
    51  		executor.ClusterOutputs[1] = &cluster.RemoteOutput{
    52  			Commands: []cluster.ShellCommand{
    53  				cluster.ShellCommand{Content: -1, Stdout: "myPlugin version 1.2.3"},
    54  				cluster.ShellCommand{Content: 0, Stdout: "myPlugin version 1.2.3"},
    55  				cluster.ShellCommand{Content: 1, Stdout: "myPlugin version 1.2.3"},
    56  			},
    57  		}
    58  		testCluster = cluster.NewCluster([]cluster.SegConfig{
    59  			{ContentID: -1, DataDir: filepath.Join(tempDir, "seg-1"), Hostname: "coordinator", Port: 100},
    60  			{ContentID: 0, DataDir: filepath.Join(tempDir, "seg0"), Hostname: "segment1", Port: 101},
    61  			{ContentID: 1, DataDir: filepath.Join(tempDir, "seg1"), Hostname: "segment2", Port: 102},
    62  		})
    63  		testCluster.Executor = &executor
    64  	})
    65  	AfterEach(func() {
    66  		err := os.RemoveAll(tempDir)
    67  		Expect(err).To(Not(HaveOccurred()))
    68  		_ = os.Remove(subject.ConfigPath)
    69  		confDir := filepath.Dir(subject.ConfigPath)
    70  		confFileName := filepath.Base(subject.ConfigPath)
    71  		files, _ := ioutil.ReadDir(confDir)
    72  		for _, f := range files {
    73  			match, _ := filepath.Match(confFileName+"*", f.Name())
    74  			if match {
    75  				_ = os.Remove(confDir + "/" + f.Name())
    76  			}
    77  		}
    78  	})
    79  	Describe("plugin versions via CheckPluginExistsOnAllHosts()", func() {
    80  		It(" generates the correct commands", func() {
    81  			operating.System.Getenv = func(key string) string {
    82  				return "my/install/dir"
    83  			}
    84  
    85  			_ = subject.CheckPluginExistsOnAllHosts(testCluster)
    86  
    87  			apiVersionCommands := executor.ClusterCommands[0]
    88  			expectedCommand := "source my/install/dir/greenplum_path.sh && /a/b/myPlugin plugin_api_version"
    89  			for _, shellCommands := range apiVersionCommands {
    90  				Expect(shellCommands.CommandString).To(ContainSubstring(expectedCommand))
    91  			}
    92  			nativeVersionCommands := executor.ClusterCommands[1]
    93  			expectedCommand = "source my/install/dir/greenplum_path.sh && /a/b/myPlugin --version"
    94  			// for _, contentID := range testCluster.ContentIDs {
    95  			// 	cmd := nativeVersionCommands[contentID]
    96  			// 	Expect(cmd[len(cmd)-1]).To(Equal(expectedCommand))
    97  			// }
    98  			for _, shellCommands := range nativeVersionCommands {
    99  				Expect(shellCommands.CommandString).To(ContainSubstring(expectedCommand))
   100  			}
   101  		})
   102  	})
   103  	Describe("creates segment-specific plugin config and copies it to all hosts", func() {
   104  		It("appends PGPORT and the --version of the plugin", func() {
   105  			testConfigPath := "/tmp/my_plugin_config.yaml"
   106  			testConfigContents := `
   107  executablepath: /tmp/fake_path
   108  options:
   109      field1: 12
   110      field2: hello
   111      field3: 567
   112  `
   113  			err := ioutil.WriteFile(testConfigPath, []byte(testConfigContents), 0777)
   114  			Expect(err).To(Not(HaveOccurred()))
   115  			subject.SetBackupPluginVersion("myTimestamp", "my.test.version")
   116  			subject.CopyPluginConfigToAllHosts(testCluster)
   117  
   118  			Expect(executor.NumRemoteExecutions).To(Equal(1))
   119  			cc := executor.ClusterCommands[0]
   120  			Expect(len(cc)).To(Equal(3))
   121  			Expect(cc[0].Content).To(Equal(-1))
   122  			Expect(cc[0].CommandString).To(MatchRegexp(`rsync -e ssh .*-1 coordinator:\/tmp\/my_plugin_config\.yaml; rm .*-1`))
   123  			Expect(cc[1].Content).To(Equal(0))
   124  			Expect(cc[1].CommandString).To(MatchRegexp(`rsync -e ssh .*0 segment1:\/tmp\/my_plugin_config\.yaml; rm .*0`))
   125  			Expect(cc[2].Content).To(Equal(1))
   126  			Expect(cc[2].CommandString).To(MatchRegexp(`rsync -e ssh .*1 segment2:\/tmp\/my_plugin_config\.yaml; rm .*1`))
   127  
   128  			rgx := regexp.MustCompile(`rsync -e ssh (.*-1) coordinator:\/tmp\/my_plugin_config\.yaml; rm .*-1`)
   129  			rs := rgx.FindStringSubmatch(cc[0].CommandString)
   130  			coordinatorConfigPath := rs[1]
   131  			rgx = regexp.MustCompile(`rsync -e ssh (.*0) segment1:\/tmp\/my_plugin_config\.yaml; rm .*0`)
   132  			rs = rgx.FindStringSubmatch(cc[1].CommandString)
   133  			segmentOneConfigPath := rs[1]
   134  			rgx = regexp.MustCompile(`rsync -e ssh (.*1) segment2:\/tmp\/my_plugin_config\.yaml; rm .*1`)
   135  			rs = rgx.FindStringSubmatch(cc[2].CommandString)
   136  			segmentTwoConfigPath := rs[1]
   137  
   138  			// check contents
   139  			contents := strings.Join(iohelper.MustReadLinesFromFile(coordinatorConfigPath), "\n")
   140  			Expect(contents).To(ContainSubstring("\n  pgport: \"100\""))
   141  			Expect(contents).To(ContainSubstring("\n  backup_plugin_version: my.test.version"))
   142  			contents = strings.Join(iohelper.MustReadLinesFromFile(segmentOneConfigPath), "\n")
   143  			Expect(contents).To(ContainSubstring("\n  pgport: \"101\""))
   144  			Expect(contents).To(ContainSubstring("\n  backup_plugin_version: my.test.version"))
   145  			contents = strings.Join(iohelper.MustReadLinesFromFile(segmentTwoConfigPath), "\n")
   146  			Expect(contents).To(ContainSubstring("\n  pgport: \"102\""))
   147  			Expect(contents).To(ContainSubstring("\n  backup_plugin_version: my.test.version"))
   148  		})
   149  		When("copying for a plugin with encryption", func() {
   150  			It("copies the encryption key", func() {
   151  				executor.LocalOutput = "gpbackup_fake_plugin version 1.0.1+dev.28.g00c877e"
   152  				testConfigPath := "/tmp/my_plugin_config.yaml"
   153  				testConfigContents := `
   154  executablepath: /tmp/foobar
   155  options:
   156      field1: 12
   157      field2: hello
   158      field3: 567
   159  `
   160  				err := ioutil.WriteFile(testConfigPath, []byte(testConfigContents), 0777)
   161  				subject.Options["password_encryption"] = "on"
   162  				mdd := testCluster.GetDirForContent(-1)
   163  				_ = os.MkdirAll(mdd, 0777)
   164  				secretFilePath := filepath.Join(mdd, utils.SecretKeyFile)
   165  				secretFile := iohelper.MustOpenFileForWriting(secretFilePath)
   166  				_, err = secretFile.Write([]byte(`gpbackup_fake_plugin: 0123456789`))
   167  				Expect(err).To(Not(HaveOccurred()))
   168  
   169  				subject.CopyPluginConfigToAllHosts(testCluster)
   170  
   171  				// check contents
   172  				cc := executor.ClusterCommands[0]
   173  				rgx := regexp.MustCompile(`rsync -e ssh (.*-1) coordinator:\/tmp\/my_plugin_config\.yaml; rm .*-1`)
   174  				rs := rgx.FindStringSubmatch(cc[0].CommandString)
   175  				coordinatorConfigPath := rs[1]
   176  				rgx = regexp.MustCompile(`rsync -e ssh (.*0) segment1:\/tmp\/my_plugin_config\.yaml; rm .*0`)
   177  				rs = rgx.FindStringSubmatch(cc[1].CommandString)
   178  				segmentOneConfigPath := rs[1]
   179  				rgx = regexp.MustCompile(`rsync -e ssh (.*1) segment2:\/tmp\/my_plugin_config\.yaml; rm .*1`)
   180  				rs = rgx.FindStringSubmatch(cc[2].CommandString)
   181  				segmentTwoConfigPath := rs[1]
   182  
   183  				// check contents
   184  				contents := strings.Join(iohelper.MustReadLinesFromFile(coordinatorConfigPath), "\n")
   185  				Expect(contents).To(ContainSubstring("\n  gpbackup_fake_plugin: \"0123456789\""))
   186  				contents = strings.Join(iohelper.MustReadLinesFromFile(segmentOneConfigPath), "\n")
   187  				Expect(contents).To(ContainSubstring("\n  gpbackup_fake_plugin: \"0123456789\""))
   188  				contents = strings.Join(iohelper.MustReadLinesFromFile(segmentTwoConfigPath), "\n")
   189  				Expect(contents).To(ContainSubstring("\n  gpbackup_fake_plugin: \"0123456789\""))
   190  			})
   191  			It("writes a stdout message when encrypt key is not found", func() {
   192  				subject.Options["password_encryption"] = "on"
   193  				executor.LocalOutput = "gpbackup_fake_plugin version 1.0.1+dev.28.g00c877e"
   194  				pluginName, err := subject.GetPluginName(testCluster)
   195  				Expect(err).To(Not(HaveOccurred()))
   196  				errMsg := fmt.Sprintf("Cannot find encryption key for plugin %s. Please re-encrypt password(s) so that key becomes available.", pluginName)
   197  				defer testhelper.ShouldPanicWithMessage(errMsg)
   198  				subject.CopyPluginConfigToAllHosts(testCluster)
   199  
   200  				Expect(string(stdout.Contents())).To(ContainSubstring(errMsg))
   201  				Expect(string(stdout.Contents())).To(ContainSubstring(errMsg))
   202  			})
   203  		})
   204  	})
   205  	Describe("version validation", func() {
   206  		When("version is equal to requirement", func() {
   207  			It("succeeds", func() {
   208  				subject.CheckPluginExistsOnAllHosts(testCluster)
   209  			})
   210  		})
   211  		When("version is greater than requirement", func() {
   212  			It("succeeds", func() {
   213  				// add one to whatever the current required version might be
   214  				version, _ := semver.Make(utils.RequiredPluginVersion)
   215  				greater, _ := semver.Make(strconv.Itoa(int(version.Major)+1) + ".0.0")
   216  				co := executor.ClusterOutputs[0].Commands
   217  				Expect(co[0].Content).To(Equal(-1))
   218  				co[0].Stdout = greater.String()
   219  				Expect(co[1].Content).To(Equal(0))
   220  				co[1].Stdout = greater.String()
   221  				Expect(co[2].Content).To(Equal(1))
   222  				co[2].Stdout = greater.String()
   223  
   224  				co[1].Stdout = greater.String()
   225  				co[2].Stdout = greater.String()
   226  
   227  				_ = subject.CheckPluginExistsOnAllHosts(testCluster)
   228  			})
   229  		})
   230  		When("version is too low", func() {
   231  			It("panics with message", func() {
   232  				co := executor.ClusterOutputs[0].Commands
   233  				co[0].Stdout = "0.2.0"
   234  				co[1].Stdout = "0.2.0"
   235  				co[2].Stdout = "0.2.0"
   236  				defer testhelper.ShouldPanicWithMessage("Plugin API version incorrect")
   237  
   238  				_ = subject.CheckPluginExistsOnAllHosts(testCluster)
   239  			})
   240  		})
   241  		When("version cannot be parsed", func() {
   242  			It("panics with message", func() {
   243  				co := executor.ClusterOutputs[0].Commands
   244  				co[0].Stdout = "foo"
   245  				co[1].Stdout = "foo"
   246  				co[2].Stdout = "foo"
   247  				defer testhelper.ShouldPanicWithMessage("Unable to parse plugin API version")
   248  
   249  				_ = subject.CheckPluginExistsOnAllHosts(testCluster)
   250  			})
   251  		})
   252  		When("version command fails", func() {
   253  			It("panics with message", func() {
   254  				subject.ExecutablePath = "myFailingPlugin"
   255  				executor.ClusterOutputs[0].NumErrors = 1
   256  				defer testhelper.ShouldPanicWithMessage("Unable to execute plugin myFailingPlugin")
   257  
   258  				_ = subject.CheckPluginExistsOnAllHosts(testCluster)
   259  			})
   260  		})
   261  		When("version inconsistent", func() {
   262  			It("panics with message", func() {
   263  				executor.ClusterOutputs[0].Commands[0].Stdout = "99.99.9999"
   264  				defer testhelper.ShouldPanicWithMessage("Plugin API version is inconsistent across segments")
   265  
   266  				_ = subject.CheckPluginExistsOnAllHosts(testCluster)
   267  			})
   268  		})
   269  	})
   270  	Describe("UsesEncryption", func() {
   271  		It("returns false when there is no encryption in config", func() {
   272  			Expect(subject.UsesEncryption()).To(BeFalse())
   273  		})
   274  		It("returns true when there is local encryption in config", func() {
   275  			subject.Options["password_encryption"] = "on"
   276  			Expect(subject.UsesEncryption()).To(BeTrue())
   277  		})
   278  		It("returns true when there is remote encryption in config", func() {
   279  			subject.Options["replication"] = "on"
   280  			subject.Options["remote_password_encryption"] = "on"
   281  			Expect(subject.UsesEncryption()).To(BeTrue())
   282  		})
   283  	})
   284  	Describe("GetSecretKey", func() {
   285  		It("returns a secret key when one exists for the given name", func() {
   286  			mdd := testCluster.GetDirForContent(-1)
   287  			_ = os.MkdirAll(mdd, 0777)
   288  			secretFilePath := filepath.Join(mdd, utils.SecretKeyFile)
   289  			err := ioutil.WriteFile(secretFilePath, []byte(`gpbackup_fake_plugin: 0123456789`), 0777)
   290  			Expect(err).To(Not(HaveOccurred()))
   291  
   292  			key, err := utils.GetSecretKey("gpbackup_fake_plugin", mdd)
   293  
   294  			Expect(err).To(Not(HaveOccurred()))
   295  			Expect(key).To(Equal("0123456789"))
   296  		})
   297  		It("returns an error when no encrypt file exists for the given name", func() {
   298  			mdd := testCluster.GetDirForContent(-1)
   299  
   300  			pluginName := "gpbackup_fake_plugin"
   301  			_, err := utils.GetSecretKey(pluginName, mdd)
   302  
   303  			Expect(err).To(HaveOccurred())
   304  			Expect(err.Error()).To(Equal(fmt.Sprintf("Cannot find encryption key for plugin %s. Please re-encrypt password(s) so that key becomes available.", pluginName)))
   305  		})
   306  		It("returns an error when no key exists for the given name", func() {
   307  			mdd := testCluster.GetDirForContent(-1)
   308  			_ = os.MkdirAll(mdd, 0777)
   309  			secretFilePath := filepath.Join(mdd, utils.SecretKeyFile)
   310  			err := ioutil.WriteFile(secretFilePath, []byte(""), 0777)
   311  			Expect(err).To(Not(HaveOccurred()))
   312  
   313  			pluginName := "gpbackup_fake_plugin"
   314  			_, err = utils.GetSecretKey(pluginName, mdd)
   315  
   316  			Expect(err).To(HaveOccurred())
   317  			Expect(err.Error()).To(Equal(fmt.Sprintf("Cannot find encryption key for plugin %s. Please re-encrypt password(s) so that key becomes available.", pluginName)))
   318  		})
   319  		It("returns an error when encrypt file cannot be parsed", func() {
   320  			mdd := testCluster.GetDirForContent(-1)
   321  			_ = os.MkdirAll(mdd, 0777)
   322  			secretFilePath := filepath.Join(mdd, utils.SecretKeyFile)
   323  			err := ioutil.WriteFile(secretFilePath, []byte("improperlyFormattedYaml"), 0777)
   324  			Expect(err).To(Not(HaveOccurred()))
   325  
   326  			pluginName := "gpbackup_fake_plugin"
   327  			_, err = utils.GetSecretKey(pluginName, mdd)
   328  
   329  			Expect(err).To(HaveOccurred())
   330  			Expect(err.Error()).To(Equal(fmt.Sprintf("Cannot find encryption key for plugin %s. Please re-encrypt password(s) so that key becomes available.", pluginName)))
   331  		})
   332  	})
   333  	Describe("DeleteConfigFileOnSegments", func() {
   334  		When("config has encryption", func() {
   335  			It("sends the correct cluster command to delete config file", func() {
   336  				subject.Options["password_encryption"] = "on"
   337  
   338  				subject.DeletePluginConfigWhenEncrypting(testCluster)
   339  
   340  				Expect(executor.NumRemoteExecutions).To(Equal(1))
   341  				cc := executor.ClusterCommands[0]
   342  				Expect(len(cc)).To(Equal(3))
   343  				Expect(cc[0].Content).To(Equal(-1))
   344  				Expect(cc[0].CommandString).To(ContainSubstring("rm -f /tmp/my_plugin_config.yaml"))
   345  				Expect(cc[1].Content).To(Equal(0))
   346  				Expect(cc[1].CommandString).To(ContainSubstring("rm -f /tmp/my_plugin_config.yaml"))
   347  				Expect(cc[2].Content).To(Equal(1))
   348  				Expect(cc[2].CommandString).To(ContainSubstring("rm -f /tmp/my_plugin_config.yaml"))
   349  			})
   350  		})
   351  		When("config does not have encryption", func() {
   352  			It("does not send a cluster command to delete config file", func() {
   353  				subject.DeletePluginConfigWhenEncrypting(testCluster)
   354  
   355  				Expect(executor.NumLocalExecutions).To(Equal(0))
   356  			})
   357  		})
   358  	})
   359  	Describe("GetPluginName", func() {
   360  		It("make the correct plugin call, parses out plugin name correctly, and returns it", func() {
   361  			executor.LocalOutput = "gpbackup_fake_plugin version 1.0.1+dev.28.g00c877e"
   362  			pluginName, err := subject.GetPluginName(testCluster)
   363  
   364  			Expect(err).To(Not(HaveOccurred()))
   365  			Expect(executor.LocalCommands[0]).To(Equal("/a/b/myPlugin --version"))
   366  			Expect(pluginName).To(Equal("gpbackup_fake_plugin"))
   367  		})
   368  		It("encountered an error running plugin command", func() {
   369  			executor.LocalError = errors.New("error executing plugin")
   370  			pluginName, err := subject.GetPluginName(testCluster)
   371  
   372  			Expect(pluginName).To(Equal(""))
   373  			Expect(err).To(HaveOccurred())
   374  			Expect(err.Error()).To(Equal("ERROR: Failed to get plugin name. Failed with error: error executing plugin"))
   375  		})
   376  		It("did not recieve expected information from plugin", func() {
   377  			executor.LocalOutput = "bad output"
   378  			pluginName, err := subject.GetPluginName(testCluster)
   379  
   380  			Expect(pluginName).To(Equal(""))
   381  			Expect(err).To(HaveOccurred())
   382  			Expect(err.Error()).To(Equal("Unexpected plugin version format: \"bad output\"\nExpected: \"[plugin_name] version [git_version]\""))
   383  		})
   384  	})
   385  	Describe("ReadPluginConfig", func() {
   386  		It("returns an error if executablepath is not specified", func() {
   387  			operating.System.ReadFile = func(string) ([]byte, error) {
   388  				return []byte(`options:
   389   hostname: "myhostname"`), nil
   390  			}
   391  
   392  			_, err := utils.ReadPluginConfig("myconfigpath")
   393  			Expect(err).To(HaveOccurred())
   394  			Expect(err.Error()).To(Equal("executablepath is required in config file"))
   395  		})
   396  		It("returns an error if additional fields are present on the root level", func() {
   397  			operating.System.ReadFile = func(string) ([]byte, error) {
   398  				return []byte(`executablepath: "/usr/local/gpdb/bin/gpbackup_ddboost_plugin"
   399  options:
   400  hostname: "myhostname"`), nil
   401  			}
   402  
   403  			_, err := utils.ReadPluginConfig("myconfigpath")
   404  			Expect(err).To(HaveOccurred())
   405  			Expect(err.Error()).To(Equal("plugin config file is formatted incorrectly"))
   406  		})
   407  	})
   408  })