From a0b5380d5eea869fa949435d3a11a1380fe5b5c4 Mon Sep 17 00:00:00 2001 From: Andreas Fleig Date: Mon, 28 Nov 2016 16:22:19 +0100 Subject: [PATCH] Add mysqldump support [#130224029] --- README.md | 46 ++++++- cfmysql/cfmysqlfakes/fake_mysql_runner.go | 53 ++++++++ cfmysql/mysql_runner.go | 38 ++++++ cfmysql/mysql_runner_test.go | 73 +++++++++++ cfmysql/plugin.go | 41 +++++- cfmysql/plugin_test.go | 151 +++++++++++++++++++++- 6 files changed, 392 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f85f9bb..41517f9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,35 @@ Cloud Foundry apps. Use it to ## Usage -### Geting a list of available databases +```bash +$ cf mysql -h +NAME: + mysql - Connect to a MySQL database service + +USAGE: + Get a list of available databases: + cf mysql + + Open a mysql client to a database: + cf mysql [mysql args...] + + +$ cf mysqldump -h +NAME: + mysqldump - Dump a MySQL database + +USAGE: + Get a list of available databases: + cf mysqldump + + Dumping all tables in a database: + cf mysqldump [mysqldump args...] + + Dumping specific tables in a database: + cf mysqldump [tables...] [mysqldump args...] +``` + +### Getting a list of available databases Running the plugin without arguments should give a list of available MySQL databases: @@ -71,6 +99,22 @@ $ echo "select 1 as foo, 2 as bar;" | cf mysql my-db --xml ``` +### Dumping a database + +Running `cf mysqldump` with a database name will dump the whole database: + +```bash +$ cf mysqldump my-db --single-transaction > dump.sql +``` + +### Dumping individual tables + +Passing table names in addition to the database name will just dump those tables: + +```bash +$ cf mysqldump my-db table1 table2 --single-transaction > two-tables.sql +``` + ## Installing and uninstalling Download a binary release or build yourself by running `go build`. Then, install the plugin with diff --git a/cfmysql/cfmysqlfakes/fake_mysql_runner.go b/cfmysql/cfmysqlfakes/fake_mysql_runner.go index f0ba8c0..afdd3a3 100644 --- a/cfmysql/cfmysqlfakes/fake_mysql_runner.go +++ b/cfmysql/cfmysqlfakes/fake_mysql_runner.go @@ -21,6 +21,19 @@ type FakeMysqlRunner struct { runMysqlReturns struct { result1 error } + RunMysqlDumpStub func(hostname string, port int, dbName string, username string, password string, args ...string) error + runMysqlDumpMutex sync.RWMutex + runMysqlDumpArgsForCall []struct { + hostname string + port int + dbName string + username string + password string + args []string + } + runMysqlDumpReturns struct { + result1 error + } invocations map[string][][]interface{} invocationsMutex sync.RWMutex } @@ -63,11 +76,51 @@ func (fake *FakeMysqlRunner) RunMysqlReturns(result1 error) { }{result1} } +func (fake *FakeMysqlRunner) RunMysqlDump(hostname string, port int, dbName string, username string, password string, args ...string) error { + fake.runMysqlDumpMutex.Lock() + fake.runMysqlDumpArgsForCall = append(fake.runMysqlDumpArgsForCall, struct { + hostname string + port int + dbName string + username string + password string + args []string + }{hostname, port, dbName, username, password, args}) + fake.recordInvocation("RunMysqlDump", []interface{}{hostname, port, dbName, username, password, args}) + fake.runMysqlDumpMutex.Unlock() + if fake.RunMysqlDumpStub != nil { + return fake.RunMysqlDumpStub(hostname, port, dbName, username, password, args...) + } else { + return fake.runMysqlDumpReturns.result1 + } +} + +func (fake *FakeMysqlRunner) RunMysqlDumpCallCount() int { + fake.runMysqlDumpMutex.RLock() + defer fake.runMysqlDumpMutex.RUnlock() + return len(fake.runMysqlDumpArgsForCall) +} + +func (fake *FakeMysqlRunner) RunMysqlDumpArgsForCall(i int) (string, int, string, string, string, []string) { + fake.runMysqlDumpMutex.RLock() + defer fake.runMysqlDumpMutex.RUnlock() + return fake.runMysqlDumpArgsForCall[i].hostname, fake.runMysqlDumpArgsForCall[i].port, fake.runMysqlDumpArgsForCall[i].dbName, fake.runMysqlDumpArgsForCall[i].username, fake.runMysqlDumpArgsForCall[i].password, fake.runMysqlDumpArgsForCall[i].args +} + +func (fake *FakeMysqlRunner) RunMysqlDumpReturns(result1 error) { + fake.RunMysqlDumpStub = nil + fake.runMysqlDumpReturns = struct { + result1 error + }{result1} +} + func (fake *FakeMysqlRunner) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() fake.runMysqlMutex.RLock() defer fake.runMysqlMutex.RUnlock() + fake.runMysqlDumpMutex.RLock() + defer fake.runMysqlDumpMutex.RUnlock() return fake.invocations } diff --git a/cfmysql/mysql_runner.go b/cfmysql/mysql_runner.go index 11cb337..eeb24ef 100644 --- a/cfmysql/mysql_runner.go +++ b/cfmysql/mysql_runner.go @@ -6,11 +6,13 @@ import ( "code.cloudfoundry.org/cli/cf/errors" "fmt" "os" + "strings" ) //go:generate counterfeiter . MysqlRunner type MysqlRunner interface { RunMysql(hostname string, port int, dbName string, username string, password string, args ...string) error + RunMysqlDump(hostname string, port int, dbName string, username string, password string, args ...string) error } type MysqlClientRunner struct { @@ -40,6 +42,42 @@ func (self *MysqlClientRunner) RunMysql(hostname string, port int, dbName string return nil } +func (self *MysqlClientRunner) RunMysqlDump(hostname string, port int, dbName string, username string, password string, mysqlDumpArgs ...string) error { + path, err := self.ExecWrapper.LookPath("mysqldump") + if err != nil { + return errors.New("'mysqldump' not found in PATH") + } + + tableArgs := []string{} + nonTableArgs := mysqlDumpArgs[0:] + + for i, argument := range (mysqlDumpArgs) { + if strings.HasPrefix(argument, "-") { + break + } + + tableArgs = append(tableArgs, argument) + nonTableArgs = mysqlDumpArgs[i + 1:] + } + + args := []string{"-u", username, "-p" + password, "-h", hostname, "-P", strconv.Itoa(port)} + args = append(args, nonTableArgs...) + args = append(args, dbName) + args = append(args, tableArgs...) + + cmd := exec.Command(path, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = self.ExecWrapper.Run(cmd) + if err != nil { + return fmt.Errorf("Error running mysqldump: %s", err) + } + + return nil +} + func (self *MysqlClientRunner) MakeMysqlCommand(hostname string, port int, dbName string, username string, password string) *exec.Cmd { return exec.Command("mysql", "-u", "username", "-p" + password, "-h", "hostname", "-P", strconv.Itoa(port), dbName) } diff --git a/cfmysql/mysql_runner_test.go b/cfmysql/mysql_runner_test.go index 70bc29d..0430b9b 100644 --- a/cfmysql/mysql_runner_test.go +++ b/cfmysql/mysql_runner_test.go @@ -29,6 +29,7 @@ var _ = Describe("MysqlRunner", func() { err := runner.RunMysql("hostname", 42, "dbname", "username", "password") Expect(err).To(Equal(errors.New("'mysql' client not found in PATH"))) + Expect(exec.LookPathArgsForCall(0)).To(Equal("mysql")) }) }) @@ -81,4 +82,76 @@ var _ = Describe("MysqlRunner", func() { }) }) }) + + Context("RunMysqlDump", func() { + var exec *cfmysqlfakes.FakeExec + var runner MysqlClientRunner + + BeforeEach(func() { + exec = new(cfmysqlfakes.FakeExec) + runner = MysqlClientRunner{ + ExecWrapper: exec, + } + }) + + Context("When mysqldump is not in PATH", func() { + It("Returns an error", func() { + exec.LookPathReturns("", errors.New("PC LOAD LETTER")) + + err := runner.RunMysqlDump("hostname", 42, "dbname", "username", "password") + + Expect(err).To(Equal(errors.New("'mysqldump' not found in PATH"))) + Expect(exec.LookPathArgsForCall(0)).To(Equal("mysqldump")) + }) + }) + + Context("When Run returns an error", func() { + It("Forwards the error", func() { + exec.LookPathReturns("/path/to/mysqldump", nil) + exec.RunReturns(errors.New("PC LOAD LETTER")) + + err := runner.RunMysqlDump("hostname", 42, "dbname", "username", "password") + + Expect(err).To(Equal(errors.New("Error running mysqldump: PC LOAD LETTER"))) + }) + }) + + Context("When mysqldump is in PATH", func() { + It("Calls mysqldump with the right arguments", func() { + exec.LookPathReturns("/path/to/mysqldump", nil) + + err := runner.RunMysqlDump("hostname", 42, "dbname", "username", "password") + + Expect(err).To(BeNil()) + Expect(exec.LookPathCallCount()).To(Equal(1)) + Expect(exec.RunCallCount()).To(Equal(1)) + + cmd := exec.RunArgsForCall(0) + Expect(cmd.Path).To(Equal("/path/to/mysqldump")) + Expect(cmd.Args).To(Equal([]string{"/path/to/mysqldump", "-u", "username", "-ppassword", "-h", "hostname", "-P", "42", "dbname"})) + Expect(cmd.Stdin).To(Equal(os.Stdin)) + Expect(cmd.Stdout).To(Equal(os.Stdout)) + Expect(cmd.Stderr).To(Equal(os.Stderr)) + }) + }) + + Context("When mysqldump is in PATH and additional arguments are passed", func() { + It("Calls mysqldump with the right arguments", func() { + exec.LookPathReturns("/path/to/mysqldump", nil) + + err := runner.RunMysqlDump("hostname", 42, "dbname", "username", "password", "table1", "table2", "--foo", "bar", "--baz") + + Expect(err).To(BeNil()) + Expect(exec.LookPathCallCount()).To(Equal(1)) + Expect(exec.RunCallCount()).To(Equal(1)) + + cmd := exec.RunArgsForCall(0) + Expect(cmd.Path).To(Equal("/path/to/mysqldump")) + Expect(cmd.Args).To(Equal([]string{"/path/to/mysqldump", "-u", "username", "-ppassword", "-h", "hostname", "-P", "42", "--foo", "bar", "--baz", "dbname", "table1", "table2"})) + Expect(cmd.Stdin).To(Equal(os.Stdin)) + Expect(cmd.Stdout).To(Equal(os.Stdout)) + Expect(cmd.Stderr).To(Equal(os.Stderr)) + }) + }) + }) }) diff --git a/cfmysql/plugin.go b/cfmysql/plugin.go index c477fc7..f2a1187 100644 --- a/cfmysql/plugin.go +++ b/cfmysql/plugin.go @@ -22,7 +22,7 @@ func (self *MysqlPlugin) GetMetadata() plugin.PluginMetadata { Name: "mysql", Version: plugin.VersionType{ Major: 1, - Minor: 0, + Minor: 3, Build: 0, }, MinCliVersion: plugin.VersionType{ @@ -41,21 +41,38 @@ func (self *MysqlPlugin) GetMetadata() plugin.PluginMetadata { "cf mysql [mysql args...]", }, }, + { + Name: "mysqldump", + HelpText: "Dump a MySQL database", + UsageDetails: plugin.Usage{ + Usage: "Get a list of available databases:\n " + + "cf mysqldump\n\n " + + "Dumping all tables in a database:\n " + + "cf mysqldump [mysqldump args...]\n\n " + + "Dumping specific tables in a database:\n " + + "cf mysqldump [tables...] [mysqldump args...]", + }, + }, }, } } func (self *MysqlPlugin) Run(cliConnection plugin.CliConnection, args []string) { command := args[0] - mysqlArgs := []string{} - if len(args) > 2 { - mysqlArgs = args[2:] - } switch command { case "mysql": + fallthrough + + case "mysqldump": if len(args) > 1 { dbName := args[1] + + mysqlArgs := []string{} + if len(args) > 2 { + mysqlArgs = args[2:] + } + self.connectTo(cliConnection, command, dbName, mysqlArgs) } else { self.showServices(cliConnection, command) @@ -103,7 +120,7 @@ func (self *MysqlPlugin) connectTo(cliConnection plugin.CliConnection, command s tunnelPort := self.PortFinder.GetPort() self.ApiClient.OpenSshTunnel(cliConnection, *service, startedApps[0].Name, tunnelPort) - err = self.MysqlRunner.RunMysql("127.0.0.1", tunnelPort, service.DbName, service.Username, service.Password, mysqlArgs...) + err = self.runClient(command, "127.0.0.1", tunnelPort, service.DbName, service.Username, service.Password, mysqlArgs...) if err != nil { fmt.Fprintf(self.Err, "FAILED\n%s", err) self.setErrorExit() @@ -119,6 +136,18 @@ func getServiceByName(services []MysqlService, dbName string) (*MysqlService, bo return nil, false } +func (self *MysqlPlugin) runClient(command string, hostname string, port int, dbName string, username string, password string, args ...string) error { + switch command { + case "mysql": + return self.MysqlRunner.RunMysql(hostname, port, dbName, username, password, args...) + + case "mysqldump": + return self.MysqlRunner.RunMysqlDump(hostname, port, dbName, username, password, args...) + } + + panic(fmt.Errorf("Command not implemented: %s", command)) +} + func (self *MysqlPlugin) showServices(cliConnection plugin.CliConnection, command string) { services, err := self.ApiClient.GetMysqlServices(cliConnection) if err != nil { diff --git a/cfmysql/plugin_test.go b/cfmysql/plugin_test.go index 85ac56d..9ea74a0 100644 --- a/cfmysql/plugin_test.go +++ b/cfmysql/plugin_test.go @@ -38,13 +38,13 @@ var _ = Describe("Plugin", func() { }) Context("When calling 'cf plugins'", func() { - It("Shows the mysql plugin with version 1.0.0", func() { + It("Shows the mysql plugin with the current version", func() { mysqlPlugin := NewPlugin() Expect(mysqlPlugin.GetMetadata().Name).To(Equal("mysql")) Expect(mysqlPlugin.GetMetadata().Version).To(Equal(plugin.VersionType{ Major: 1, - Minor: 0, + Minor: 3, Build: 0, })) }) @@ -54,7 +54,7 @@ var _ = Describe("Plugin", func() { It("Shows instructions for 'cf mysql'", func() { mysqlPlugin := NewPlugin() - Expect(mysqlPlugin.GetMetadata().Commands).To(HaveLen(1)) + Expect(mysqlPlugin.GetMetadata().Commands).To(HaveLen(2)) Expect(mysqlPlugin.GetMetadata().Commands[0].Name).To(Equal("mysql")) }) }) @@ -274,6 +274,151 @@ var _ = Describe("Plugin", func() { }) + Context("When calling 'cf mysqldump -h'", func() { + It("Shows instructions for 'cf mysqldump'", func() { + mysqlPlugin := NewPlugin() + + Expect(mysqlPlugin.GetMetadata().Commands).To(HaveLen(2)) + Expect(mysqlPlugin.GetMetadata().Commands[1].Name).To(Equal("mysqldump")) + }) + }) + + Context("When calling 'cf mysqldump' without arguments", func() { + Context("With databases available", func() { + var serviceA, serviceB MysqlService + + BeforeEach(func() { + serviceA = MysqlService{ + Name: "database-a", + Hostname: "database-a.host", + Port: "123", + DbName: "dbname-a", + } + serviceB = MysqlService{ + Name: "database-b", + Hostname: "database-b.host", + Port: "234", + DbName: "dbname-b", + } + }) + + It("Lists the available MySQL databases", func() { + apiClient.GetMysqlServicesReturns([]MysqlService{serviceA, serviceB}, nil) + + mysqlPlugin.Run(cliConnection, []string{"mysqldump"}) + + Expect(apiClient.GetMysqlServicesCallCount()).To(Equal(1)) + Expect(out).To(gbytes.Say("MySQL databases bound to an app:\n\ndatabase-a\ndatabase-b\n")) + Expect(err).To(gbytes.Say("")) + Expect(mysqlPlugin.GetExitCode()).To(Equal(0)) + }) + }) + + Context("With no databases available", func() { + It("Tells the user that databases must be bound to a started app", func() { + apiClient.GetMysqlServicesReturns([]MysqlService{}, nil) + + mysqlPlugin.Run(cliConnection, []string{"mysqldump"}) + + Expect(apiClient.GetMysqlServicesCallCount()).To(Equal(1)) + Expect(out).To(gbytes.Say("")) + Expect(err).To(gbytes.Say("No MySQL databases available. Please bind your database services to a started app to make them available to 'cf mysqldump'.")) + Expect(mysqlPlugin.GetExitCode()).To(Equal(0)) + }) + }) + + Context("With failing API calls", func() { + It("Shows an error message", func() { + apiClient.GetMysqlServicesReturns(nil, fmt.Errorf("foo")) + + mysqlPlugin.Run(cliConnection, []string{"mysqldump"}) + + Expect(apiClient.GetMysqlServicesCallCount()).To(Equal(1)) + Expect(out).To(gbytes.Say("")) + Expect(err).To(gbytes.Say("Unable to retrieve services: foo\n")) + Expect(mysqlPlugin.GetExitCode()).To(Equal(1)) + }) + }) + }) + + Context("When calling 'cf mysql db-name'", func() { + var serviceA, serviceB MysqlService + + BeforeEach(func() { + serviceA = MysqlService{ + Name: "database-a", + Hostname: "database-a.host", + Port: "123", + DbName: "dbname-a", + Username: "username", + Password: "password", + } + serviceB = MysqlService{ + Name: "database-b", + Hostname: "database-b.host", + Port: "234", + DbName: "dbname-b", + } + }) + + Context("When the database is available", func() { + var app plugin_models.GetAppsModel + var mysqlRunner *cfmysqlfakes.FakeMysqlRunner + + BeforeEach(func() { + app = plugin_models.GetAppsModel{ + Name: "app-name", + } + mysqlRunner = new(cfmysqlfakes.FakeMysqlRunner) + mysqlPlugin = MysqlPlugin{ + In: in, + Out: out, + Err: err, + ApiClient: apiClient, + MysqlRunner: mysqlRunner, + PortFinder: portFinder, + } + }) + + It("Opens an SSH tunnel through a started app", func() { + apiClient.GetMysqlServicesReturns([]MysqlService{serviceA, serviceB}, nil) + apiClient.GetStartedAppsReturns([]plugin_models.GetAppsModel{app}, nil) + portFinder.GetPortReturns(2342) + + mysqlPlugin.Run(cliConnection, []string{"mysqldump", "database-a"}) + + Expect(apiClient.GetMysqlServicesCallCount()).To(Equal(1)) + Expect(apiClient.GetStartedAppsCallCount()).To(Equal(1)) + Expect(portFinder.GetPortCallCount()).To(Equal(1)) + Expect(apiClient.OpenSshTunnelCallCount()).To(Equal(1)) + + calledCliConnection, calledService, calledAppName, localPort := apiClient.OpenSshTunnelArgsForCall(0) + Expect(calledCliConnection).To(Equal(cliConnection)) + Expect(calledService).To(Equal(serviceA)) + Expect(calledAppName).To(Equal("app-name")) + Expect(localPort).To(Equal(2342)) + }) + + It("Opens mysqldump connecting through the tunnel", func() { + apiClient.GetMysqlServicesReturns([]MysqlService{serviceA, serviceB}, nil) + apiClient.GetStartedAppsReturns([]plugin_models.GetAppsModel{app}, nil) + portFinder.GetPortReturns(2342) + + mysqlPlugin.Run(cliConnection, []string{"mysqldump", "database-a"}) + + Expect(portFinder.GetPortCallCount()).To(Equal(1)) + Expect(mysqlRunner.RunMysqlDumpCallCount()).To(Equal(1)) + + hostname, port, dbName, username, password, _ := mysqlRunner.RunMysqlDumpArgsForCall(0) + Expect(hostname).To(Equal("127.0.0.1")) + Expect(port).To(Equal(2342)) + Expect(dbName).To(Equal(serviceA.DbName)) + Expect(username).To(Equal(serviceA.Username)) + Expect(password).To(Equal(serviceA.Password)) + }) + }) + }) + Context("When the plugin is being uninstalled", func() { It("Does not give any output or call the API", func() { mysqlPlugin.Run(cliConnection, []string{"CLI-MESSAGE-UNINSTALL"})