Skip to content

Commit

Permalink
Add db open command (#72)
Browse files Browse the repository at this point in the history
Add `db open` command
  • Loading branch information
tangrufus authored Nov 26, 2019
2 parents b83f85e + 99eb736 commit b1afd5e
Show file tree
Hide file tree
Showing 10 changed files with 494 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`|
Expand Down
29 changes: 29 additions & 0 deletions cmd/db.go
Original file line number Diff line number Diff line change
@@ -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 <subcommand> [<args>]
`

return strings.TrimSpace(helpText)
}
187 changes: 187 additions & 0 deletions cmd/db_open.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package cmd

import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"strings"

"github.com/mitchellh/cli"
"trellis-cli/templates"
"trellis-cli/trellis"
)

const playbookPath = "dump_db_credentials.yml"
const 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
`

const j2TemplatePath = "db_credentials.json.j2"
const 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
writeFile(playbookPath, templates.TrimSpace(dumpDbCredentialsYmlTemplate))
defer deleteFile(playbookPath)
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 db [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)
}
24 changes: 24 additions & 0 deletions cmd/db_opener_factory.go
Original file line number Diff line number Diff line change
@@ -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)
}
56 changes: 56 additions & 0 deletions cmd/db_opener_factory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"strings"
"testing"
"reflect"

"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{}

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{}

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)
}
}
84 changes: 84 additions & 0 deletions cmd/db_opener_sequel_pro.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package cmd

import (
"fmt"

"io/ioutil"
"text/template"

"github.com/mitchellh/cli"
)

type DBOpenerSequelPro struct {
ui cli.Ui
}

const sequelProSpfTemplate = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ContentFilters</key>
<dict/>
<key>auto_connect</key>
<true/>
<key>data</key>
<dict>
<key>connection</key>
<dict>
<key>database</key>
<string>{{.DBName}}</string>
<key>host</key>
<string>{{.DBHost}}</string>
<key>user</key>
<string>{{.DBUser}}</string>
<key>password</key>
<string>{{.DBPassword}}</string>
<key>ssh_host</key>
<string>{{.SSHHost}}</string>
<key>ssh_port</key>
<string>{{.SSHPort}}</string>
<key>ssh_user</key>
<string>{{.SSHUser}}</string>
<key>type</key>
<string>SPSSHTunnelConnection</string>
</dict>
</dict>
<key>format</key>
<string>connection</string>
<key>queryFavorites</key>
<array/>
<key>queryHistory</key>
<array/>
</dict>
</plist>
`

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
}
Loading

0 comments on commit b1afd5e

Please sign in to comment.