From ba46cc29377f04980cbe49483ff4943c5fb15754 Mon Sep 17 00:00:00 2001 From: Tang Rufus Date: Wed, 25 Sep 2019 21:26:18 +0100 Subject: [PATCH 1/5] Add `db open` command --- README.md | 1 + cmd/db.go | 29 +++++ cmd/db_open.go | 187 +++++++++++++++++++++++++++++++ cmd/db_opener_factory.go | 24 ++++ cmd/db_opener_factory_test.go | 41 +++++++ cmd/db_opener_sequel_pro.go | 84 ++++++++++++++ cmd/db_opener_sequel_pro_test.go | 46 ++++++++ cmd/db_opener_tableplus.go | 34 ++++++ cmd/db_opener_tableplus_test.go | 27 +++++ main.go | 6 + 10 files changed, 479 insertions(+) create mode 100644 cmd/db.go create mode 100644 cmd/db_open.go create mode 100644 cmd/db_opener_factory.go create mode 100644 cmd/db_opener_factory_test.go create mode 100644 cmd/db_opener_sequel_pro.go create mode 100644 cmd/db_opener_sequel_pro_test.go create mode 100644 cmd/db_opener_tableplus.go create mode 100644 cmd/db_opener_tableplus_test.go diff --git a/README.md b/README.md index d0631a98..82d9c2e6 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Supported commands so far: | --- | --- | | `alias` | Generate WP CLI aliases for remote environments | | `check` | Checks if Trellis requirements are met | +| `db` | Commands for database management | | `deploy` | Deploys a site to the specified environment | | `dotenv` | Template .env files to local system | | `down` | Stops the Vagrant machine by running `vagrant halt`| diff --git a/cmd/db.go b/cmd/db.go new file mode 100644 index 00000000..9129901a --- /dev/null +++ b/cmd/db.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "strings" + + "github.com/mitchellh/cli" + "trellis-cli/trellis" +) + +type DBCommand struct { + UI cli.Ui + Trellis *trellis.Trellis +} + +func (c *DBCommand) Run(args []string) int { + return cli.RunResultHelp +} + +func (c *DBCommand) Synopsis() string { + return "Commands for database management" +} + +func (c *DBCommand) Help() string { + helpText := ` +Usage: trellis db [] +` + + return strings.TrimSpace(helpText) +} diff --git a/cmd/db_open.go b/cmd/db_open.go new file mode 100644 index 00000000..30d2cbc4 --- /dev/null +++ b/cmd/db_open.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "strings" + + "github.com/mitchellh/cli" + "trellis-cli/templates" + "trellis-cli/trellis" +) + +var dumpDbCredentialsYmlTemplate = ` +--- +- name: 'Trellis CLI: Dump database credentials' + hosts: web:&{{ env }} + remote_user: "{{ web_user }}" + gather_facts: false + connection: local + tasks: + - name: Dump database credentials + template: + src: db_credentials.json.j2 + dest: "{{ dest }}" + mode: '0600' + with_dict: "{{ wordpress_sites }}" + when: item.key == site +` + +var dbCredentialsJsonJ2Template = ` +{ + "web_user": "{{ web_user }}", + "ansible_host": "{{ ansible_host }}", + "ansible_port": {{ ansible_port | default(22) }}, + "db_user": "{{ site_env.db_user }}", + "db_password": "{{ site_env.db_password }}", + "db_host": "{{ site_env.db_host }}", + "db_name": "{{ site_env.db_name }}", + "wp_env": "{{ site_env.wp_env }}" +} +` + +func NewDBOpenCommand(ui cli.Ui, trellis *trellis.Trellis, dbOpenerFactory *DBOpenerFactory) *DBOpenCommand { + c := &DBOpenCommand{UI: ui, Trellis: trellis, dbOpenerFactory: dbOpenerFactory} + c.init() + return c +} + +type DBOpenCommand struct { + UI cli.Ui + flags *flag.FlagSet + app string + Trellis *trellis.Trellis + dbOpenerFactory *DBOpenerFactory +} + +type DBCredentials struct { + SSHUser string `json:"web_user"` + SSHHost string `json:"ansible_host"` + SSHPort int `json:"ansible_port"` + DBUser string `json:"db_user"` + DBPassword string `json:"db_password"` + DBHost string `json:"db_host"` + DBName string `json:"db_name"` + WPEnv string `json:"wp_env"` +} + +func (c *DBOpenCommand) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.Usage = func() { c.UI.Info(c.Help()) } + c.flags.StringVar(&c.app, "app", "", "Database client to be used; Supported: tableplus, sequel-pro") +} + +func (c *DBOpenCommand) Run(args []string) int { + if err := c.Trellis.LoadProject(); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + if flagsParseErr := c.flags.Parse(args); flagsParseErr != nil { + return 1 + } + + args = c.flags.Args() + + commandArgumentValidator := &CommandArgumentValidator{required: 1, optional: 1} + if commandArgumentErr := commandArgumentValidator.validate(args); commandArgumentErr != nil { + c.UI.Error(commandArgumentErr.Error()) + c.UI.Output(c.Help()) + return 1 + } + + environment := c.flags.Arg(0) + environmentErr := c.Trellis.ValidateEnvironment(environment) + if environmentErr != nil { + c.UI.Error(environmentErr.Error()) + return 1 + } + + siteNameArg := c.flags.Arg(1) + siteName, siteNameErr := c.Trellis.FindSiteNameFromEnvironment(environment, siteNameArg) + if siteNameErr != nil { + c.UI.Error(siteNameErr.Error()) + return 1 + } + + // Template JSON file for db credentials + dbCredentialsJson, dbCredentialsErr := ioutil.TempFile("", "*.json") + if dbCredentialsErr != nil { + c.UI.Error(fmt.Sprintf("Error createing temporary db credentials JSON file: %s", dbCredentialsErr)) + } + defer deleteFile(dbCredentialsJson.Name()) + + // Template playbook files from package to Trellis + playbookPath := "dump_db_credentials.yml" + writeFile(playbookPath, templates.TrimSpace(dumpDbCredentialsYmlTemplate)) + defer deleteFile(playbookPath) + j2TemplatePath := "db_credentials.json.j2" + writeFile(j2TemplatePath, templates.TrimSpace(dbCredentialsJsonJ2Template)) + defer deleteFile(j2TemplatePath) + + // Run the playbook to generate dbCredentialsJson + playbookCommand := execCommand("ansible-playbook", playbookPath, "-e", "env="+environment, "-e", "site="+siteName, "-e", "dest="+dbCredentialsJson.Name()) + appendEnvironmentVariable(playbookCommand, "ANSIBLE_RETRY_FILES_ENABLED=false") + logCmd(playbookCommand, c.UI, true) + playbookErr := playbookCommand.Run() + if playbookErr != nil { + c.UI.Error(fmt.Sprintf("Error running ansible-playbook: %s", playbookErr)) + return 1 + } + + // Read dbCredentialsJson + dbCredentialsByte, readErr := ioutil.ReadFile(dbCredentialsJson.Name()) + if readErr != nil { + c.UI.Error(fmt.Sprintf("Error reading db credentials JSON file: %s", readErr)) + return 1 + } + var dbCredentials DBCredentials + unmarshalErr := json.Unmarshal(dbCredentialsByte, &dbCredentials) + if unmarshalErr != nil { + c.UI.Error(fmt.Sprintf("Error unmarshaling db credentials JSON file: %s", unmarshalErr)) + return 1 + } + + // Open database with dbCredentialsJson and the app + opener, newDBOpenerErr := c.dbOpenerFactory.make(c.app, c.UI) + if newDBOpenerErr != nil { + c.UI.Error(fmt.Sprintf("Error initializing new db opener object: %s", newDBOpenerErr)) + return 1 + } + + openErr := opener.open(dbCredentials) + if openErr != nil { + c.UI.Error(fmt.Sprintf("Error opening db: %s", openErr)) + return 1 + } + + return 0 +} + +func (c *DBOpenCommand) Synopsis() string { + return "Open database with GUI applications" +} + +func (c *DBOpenCommand) Help() string { + helpText := ` +Usage: trellis deploy [options] ENVIRONMENT [SITE] + +Open database with GUI applications + +Open a site's production database with tableplus: + + $ trellis db open --app=tableplus production example.com + +Arguments: + ENVIRONMENT Name of environment (ie: production) + SITE Name of the site (ie: example.com); Optional when only single site exist in the environment + +Options: + --app Database client to be open with; Supported: tableplus, sequel-pro + -h, --help show this help +` + + return strings.TrimSpace(helpText) +} diff --git a/cmd/db_opener_factory.go b/cmd/db_opener_factory.go new file mode 100644 index 00000000..1ff9c85d --- /dev/null +++ b/cmd/db_opener_factory.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/mitchellh/cli" +) + +type DBOpenerFactory struct {} + +type DBOpener interface { + open(c DBCredentials) (err error) +} + +func (f *DBOpenerFactory) make(app string, ui cli.Ui) (o DBOpener, err error) { + switch app { + case "tableplus": + return &DBOpenerTableplus{}, nil + case "sequel-pro": + return &DBOpenerSequelPro{ui: ui}, nil + } + + return nil, fmt.Errorf("Error: %s is not supported", app) +} diff --git a/cmd/db_opener_factory_test.go b/cmd/db_opener_factory_test.go new file mode 100644 index 00000000..979ff8c9 --- /dev/null +++ b/cmd/db_opener_factory_test.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func TestMakeUnexpected(t *testing.T) { + factory := &DBOpenerFactory{} + + _, actualErr := factory.make("unexpected-app", cli.NewMockUi()) + + actualErrorMessage := actualErr.Error() + + expected := "unexpected-app is not supported" + if !strings.Contains(actualErrorMessage, expected) { + t.Errorf("expected command %s to contains %q", actualErr, expected) + } +} + +func TestMakeSequelPro(t *testing.T) { + factory := &DBOpenerFactory{} + + _, actualErr := factory.make("sequel-pro", cli.NewMockUi()) + + if actualErr != nil { + t.Errorf("expected error %s to be nil", actualErr) + } +} + +func TestMakeTableplus(t *testing.T) { + factory := &DBOpenerFactory{} + + _, actualErr := factory.make("tableplus", cli.NewMockUi()) + + if actualErr != nil { + t.Errorf("expected error %s to be nil", actualErr) + } +} diff --git a/cmd/db_opener_sequel_pro.go b/cmd/db_opener_sequel_pro.go new file mode 100644 index 00000000..89ec6cd7 --- /dev/null +++ b/cmd/db_opener_sequel_pro.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + + "io/ioutil" + "text/template" + + "github.com/mitchellh/cli" +) + +type DBOpenerSequelPro struct { + ui cli.Ui +} + +var sequelProSpfTemplate = ` + + + + + ContentFilters + + auto_connect + + data + + connection + + database + {{.DBName}} + host + {{.DBHost}} + user + {{.DBUser}} + password + {{.DBPassword}} + ssh_host + {{.SSHHost}} + ssh_port + {{.SSHPort}} + ssh_user + {{.SSHUser}} + type + SPSSHTunnelConnection + + + format + connection + queryFavorites + + queryHistory + + + +` + +func (o *DBOpenerSequelPro) open(c DBCredentials) (err error) { + sequelProSpf, sequelProSpfErr := ioutil.TempFile("", "*.spf") + if sequelProSpfErr != nil { + return fmt.Errorf("Error creating temporary SequelPro SPF file: %s", sequelProSpfErr) + } + // TODO: [Help Wanted] There is a chance that the SPF file got deleted before SequelPro establish db connection. + // But we really want to delete this file because it contains db credentials in plain text. + //defer deleteFile(sequelProSpf.Name()) + + tmpl, tmplErr := template.New("sequelProSpf").Parse(sequelProSpfTemplate) + if tmplErr != nil { + return fmt.Errorf("Error templating SequelPro SPF: %s", tmplErr) + } + + tmplExecuteErr := tmpl.Execute(sequelProSpf, c) + if tmplExecuteErr != nil { + return fmt.Errorf("Error writing SequelPro SPF: %s", tmplExecuteErr) + } + + open := execCommand("open", sequelProSpf.Name()) + logCmd(open, o.ui, true) + openErr := open.Run() + if openErr != nil { + return fmt.Errorf("Error opening database with Tableplus: %s", openErr) + } + + return nil +} diff --git a/cmd/db_opener_sequel_pro_test.go b/cmd/db_opener_sequel_pro_test.go new file mode 100644 index 00000000..7570a310 --- /dev/null +++ b/cmd/db_opener_sequel_pro_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "strings" + "testing" + + "os/exec" + + "github.com/mitchellh/cli" +) + +func TestOpen(t *testing.T) { + execCommand = mockExecCommand + defer func() { execCommand = exec.Command }() + + dbCredentials := DBCredentials{ + SSHUser: "ssh-user", + SSHHost: "ssh-host", + SSHPort: 1234, + DBUser: "db-user", + DBPassword: "db-password", + DBHost: "db-host", + DBName: "db-name", + WPEnv: "wp-env", + } + + ui := cli.NewMockUi() + sequelPro := &DBOpenerSequelPro{ + ui: ui, + } + + sequelPro.open(dbCredentials) + + actualCombined := ui.OutputWriter.String() + ui.ErrorWriter.String() + actualCombined = strings.TrimSpace(actualCombined) + + expectedPrefix := "open" + if !strings.HasPrefix(actualCombined, expectedPrefix) { + t.Errorf("expected command %q to have prefix %q", actualCombined, expectedPrefix) + } + + expectedSuffix := ".spf" + if !strings.HasSuffix(actualCombined, expectedSuffix) { + t.Errorf("expected command %q to have suffix %q", actualCombined, expectedSuffix) + } +} diff --git a/cmd/db_opener_tableplus.go b/cmd/db_opener_tableplus.go new file mode 100644 index 00000000..d936b77e --- /dev/null +++ b/cmd/db_opener_tableplus.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" +) + +type DBOpenerTableplus struct{} + +func (o *DBOpenerTableplus) open(c DBCredentials) (err error) { + uri := o.uriFor(c) + open := execCommand("open", uri) + + // Intentionally omitting `logCmd` to prevent printing db credentials. + openErr := open.Run() + if openErr != nil { + return fmt.Errorf("Error opening database with Tableplus: %s", err) + } + + return nil +} + +func (o *DBOpenerTableplus) uriFor(c DBCredentials) (string) { + return fmt.Sprintf( + "mysql+ssh://%s@%s:%d/%s:%s@%s/%s?usePrivateKey=true&enviroment=%s", + c.SSHUser, + c.SSHHost, + c.SSHPort, + c.DBUser, + c.DBPassword, + c.DBHost, + c.DBName, + c.WPEnv, + ) +} diff --git a/cmd/db_opener_tableplus_test.go b/cmd/db_opener_tableplus_test.go new file mode 100644 index 00000000..96d68837 --- /dev/null +++ b/cmd/db_opener_tableplus_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "testing" +) + +func TestUriFor(t *testing.T) { + dbCredentials := DBCredentials{ + SSHUser: "ssh-user", + SSHHost: "ssh-host", + SSHPort: 1234, + DBUser: "db-user", + DBPassword: "db-password", + DBHost: "db-host", + DBName: "db-name", + WPEnv: "wp-env", + } + + tableplus := &DBOpenerTableplus{} + + actual := tableplus.uriFor(dbCredentials) + + expected := "mysql+ssh://ssh-user@ssh-host:1234/db-user:db-password@db-host/db-name?usePrivateKey=true&enviroment=wp-env" + if actual != expected { + t.Errorf("expected uri %s to be %s", actual, expected) + } +} diff --git a/main.go b/main.go index 30ed1edd..a456a8bd 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,12 @@ func main() { "check": func() (cli.Command, error) { return &cmd.CheckCommand{UI: ui, Trellis: trellis}, nil }, + "db": func() (cli.Command, error) { + return &cmd.DBCommand{UI: ui, Trellis: trellis}, nil + }, + "db open": func() (cli.Command, error) { + return cmd.NewDBOpenCommand(ui, trellis, &cmd.DBOpenerFactory{}), nil + }, "deploy": func() (cli.Command, error) { return cmd.NewDeployCommand(ui, trellis), nil }, From 3de770b9ac661ece6eab9579c16da420c9112935 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Mon, 18 Nov 2019 01:06:53 +0000 Subject: [PATCH 2/5] Test return type --- cmd/db_opener_factory_test.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/cmd/db_opener_factory_test.go b/cmd/db_opener_factory_test.go index 979ff8c9..6b437cb7 100644 --- a/cmd/db_opener_factory_test.go +++ b/cmd/db_opener_factory_test.go @@ -3,6 +3,7 @@ package cmd import ( "strings" "testing" + "reflect" "github.com/mitchellh/cli" ) @@ -23,19 +24,33 @@ func TestMakeUnexpected(t *testing.T) { func TestMakeSequelPro(t *testing.T) { factory := &DBOpenerFactory{} - _, actualErr := factory.make("sequel-pro", cli.NewMockUi()) + actual, actualErr := factory.make("sequel-pro", cli.NewMockUi()) if actualErr != nil { t.Errorf("expected error %s to be nil", actualErr) } + + actualType := reflect.TypeOf(actual) + expectedType := reflect.TypeOf(&DBOpenerSequelPro{}) + + if actualType != expectedType { + t.Errorf("expected return type %s to be %s", actualType, expectedType) + } } func TestMakeTableplus(t *testing.T) { factory := &DBOpenerFactory{} - _, actualErr := factory.make("tableplus", cli.NewMockUi()) + actual, actualErr := factory.make("tableplus", cli.NewMockUi()) if actualErr != nil { t.Errorf("expected error %s to be nil", actualErr) } + + actualType := reflect.TypeOf(actual) + expectedType := reflect.TypeOf(&DBOpenerTableplus{}) + + if actualType != expectedType { + t.Errorf("expected return type %s to be %s", actualType, expectedType) + } } From 75fcab48d71c56b27a6ffcd984191ab320527937 Mon Sep 17 00:00:00 2001 From: TangRufus Date: Mon, 18 Nov 2019 01:11:58 +0000 Subject: [PATCH 3/5] Move constants to the top https://github.com/roots/trellis-cli/pull/72#discussion_r347168885 https://github.com/roots/trellis-cli/pull/72#discussion_r347168897 --- cmd/db_open.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/db_open.go b/cmd/db_open.go index 30d2cbc4..0a0267ff 100644 --- a/cmd/db_open.go +++ b/cmd/db_open.go @@ -12,7 +12,8 @@ import ( "trellis-cli/trellis" ) -var dumpDbCredentialsYmlTemplate = ` +const playbookPath = "dump_db_credentials.yml" +const dumpDbCredentialsYmlTemplate = ` --- - name: 'Trellis CLI: Dump database credentials' hosts: web:&{{ env }} @@ -29,7 +30,8 @@ var dumpDbCredentialsYmlTemplate = ` when: item.key == site ` -var dbCredentialsJsonJ2Template = ` +const j2TemplatePath = "db_credentials.json.j2" +const dbCredentialsJsonJ2Template = ` { "web_user": "{{ web_user }}", "ansible_host": "{{ ansible_host }}", @@ -114,10 +116,8 @@ func (c *DBOpenCommand) Run(args []string) int { defer deleteFile(dbCredentialsJson.Name()) // Template playbook files from package to Trellis - playbookPath := "dump_db_credentials.yml" writeFile(playbookPath, templates.TrimSpace(dumpDbCredentialsYmlTemplate)) defer deleteFile(playbookPath) - j2TemplatePath := "db_credentials.json.j2" writeFile(j2TemplatePath, templates.TrimSpace(dbCredentialsJsonJ2Template)) defer deleteFile(j2TemplatePath) From 94cf8f435888919cce3070dfe4ed4ab4e71ee02e Mon Sep 17 00:00:00 2001 From: TangRufus Date: Mon, 18 Nov 2019 01:13:08 +0000 Subject: [PATCH 4/5] Fix typo https://github.com/roots/trellis-cli/pull/72#discussion_r347169079 --- cmd/db_open.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/db_open.go b/cmd/db_open.go index 0a0267ff..20e4d280 100644 --- a/cmd/db_open.go +++ b/cmd/db_open.go @@ -166,7 +166,7 @@ func (c *DBOpenCommand) Synopsis() string { func (c *DBOpenCommand) Help() string { helpText := ` -Usage: trellis deploy [options] ENVIRONMENT [SITE] +Usage: trellis db [options] ENVIRONMENT [SITE] Open database with GUI applications From 99eb736f2da78c2301cbad20ce7f4a58f58bfc1e Mon Sep 17 00:00:00 2001 From: TangRufus Date: Mon, 18 Nov 2019 01:17:27 +0000 Subject: [PATCH 5/5] Use `const` instead of `var` --- cmd/db_opener_sequel_pro.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/db_opener_sequel_pro.go b/cmd/db_opener_sequel_pro.go index 89ec6cd7..4fae8dae 100644 --- a/cmd/db_opener_sequel_pro.go +++ b/cmd/db_opener_sequel_pro.go @@ -13,7 +13,7 @@ type DBOpenerSequelPro struct { ui cli.Ui } -var sequelProSpfTemplate = ` +const sequelProSpfTemplate = `