diff --git a/builtin/bins/provider-mysql/main.go b/builtin/bins/provider-mysql/main.go new file mode 100644 index 000000000..0c21be953 --- /dev/null +++ b/builtin/bins/provider-mysql/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/providers/mysql" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProviderFunc: mysql.Provider, + }) +} diff --git a/builtin/bins/provider-mysql/main_test.go b/builtin/bins/provider-mysql/main_test.go new file mode 100644 index 000000000..06ab7d0f9 --- /dev/null +++ b/builtin/bins/provider-mysql/main_test.go @@ -0,0 +1 @@ +package main diff --git a/builtin/providers/mysql/provider.go b/builtin/providers/mysql/provider.go new file mode 100644 index 000000000..3afd7db4c --- /dev/null +++ b/builtin/providers/mysql/provider.go @@ -0,0 +1,71 @@ +package mysql + +import ( + "fmt" + "strings" + + mysqlc "github.com/ziutek/mymysql/thrsafe" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + Schema: map[string]*schema.Schema{ + "endpoint": &schema.Schema{ + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("MYSQL_ENDPOINT", nil), + }, + + "username": &schema.Schema{ + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("MYSQL_USERNAME", nil), + }, + + "password": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("MYSQL_PASSWORD", nil), + }, + }, + + ResourcesMap: map[string]*schema.Resource{ + "mysql_database": resourceDatabase(), + }, + + ConfigureFunc: providerConfigure, + } +} + +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + + var username = d.Get("username").(string) + var password = d.Get("password").(string) + var endpoint = d.Get("endpoint").(string) + + proto := "tcp" + if endpoint[0] == '/' { + proto = "unix" + } + + // mysqlc is the thread-safe implementation of mymysql, so we can + // safely re-use the same connection between multiple parallel + // operations. + conn := mysqlc.New(proto, "", endpoint, username, password) + + err := conn.Connect() + if err != nil { + return nil, err + } + + return conn, nil +} + +var identQuoteReplacer = strings.NewReplacer("`", "``") + +func quoteIdentifier(in string) string { + return fmt.Sprintf("`%s`", identQuoteReplacer.Replace(in)) +} diff --git a/builtin/providers/mysql/provider_test.go b/builtin/providers/mysql/provider_test.go new file mode 100644 index 000000000..824e2b2be --- /dev/null +++ b/builtin/providers/mysql/provider_test.go @@ -0,0 +1,55 @@ +package mysql + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +// To run these acceptance tests, you will need access to a MySQL server. +// Amazon RDS is one way to get a MySQL server. If you use RDS, you can +// use the root account credentials you specified when creating an RDS +// instance to get the access necessary to run these tests. (the tests +// assume full access to the server.) +// +// Set the MYSQL_ENDPOINT and MYSQL_USERNAME environment variables before +// running the tests. If the given user has a password then you will also need +// to set MYSQL_PASSWORD. +// +// The tests assume a reasonably-vanilla MySQL configuration. In particular, +// they assume that the "utf8" character set is available and that +// "utf8_bin" is a valid collation that isn't the default for that character +// set. +// +// You can run the tests like this: +// make testacc TEST=./builtin/providers/mysql + +var testAccProviders map[string]terraform.ResourceProvider +var testAccProvider *schema.Provider + +func init() { + testAccProvider = Provider().(*schema.Provider) + testAccProviders = map[string]terraform.ResourceProvider{ + "mysql": testAccProvider, + } +} + +func TestProvider(t *testing.T) { + if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvider_impl(t *testing.T) { + var _ terraform.ResourceProvider = Provider() +} + +func testAccPreCheck(t *testing.T) { + for _, name := range []string{"MYSQL_ENDPOINT", "MYSQL_USERNAME"} { + if v := os.Getenv(name); v == "" { + t.Fatal("MYSQL_ENDPOINT, MYSQL_USERNAME and optionally MYSQL_PASSWORD must be set for acceptance tests") + } + } +} diff --git a/builtin/providers/mysql/resource_database.go b/builtin/providers/mysql/resource_database.go new file mode 100644 index 000000000..4aa56e810 --- /dev/null +++ b/builtin/providers/mysql/resource_database.go @@ -0,0 +1,174 @@ +package mysql + +import ( + "fmt" + "log" + "strings" + + mysqlc "github.com/ziutek/mymysql/mysql" + + "github.com/hashicorp/terraform/helper/schema" +) + +const defaultCharacterSetKeyword = "CHARACTER SET " +const defaultCollateKeyword = "COLLATE " + +func resourceDatabase() *schema.Resource { + return &schema.Resource{ + Create: CreateDatabase, + Update: UpdateDatabase, + Read: ReadDatabase, + Delete: DeleteDatabase, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "default_character_set": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "utf8", + }, + + "default_collation": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "utf8_general_ci", + }, + }, + } +} + +func CreateDatabase(d *schema.ResourceData, meta interface{}) error { + conn := meta.(mysqlc.Conn) + + stmtSQL := databaseConfigSQL("CREATE", d) + log.Println("Executing statement:", stmtSQL) + + _, _, err := conn.Query(stmtSQL) + if err != nil { + return err + } + + d.SetId(d.Get("name").(string)) + + return nil +} + +func UpdateDatabase(d *schema.ResourceData, meta interface{}) error { + conn := meta.(mysqlc.Conn) + + stmtSQL := databaseConfigSQL("ALTER", d) + log.Println("Executing statement:", stmtSQL) + + _, _, err := conn.Query(stmtSQL) + if err != nil { + return err + } + + return nil +} + +func ReadDatabase(d *schema.ResourceData, meta interface{}) error { + conn := meta.(mysqlc.Conn) + + // This is kinda flimsy-feeling, since it depends on the formatting + // of the SHOW CREATE DATABASE output... but this data doesn't seem + // to be available any other way, so hopefully MySQL keeps this + // compatible in future releases. + + name := d.Id() + stmtSQL := "SHOW CREATE DATABASE " + quoteIdentifier(name) + + log.Println("Executing query:", stmtSQL) + rows, _, err := conn.Query(stmtSQL) + if err != nil { + if mysqlErr, ok := err.(*mysqlc.Error); ok { + if mysqlErr.Code == mysqlc.ER_BAD_DB_ERROR { + d.SetId("") + return nil + } + } + return err + } + + row := rows[0] + createSQL := string(row[1].([]byte)) + + defaultCharset := extractIdentAfter(createSQL, defaultCharacterSetKeyword) + defaultCollation := extractIdentAfter(createSQL, defaultCollateKeyword) + + if defaultCollation == "" && defaultCharset != "" { + // MySQL doesn't return the collation if it's the default one for + // the charset, so if we don't have a collation we need to go + // hunt for the default. + stmtSQL := "SHOW COLLATION WHERE `Charset` = '%s' AND `Default` = 'Yes'" + rows, _, err := conn.Query(stmtSQL, defaultCharset) + if err != nil { + return fmt.Errorf("Error getting default charset: %s", err) + } + if len(rows) == 0 { + return fmt.Errorf("Charset %s has no default collation", defaultCharset) + } + row := rows[0] + defaultCollation = string(row[0].([]byte)) + } + + d.Set("default_character_set", defaultCharset) + d.Set("default_collation", defaultCollation) + + return nil +} + +func DeleteDatabase(d *schema.ResourceData, meta interface{}) error { + conn := meta.(mysqlc.Conn) + + name := d.Id() + stmtSQL := "DROP DATABASE " + quoteIdentifier(name) + log.Println("Executing statement:", stmtSQL) + + _, _, err := conn.Query(stmtSQL) + if err == nil { + d.SetId("") + } + return err +} + +func databaseConfigSQL(verb string, d *schema.ResourceData) string { + name := d.Get("name").(string) + defaultCharset := d.Get("default_character_set").(string) + defaultCollation := d.Get("default_collation").(string) + + var defaultCharsetClause string + var defaultCollationClause string + + if defaultCharset != "" { + defaultCharsetClause = defaultCharacterSetKeyword + quoteIdentifier(defaultCharset) + } + if defaultCollation != "" { + defaultCollationClause = defaultCollateKeyword + quoteIdentifier(defaultCollation) + } + + return fmt.Sprintf( + "%s DATABASE %s %s %s", + verb, + quoteIdentifier(name), + defaultCharsetClause, + defaultCollationClause, + ) +} + +func extractIdentAfter(sql string, keyword string) string { + charsetIndex := strings.Index(sql, keyword) + if charsetIndex != -1 { + charsetIndex += len(keyword) + remain := sql[charsetIndex:] + spaceIndex := strings.IndexRune(remain, ' ') + return remain[:spaceIndex] + } + + return "" +} diff --git a/builtin/providers/mysql/resource_database_test.go b/builtin/providers/mysql/resource_database_test.go new file mode 100644 index 000000000..49c44256f --- /dev/null +++ b/builtin/providers/mysql/resource_database_test.go @@ -0,0 +1,91 @@ +package mysql + +import ( + "fmt" + "strings" + "testing" + + mysqlc "github.com/ziutek/mymysql/mysql" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccDatabase(t *testing.T) { + var dbName string + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccDatabaseCheckDestroy(dbName), + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccDatabaseConfig_basic, + Check: testAccDatabaseCheck( + "mysql_database.test", &dbName, + ), + }, + }, + }) +} + +func testAccDatabaseCheck(rn string, name *string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s", rn) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("database id not set") + } + + conn := testAccProvider.Meta().(mysqlc.Conn) + rows, _, err := conn.Query("SHOW CREATE DATABASE terraform_acceptance_test") + if err != nil { + return fmt.Errorf("error reading database: %s", err) + } + if len(rows) != 1 { + return fmt.Errorf("expected 1 row reading database but got %d", len(rows)) + } + + row := rows[0] + createSQL := string(row[1].([]byte)) + + if strings.Index(createSQL, "CHARACTER SET utf8") == -1 { + return fmt.Errorf("database default charset isn't utf8") + } + if strings.Index(createSQL, "COLLATE utf8_bin") == -1 { + return fmt.Errorf("database default collation isn't utf8_bin") + } + + *name = rs.Primary.ID + + return nil + } +} + +func testAccDatabaseCheckDestroy(name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := testAccProvider.Meta().(mysqlc.Conn) + + _, _, err := conn.Query("SHOW CREATE DATABASE terraform_acceptance_test") + if err == nil { + return fmt.Errorf("database still exists after destroy") + } + if mysqlErr, ok := err.(*mysqlc.Error); ok { + if mysqlErr.Code == mysqlc.ER_BAD_DB_ERROR { + return nil + } + } + + return fmt.Errorf("got unexpected error: %s", err) + } +} + +const testAccDatabaseConfig_basic = ` +resource "mysql_database" "test" { + name = "terraform_acceptance_test" + default_character_set = "utf8" + default_collation = "utf8_bin" +} +` diff --git a/website/source/assets/stylesheets/_docs.scss b/website/source/assets/stylesheets/_docs.scss index 49e9e164f..0143966b4 100755 --- a/website/source/assets/stylesheets/_docs.scss +++ b/website/source/assets/stylesheets/_docs.scss @@ -22,6 +22,7 @@ body.layout-dyn, body.layout-google, body.layout-heroku, body.layout-mailgun, +body.layout-mysql, body.layout-openstack, body.layout-packet, body.layout-postgresql, diff --git a/website/source/docs/providers/mysql/index.html.markdown b/website/source/docs/providers/mysql/index.html.markdown new file mode 100644 index 000000000..555c589e2 --- /dev/null +++ b/website/source/docs/providers/mysql/index.html.markdown @@ -0,0 +1,72 @@ +--- +layout: "mysql" +page_title: "Provider: MySQL" +sidebar_current: "docs-mysql-index" +description: |- + A provider for MySQL Server. +--- + +# MySQL Provider + +[MySQL](http://www.mysql.com) is a relational database server. The MySQL +provider exposes resources used to manage the configuration of resources +in a MySQL server. + +Use the navigation to the left to read about the available resources. + +## Example Usage + +The following is a minimal example: + +``` +# Configure the MySQL provider +provider "mysql" { + endpoint = "my-database.example.com:3306" + username = "app-user" + password = "app-password" +} + +# Create a Database +resource "mysql_database" "app" { + name = "my_awesome_app" +} +``` + +This provider can be used in conjunction with other resources that create +MySQL servers. For example, ``aws_db_instance`` is able to create MySQL +servers in Amazon's RDS service. + +``` +# Create a database server +resource "aws_db_instance" "default" { + engine = "mysql" + engine_version = "5.6.17" + instance_class = "db.t1.micro" + name = "initial_db" + username = "rootuser" + password = "rootpasswd" + # etc, etc; see aws_db_instance docs for more +} + +# Configure the MySQL provider based on the outcome of +# creating the aws_db_instance. +provider "mysql" { + endpoint = "${aws_db_instance.default.endpoint}" + username = "${aws_db_instance.default.username}" + password = "${aws_db_instance.default.password}" +} + +# Create a second database, in addition to the "initial_db" created +# by the aws_db_instance resource above. +resource "mysql_database" "app" { + name = "another_db" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `endpoint` - (Required) The address of the MySQL server to use. Most often a "hostname:port" pair, but may also be an absolute path to a Unix socket when the host OS is Unix-compatible. +* `username` - (Required) Username to use to authenticate with the server. +* `password` - (Optional) Password for the given user, if that user has a password. diff --git a/website/source/docs/providers/mysql/r/database.html.markdown b/website/source/docs/providers/mysql/r/database.html.markdown new file mode 100644 index 000000000..36459ab9e --- /dev/null +++ b/website/source/docs/providers/mysql/r/database.html.markdown @@ -0,0 +1,54 @@ +--- +layout: "mysql" +page_title: "MySQL: mysql_database" +sidebar_current: "docs-mysql-resource-database" +description: |- + Creates and manages a database on a MySQL server. +--- + +# mysql\_database + +The ``mysql_database`` resource creates and manages a database on a MySQL +server. + +~> **Caution:** The ``mysql_database`` resource can completely delete your +database just as easily as it can create it. To avoid costly accidents, +consider setting +[``prevent_destroy``](/docs/configuration/resources.html#prevent_destroy) +on your database resources as an extra safety measure. + +## Example Usage + +``` +resource "mysql_database" "app" { + name = "my_awesome_app" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the database. This must be unique within + a given MySQL server and may or may not be case-sensitive depending on + the operating system on which the MySQL server is running. + +* `default_character_set` - (Optional) The default character set to use when + a table is created without specifying an explicit character set. Defaults + to "utf8". + +* `default_collation` - (Optional) The default collation to use when a table + is created without specifying an explicit collation. Defaults to + ``utf8_general_ci``. Each character set has its own set of collations, so + changing the character set requires also changing the collation. + +Note that the defaults for character set and collation above do not respect +any defaults set on the MySQL server, so that the configuration can be set +appropriately even though Terraform cannot see the server-level defaults. If +you wish to use the server's defaults you must consult the server's +configuration and then set the ``default_character_set`` and +``default_collation`` to match. + +## Attributes Reference + +No further attributes are exported. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index e61f01b87..73c8aad08 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -185,6 +185,10 @@ Mailgun + > + MySQL + + > OpenStack diff --git a/website/source/layouts/mysql.erb b/website/source/layouts/mysql.erb new file mode 100644 index 000000000..ac2107197 --- /dev/null +++ b/website/source/layouts/mysql.erb @@ -0,0 +1,26 @@ +<% wrap_layout :inner do %> + <% content_for :sidebar do %> + + <% end %> + + <%= yield %> + <% end %>