| package utils_test |
| |
| import ( |
| "fmt" |
| "io/ioutil" |
| "os" |
| "path/filepath" |
| "regexp" |
| "strconv" |
| "strings" |
| |
| "github.com/apache/cloudberry-backup/utils" |
| "github.com/apache/cloudberry-go-libs/cluster" |
| "github.com/apache/cloudberry-go-libs/iohelper" |
| "github.com/apache/cloudberry-go-libs/operating" |
| "github.com/apache/cloudberry-go-libs/testhelper" |
| "github.com/blang/semver" |
| "github.com/pkg/errors" |
| |
| . "github.com/onsi/ginkgo/v2" |
| . "github.com/onsi/gomega" |
| ) |
| |
| var _ = Describe("utils/plugin tests", func() { |
| var testCluster *cluster.Cluster |
| var executor testhelper.TestExecutor |
| var subject utils.PluginConfig |
| var tempDir string |
| |
| BeforeEach(func() { |
| operating.InitializeSystemFunctions() |
| tempDir, _ = ioutil.TempDir("", "temp") |
| operating.System.Stdout = stdout |
| subject = utils.PluginConfig{ |
| ExecutablePath: "/a/b/myPlugin", |
| ConfigPath: "/tmp/my_plugin_config.yaml", |
| Options: make(map[string]string), |
| } |
| subject.Options = make(map[string]string) |
| executor = testhelper.TestExecutor{ |
| ClusterOutputs: make([]*cluster.RemoteOutput, 2), |
| UseLastOutput: true, |
| } |
| executor.ClusterOutputs[0] = &cluster.RemoteOutput{ |
| Commands: []cluster.ShellCommand{ |
| cluster.ShellCommand{Content: -1, Stdout: utils.RequiredPluginVersion}, |
| cluster.ShellCommand{Content: 0, Stdout: utils.RequiredPluginVersion}, |
| cluster.ShellCommand{Content: 1, Stdout: utils.RequiredPluginVersion}, |
| }, |
| } |
| executor.ClusterOutputs[1] = &cluster.RemoteOutput{ |
| Commands: []cluster.ShellCommand{ |
| cluster.ShellCommand{Content: -1, Stdout: "myPlugin version 1.2.3"}, |
| cluster.ShellCommand{Content: 0, Stdout: "myPlugin version 1.2.3"}, |
| cluster.ShellCommand{Content: 1, Stdout: "myPlugin version 1.2.3"}, |
| }, |
| } |
| testCluster = cluster.NewCluster([]cluster.SegConfig{ |
| {ContentID: -1, DataDir: filepath.Join(tempDir, "seg-1"), Hostname: "coordinator", Port: 100}, |
| {ContentID: 0, DataDir: filepath.Join(tempDir, "seg0"), Hostname: "segment1", Port: 101}, |
| {ContentID: 1, DataDir: filepath.Join(tempDir, "seg1"), Hostname: "segment2", Port: 102}, |
| }) |
| testCluster.Executor = &executor |
| }) |
| AfterEach(func() { |
| err := os.RemoveAll(tempDir) |
| Expect(err).To(Not(HaveOccurred())) |
| _ = os.Remove(subject.ConfigPath) |
| confDir := filepath.Dir(subject.ConfigPath) |
| confFileName := filepath.Base(subject.ConfigPath) |
| files, _ := ioutil.ReadDir(confDir) |
| for _, f := range files { |
| match, _ := filepath.Match(confFileName+"*", f.Name()) |
| if match { |
| _ = os.Remove(confDir + "/" + f.Name()) |
| } |
| } |
| }) |
| Describe("plugin versions via CheckPluginExistsOnAllHosts()", func() { |
| It(" generates the correct commands", func() { |
| operating.System.Getenv = func(key string) string { |
| return "my/install/dir" |
| } |
| |
| _ = subject.CheckPluginExistsOnAllHosts(testCluster) |
| |
| apiVersionCommands := executor.ClusterCommands[0] |
| expectedCommand := "source my/install/dir/greenplum_path.sh && /a/b/myPlugin plugin_api_version" |
| for _, shellCommands := range apiVersionCommands { |
| Expect(shellCommands.CommandString).To(ContainSubstring(expectedCommand)) |
| } |
| nativeVersionCommands := executor.ClusterCommands[1] |
| expectedCommand = "source my/install/dir/greenplum_path.sh && /a/b/myPlugin --version" |
| // for _, contentID := range testCluster.ContentIDs { |
| // cmd := nativeVersionCommands[contentID] |
| // Expect(cmd[len(cmd)-1]).To(Equal(expectedCommand)) |
| // } |
| for _, shellCommands := range nativeVersionCommands { |
| Expect(shellCommands.CommandString).To(ContainSubstring(expectedCommand)) |
| } |
| }) |
| }) |
| Describe("creates segment-specific plugin config and copies it to all hosts", func() { |
| It("appends PGPORT and the --version of the plugin", func() { |
| testConfigPath := "/tmp/my_plugin_config.yaml" |
| testConfigContents := ` |
| executablepath: /tmp/fake_path |
| options: |
| field1: 12 |
| field2: hello |
| field3: 567 |
| ` |
| err := ioutil.WriteFile(testConfigPath, []byte(testConfigContents), 0777) |
| Expect(err).To(Not(HaveOccurred())) |
| subject.SetBackupPluginVersion("myTimestamp", "my.test.version") |
| subject.CopyPluginConfigToAllHosts(testCluster) |
| |
| Expect(executor.NumClusterExecutions).To(Equal(1)) |
| cc := executor.ClusterCommands[0] |
| Expect(len(cc)).To(Equal(3)) |
| Expect(cc[0].Content).To(Equal(-1)) |
| Expect(cc[0].CommandString).To(MatchRegexp(`rsync -e ssh .*-1 coordinator:\/tmp\/my_plugin_config\.yaml; rm .*-1`)) |
| Expect(cc[1].Content).To(Equal(0)) |
| Expect(cc[1].CommandString).To(MatchRegexp(`rsync -e ssh .*0 segment1:\/tmp\/my_plugin_config\.yaml; rm .*0`)) |
| Expect(cc[2].Content).To(Equal(1)) |
| Expect(cc[2].CommandString).To(MatchRegexp(`rsync -e ssh .*1 segment2:\/tmp\/my_plugin_config\.yaml; rm .*1`)) |
| |
| rgx := regexp.MustCompile(`rsync -e ssh (.*-1) coordinator:\/tmp\/my_plugin_config\.yaml; rm .*-1`) |
| rs := rgx.FindStringSubmatch(cc[0].CommandString) |
| coordinatorConfigPath := rs[1] |
| rgx = regexp.MustCompile(`rsync -e ssh (.*0) segment1:\/tmp\/my_plugin_config\.yaml; rm .*0`) |
| rs = rgx.FindStringSubmatch(cc[1].CommandString) |
| segmentOneConfigPath := rs[1] |
| rgx = regexp.MustCompile(`rsync -e ssh (.*1) segment2:\/tmp\/my_plugin_config\.yaml; rm .*1`) |
| rs = rgx.FindStringSubmatch(cc[2].CommandString) |
| segmentTwoConfigPath := rs[1] |
| |
| // check contents |
| contents := strings.Join(iohelper.MustReadLinesFromFile(coordinatorConfigPath), "\n") |
| Expect(contents).To(ContainSubstring("\n pgport: \"100\"")) |
| Expect(contents).To(ContainSubstring("\n backup_plugin_version: my.test.version")) |
| contents = strings.Join(iohelper.MustReadLinesFromFile(segmentOneConfigPath), "\n") |
| Expect(contents).To(ContainSubstring("\n pgport: \"101\"")) |
| Expect(contents).To(ContainSubstring("\n backup_plugin_version: my.test.version")) |
| contents = strings.Join(iohelper.MustReadLinesFromFile(segmentTwoConfigPath), "\n") |
| Expect(contents).To(ContainSubstring("\n pgport: \"102\"")) |
| Expect(contents).To(ContainSubstring("\n backup_plugin_version: my.test.version")) |
| }) |
| When("copying for a plugin with encryption", func() { |
| It("copies the encryption key", func() { |
| executor.LocalOutput = "gpbackup_fake_plugin version 1.0.1+dev.28.g00c877e" |
| testConfigPath := "/tmp/my_plugin_config.yaml" |
| testConfigContents := ` |
| executablepath: /tmp/foobar |
| options: |
| field1: 12 |
| field2: hello |
| field3: 567 |
| ` |
| err := ioutil.WriteFile(testConfigPath, []byte(testConfigContents), 0777) |
| subject.Options["password_encryption"] = "on" |
| mdd := testCluster.GetDirForContent(-1) |
| _ = os.MkdirAll(mdd, 0777) |
| secretFilePath := filepath.Join(mdd, utils.SecretKeyFile) |
| secretFile := iohelper.MustOpenFileForWriting(secretFilePath) |
| _, err = secretFile.Write([]byte(`gpbackup_fake_plugin: 0123456789`)) |
| Expect(err).To(Not(HaveOccurred())) |
| |
| subject.CopyPluginConfigToAllHosts(testCluster) |
| |
| // check contents |
| cc := executor.ClusterCommands[0] |
| rgx := regexp.MustCompile(`rsync -e ssh (.*-1) coordinator:\/tmp\/my_plugin_config\.yaml; rm .*-1`) |
| rs := rgx.FindStringSubmatch(cc[0].CommandString) |
| coordinatorConfigPath := rs[1] |
| rgx = regexp.MustCompile(`rsync -e ssh (.*0) segment1:\/tmp\/my_plugin_config\.yaml; rm .*0`) |
| rs = rgx.FindStringSubmatch(cc[1].CommandString) |
| segmentOneConfigPath := rs[1] |
| rgx = regexp.MustCompile(`rsync -e ssh (.*1) segment2:\/tmp\/my_plugin_config\.yaml; rm .*1`) |
| rs = rgx.FindStringSubmatch(cc[2].CommandString) |
| segmentTwoConfigPath := rs[1] |
| |
| // check contents |
| contents := strings.Join(iohelper.MustReadLinesFromFile(coordinatorConfigPath), "\n") |
| Expect(contents).To(ContainSubstring("\n gpbackup_fake_plugin: \"0123456789\"")) |
| contents = strings.Join(iohelper.MustReadLinesFromFile(segmentOneConfigPath), "\n") |
| Expect(contents).To(ContainSubstring("\n gpbackup_fake_plugin: \"0123456789\"")) |
| contents = strings.Join(iohelper.MustReadLinesFromFile(segmentTwoConfigPath), "\n") |
| Expect(contents).To(ContainSubstring("\n gpbackup_fake_plugin: \"0123456789\"")) |
| }) |
| It("writes a stdout message when encrypt key is not found", func() { |
| subject.Options["password_encryption"] = "on" |
| executor.LocalOutput = "gpbackup_fake_plugin version 1.0.1+dev.28.g00c877e" |
| pluginName, err := subject.GetPluginName(testCluster) |
| Expect(err).To(Not(HaveOccurred())) |
| errMsg := fmt.Sprintf("Cannot find encryption key for plugin %s. Please re-encrypt password(s) so that key becomes available.", pluginName) |
| defer testhelper.ShouldPanicWithMessage(errMsg) |
| subject.CopyPluginConfigToAllHosts(testCluster) |
| |
| Expect(string(stdout.Contents())).To(ContainSubstring(errMsg)) |
| Expect(string(stdout.Contents())).To(ContainSubstring(errMsg)) |
| }) |
| }) |
| }) |
| Describe("version validation", func() { |
| When("version is equal to requirement", func() { |
| It("succeeds", func() { |
| subject.CheckPluginExistsOnAllHosts(testCluster) |
| }) |
| }) |
| When("version is greater than requirement", func() { |
| It("succeeds", func() { |
| // add one to whatever the current required version might be |
| version, _ := semver.Make(utils.RequiredPluginVersion) |
| greater, _ := semver.Make(strconv.Itoa(int(version.Major)+1) + ".0.0") |
| co := executor.ClusterOutputs[0].Commands |
| Expect(co[0].Content).To(Equal(-1)) |
| co[0].Stdout = greater.String() |
| Expect(co[1].Content).To(Equal(0)) |
| co[1].Stdout = greater.String() |
| Expect(co[2].Content).To(Equal(1)) |
| co[2].Stdout = greater.String() |
| |
| co[1].Stdout = greater.String() |
| co[2].Stdout = greater.String() |
| |
| _ = subject.CheckPluginExistsOnAllHosts(testCluster) |
| }) |
| }) |
| When("version is too low", func() { |
| It("panics with message", func() { |
| co := executor.ClusterOutputs[0].Commands |
| co[0].Stdout = "0.2.0" |
| co[1].Stdout = "0.2.0" |
| co[2].Stdout = "0.2.0" |
| defer testhelper.ShouldPanicWithMessage("Plugin API version incorrect") |
| |
| _ = subject.CheckPluginExistsOnAllHosts(testCluster) |
| }) |
| }) |
| When("version cannot be parsed", func() { |
| It("panics with message", func() { |
| co := executor.ClusterOutputs[0].Commands |
| co[0].Stdout = "foo" |
| co[1].Stdout = "foo" |
| co[2].Stdout = "foo" |
| defer testhelper.ShouldPanicWithMessage("Unable to parse plugin API version") |
| |
| _ = subject.CheckPluginExistsOnAllHosts(testCluster) |
| }) |
| }) |
| When("version command fails", func() { |
| It("panics with message", func() { |
| subject.ExecutablePath = "myFailingPlugin" |
| executor.ClusterOutputs[0].NumErrors = 1 |
| defer testhelper.ShouldPanicWithMessage("Unable to execute plugin myFailingPlugin") |
| |
| _ = subject.CheckPluginExistsOnAllHosts(testCluster) |
| }) |
| }) |
| When("version inconsistent", func() { |
| It("panics with message", func() { |
| executor.ClusterOutputs[0].Commands[0].Stdout = "99.99.9999" |
| defer testhelper.ShouldPanicWithMessage("Plugin API version is inconsistent across segments") |
| |
| _ = subject.CheckPluginExistsOnAllHosts(testCluster) |
| }) |
| }) |
| }) |
| Describe("UsesEncryption", func() { |
| It("returns false when there is no encryption in config", func() { |
| Expect(subject.UsesEncryption()).To(BeFalse()) |
| }) |
| It("returns true when there is local encryption in config", func() { |
| subject.Options["password_encryption"] = "on" |
| Expect(subject.UsesEncryption()).To(BeTrue()) |
| }) |
| It("returns true when there is remote encryption in config", func() { |
| subject.Options["replication"] = "on" |
| subject.Options["remote_password_encryption"] = "on" |
| Expect(subject.UsesEncryption()).To(BeTrue()) |
| }) |
| }) |
| Describe("GetSecretKey", func() { |
| It("returns a secret key when one exists for the given name", func() { |
| mdd := testCluster.GetDirForContent(-1) |
| _ = os.MkdirAll(mdd, 0777) |
| secretFilePath := filepath.Join(mdd, utils.SecretKeyFile) |
| err := ioutil.WriteFile(secretFilePath, []byte(`gpbackup_fake_plugin: 0123456789`), 0777) |
| Expect(err).To(Not(HaveOccurred())) |
| |
| key, err := utils.GetSecretKey("gpbackup_fake_plugin", mdd) |
| |
| Expect(err).To(Not(HaveOccurred())) |
| Expect(key).To(Equal("0123456789")) |
| }) |
| It("returns an error when no encrypt file exists for the given name", func() { |
| mdd := testCluster.GetDirForContent(-1) |
| |
| pluginName := "gpbackup_fake_plugin" |
| _, err := utils.GetSecretKey(pluginName, mdd) |
| |
| Expect(err).To(HaveOccurred()) |
| 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))) |
| }) |
| It("returns an error when no key exists for the given name", func() { |
| mdd := testCluster.GetDirForContent(-1) |
| _ = os.MkdirAll(mdd, 0777) |
| secretFilePath := filepath.Join(mdd, utils.SecretKeyFile) |
| err := ioutil.WriteFile(secretFilePath, []byte(""), 0777) |
| Expect(err).To(Not(HaveOccurred())) |
| |
| pluginName := "gpbackup_fake_plugin" |
| _, err = utils.GetSecretKey(pluginName, mdd) |
| |
| Expect(err).To(HaveOccurred()) |
| 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))) |
| }) |
| It("returns an error when encrypt file cannot be parsed", func() { |
| mdd := testCluster.GetDirForContent(-1) |
| _ = os.MkdirAll(mdd, 0777) |
| secretFilePath := filepath.Join(mdd, utils.SecretKeyFile) |
| err := ioutil.WriteFile(secretFilePath, []byte("improperlyFormattedYaml"), 0777) |
| Expect(err).To(Not(HaveOccurred())) |
| |
| pluginName := "gpbackup_fake_plugin" |
| _, err = utils.GetSecretKey(pluginName, mdd) |
| |
| Expect(err).To(HaveOccurred()) |
| 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))) |
| }) |
| }) |
| Describe("DeleteConfigFileOnSegments", func() { |
| When("config has encryption", func() { |
| It("sends the correct cluster command to delete config file", func() { |
| subject.Options["password_encryption"] = "on" |
| |
| subject.DeletePluginConfigWhenEncrypting(testCluster) |
| |
| Expect(executor.NumClusterExecutions).To(Equal(1)) |
| cc := executor.ClusterCommands[0] |
| Expect(len(cc)).To(Equal(3)) |
| Expect(cc[0].Content).To(Equal(-1)) |
| Expect(cc[0].CommandString).To(ContainSubstring("rm -f /tmp/my_plugin_config.yaml")) |
| Expect(cc[1].Content).To(Equal(0)) |
| Expect(cc[1].CommandString).To(ContainSubstring("rm -f /tmp/my_plugin_config.yaml")) |
| Expect(cc[2].Content).To(Equal(1)) |
| Expect(cc[2].CommandString).To(ContainSubstring("rm -f /tmp/my_plugin_config.yaml")) |
| }) |
| }) |
| When("config does not have encryption", func() { |
| It("does not send a cluster command to delete config file", func() { |
| subject.DeletePluginConfigWhenEncrypting(testCluster) |
| |
| Expect(executor.NumLocalExecutions).To(Equal(0)) |
| }) |
| }) |
| }) |
| Describe("GetPluginName", func() { |
| It("make the correct plugin call, parses out plugin name correctly, and returns it", func() { |
| executor.LocalOutput = "gpbackup_fake_plugin version 1.0.1+dev.28.g00c877e" |
| pluginName, err := subject.GetPluginName(testCluster) |
| |
| Expect(err).To(Not(HaveOccurred())) |
| Expect(executor.LocalCommands[0]).To(Equal("/a/b/myPlugin --version")) |
| Expect(pluginName).To(Equal("gpbackup_fake_plugin")) |
| }) |
| It("encountered an error running plugin command", func() { |
| executor.LocalError = errors.New("error executing plugin") |
| pluginName, err := subject.GetPluginName(testCluster) |
| |
| Expect(pluginName).To(Equal("")) |
| Expect(err).To(HaveOccurred()) |
| Expect(err.Error()).To(Equal("ERROR: Failed to get plugin name. Failed with error: error executing plugin")) |
| }) |
| It("did not recieve expected information from plugin", func() { |
| executor.LocalOutput = "bad output" |
| pluginName, err := subject.GetPluginName(testCluster) |
| |
| Expect(pluginName).To(Equal("")) |
| Expect(err).To(HaveOccurred()) |
| Expect(err.Error()).To(Equal("Unexpected plugin version format: \"bad output\"\nExpected: \"[plugin_name] version [git_version]\"")) |
| }) |
| }) |
| Describe("ReadPluginConfig", func() { |
| It("returns an error if executablepath is not specified", func() { |
| operating.System.ReadFile = func(string) ([]byte, error) { |
| return []byte(`options: |
| hostname: "myhostname"`), nil |
| } |
| |
| _, err := utils.ReadPluginConfig("myconfigpath") |
| Expect(err).To(HaveOccurred()) |
| Expect(err.Error()).To(Equal("executablepath is required in config file")) |
| }) |
| It("returns an error if additional fields are present on the root level", func() { |
| operating.System.ReadFile = func(string) ([]byte, error) { |
| return []byte(`executablepath: "/usr/local/gpdb/bin/gpbackup_ddboost_plugin" |
| options: |
| hostname: "myhostname"`), nil |
| } |
| |
| _, err := utils.ReadPluginConfig("myconfigpath") |
| Expect(err).To(HaveOccurred()) |
| Expect(err.Error()).To(Equal("plugin config file is formatted incorrectly")) |
| }) |
| }) |
| }) |