From 26ec0cf6293457f9ad5cfb2bb2514460886e207f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Paiva?= Date: Sun, 20 Nov 2022 23:05:46 +0100 Subject: [PATCH] Add server and user_mapping resources (#220) * feat: Postgres foreign server * feat: Add user mapping resource * fix: wrong dependency * fix: wrong mapping * chore: document postgresql_server resource * chore: document postgresql_user_mapping resource * chore: replace wrong title * fix: ignore RDS tests * fix: documentation example using wrong resource * refactor: PR review changes request Co-authored-by: Cyril Gaudin --- postgresql/config.go | 3 + postgresql/provider.go | 2 + postgresql/resource_postgresql_server.go | 359 +++++++++++++ postgresql/resource_postgresql_server_test.go | 497 ++++++++++++++++++ .../resource_postgresql_user_mapping.go | 237 +++++++++ .../resource_postgresql_user_mapping_test.go | 252 +++++++++ .../docs/r/postgresql_server.html.markdown | 60 +++ .../r/postgresql_user_mapping.html.markdown | 57 ++ website/postgresql.erb | 6 + 9 files changed, 1473 insertions(+) create mode 100644 postgresql/resource_postgresql_server.go create mode 100644 postgresql/resource_postgresql_server_test.go create mode 100644 postgresql/resource_postgresql_user_mapping.go create mode 100644 postgresql/resource_postgresql_user_mapping_test.go create mode 100644 website/docs/r/postgresql_server.html.markdown create mode 100644 website/docs/r/postgresql_user_mapping.html.markdown diff --git a/postgresql/config.go b/postgresql/config.go index 2a4f85fa..8fcc9791 100644 --- a/postgresql/config.go +++ b/postgresql/config.go @@ -39,6 +39,7 @@ const ( featurePublication featurePubWithoutTruncate featureFunction + featureServer ) var ( @@ -105,6 +106,8 @@ var ( featurePublication: semver.MustParseRange(">=10.0.0"), // We do not support CREATE FUNCTION for Postgresql < 8.4 featureFunction: semver.MustParseRange(">=8.4.0"), + // CREATE SERVER support + featureServer: semver.MustParseRange(">=10.0.0"), } ) diff --git a/postgresql/provider.go b/postgresql/provider.go index 58c7bf53..dd3825e6 100644 --- a/postgresql/provider.go +++ b/postgresql/provider.go @@ -175,6 +175,8 @@ func Provider() *schema.Provider { "postgresql_schema": resourcePostgreSQLSchema(), "postgresql_role": resourcePostgreSQLRole(), "postgresql_function": resourcePostgreSQLFunction(), + "postgresql_server": resourcePostgreSQLServer(), + "postgresql_user_mapping": resourcePostgreSQLUserMapping(), }, ConfigureFunc: providerConfigure, diff --git a/postgresql/resource_postgresql_server.go b/postgresql/resource_postgresql_server.go new file mode 100644 index 00000000..e1c8e300 --- /dev/null +++ b/postgresql/resource_postgresql_server.go @@ -0,0 +1,359 @@ +package postgresql + +import ( + "bytes" + "database/sql" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/lib/pq" +) + +const ( + serverNameAttr = "server_name" + serverTypeAttr = "server_type" + serverVersionAttr = "server_version" + serverOwnerAttr = "server_owner" + serverFDWAttr = "fdw_name" + serverOptionsAttr = "options" + serverDropCascadeAttr = "drop_cascade" +) + +func resourcePostgreSQLServer() *schema.Resource { + return &schema.Resource{ + Create: PGResourceFunc(resourcePostgreSQLServerCreate), + Read: PGResourceFunc(resourcePostgreSQLServerRead), + Update: PGResourceFunc(resourcePostgreSQLServerUpdate), + Delete: PGResourceFunc(resourcePostgreSQLServerDelete), + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + serverNameAttr: { + Type: schema.TypeString, + Required: true, + Description: "The name of the foreign server to be created", + }, + serverTypeAttr: { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "Optional server type, potentially useful to foreign-data wrappers", + }, + serverVersionAttr: { + Type: schema.TypeString, + Optional: true, + Description: "Optional server version, potentially useful to foreign-data wrappers.", + }, + serverFDWAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the foreign-data wrapper that manages the server", + }, + serverOwnerAttr: { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The user name of the new owner of the foreign server", + }, + serverOptionsAttr: { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + Description: "This clause specifies the options for the server. The options typically define the connection details of the server, but the actual names and values are dependent on the server's foreign-data wrapper", + }, + serverDropCascadeAttr: { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Automatically drop objects that depend on the server (such as user mappings), and in turn all objects that depend on those objects. Drop RESTRICT is the default", + }, + }, + } +} + +func resourcePostgreSQLServerCreate(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + serverName := d.Get(serverNameAttr).(string) + + b := bytes.NewBufferString("CREATE SERVER ") + fmt.Fprint(b, pq.QuoteIdentifier(serverName)) + + if v, ok := d.GetOk(serverTypeAttr); ok { + fmt.Fprint(b, " TYPE ", pq.QuoteLiteral(v.(string))) + } + + if v, ok := d.GetOk(serverVersionAttr); ok { + fmt.Fprint(b, " VERSION ", pq.QuoteLiteral(v.(string))) + } + + fmt.Fprint(b, " FOREIGN DATA WRAPPER ", pq.QuoteIdentifier(d.Get(serverFDWAttr).(string))) + + if options, ok := d.GetOk(serverOptionsAttr); ok { + fmt.Fprint(b, " OPTIONS ( ") + cnt := 0 + len := len(options.(map[string]interface{})) + for k, v := range options.(map[string]interface{}) { + fmt.Fprint(b, " ", pq.QuoteIdentifier(k), " ", pq.QuoteLiteral(v.(string))) + if cnt < len-1 { + fmt.Fprint(b, ", ") + } + cnt++ + } + fmt.Fprint(b, " ) ") + } + + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + sql := b.String() + if _, err := txn.Exec(sql); err != nil { + return err + } + + if v, ok := d.GetOk(serverOwnerAttr); ok { + currentUser, err := getCurrentUser(txn) + if err != nil { + return err + } + if v != currentUser { + if err := setServerOwner(txn, d); err != nil { + return err + } + } + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("Error creating server: %w", err) + } + + d.SetId(d.Get(serverNameAttr).(string)) + + return resourcePostgreSQLServerReadImpl(db, d) +} + +func resourcePostgreSQLServerRead(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + return resourcePostgreSQLServerReadImpl(db, d) +} + +func resourcePostgreSQLServerReadImpl(db *DBConnection, d *schema.ResourceData) error { + serverName := d.Get(serverNameAttr).(string) + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + var serverType, serverVersion, serverOwner, serverFDW string + var serverOptions []string + query := `SELECT COALESCE(fs.srvtype, ''), COALESCE(fs.srvversion, ''), fs.srvowner::regrole, fs.srvoptions, w.fdwname ` + + `FROM pg_foreign_server fs JOIN pg_foreign_data_wrapper w on w.oid = fs.srvfdw ` + + `WHERE fs.srvname = $1` + err = txn.QueryRow(query, serverName).Scan(&serverType, &serverVersion, &serverOwner, pq.Array(&serverOptions), &serverFDW) + switch { + case err == sql.ErrNoRows: + log.Printf("[WARN] PostgreSQL foreign server (%s) not found", serverName) + d.SetId("") + return nil + case err != nil: + return fmt.Errorf("Error reading foreign server: %w", err) + } + + mappedOptions := make(map[string]interface{}) + for _, v := range serverOptions { + pair := strings.Split(v, "=") + mappedOptions[pair[0]] = pair[1] + } + + d.Set(serverNameAttr, serverName) + d.Set(serverTypeAttr, serverType) + d.Set(serverVersionAttr, serverVersion) + d.Set(serverOwnerAttr, serverOwner) + d.Set(serverOptionsAttr, mappedOptions) + d.Set(serverFDWAttr, serverFDW) + d.SetId(serverName) + + return nil +} + +func resourcePostgreSQLServerDelete(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + serverName := d.Get(serverNameAttr).(string) + + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + dropMode := "RESTRICT" + if d.Get(serverDropCascadeAttr).(bool) { + dropMode = "CASCADE" + } + + sql := fmt.Sprintf("DROP SERVER %s %s ", pq.QuoteIdentifier(serverName), dropMode) + if _, err := txn.Exec(sql); err != nil { + return err + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("Error deleting server: %w", err) + } + + d.SetId("") + + return nil +} + +func resourcePostgreSQLServerUpdate(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + if err := setServerNameIfChanged(txn, d); err != nil { + return err + } + + if err := setServerOwnerIfChanged(txn, d); err != nil { + return err + } + + if err := setServerVersionOptionsIfChanged(txn, d); err != nil { + return err + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("Error updating foreign server: %w", err) + } + + return resourcePostgreSQLServerReadImpl(db, d) +} + +func setServerVersionOptionsIfChanged(txn *sql.Tx, d *schema.ResourceData) error { + if !d.HasChange(serverVersionAttr) && !d.HasChange(serverOptionsAttr) { + return nil + } + + b := bytes.NewBufferString("ALTER SERVER ") + serverName := d.Get(serverNameAttr).(string) + + fmt.Fprintf(b, "%s ", pq.QuoteIdentifier(serverName)) + + if d.HasChange(serverVersionAttr) { + fmt.Fprintf(b, "VERSION %s", pq.QuoteLiteral(d.Get(serverVersionAttr).(string))) + } + + if d.HasChange(serverOptionsAttr) { + oldOptions, newOptions := d.GetChange(serverOptionsAttr) + fmt.Fprint(b, " OPTIONS ( ") + cnt := 0 + len := len(newOptions.(map[string]interface{})) + toRemove := oldOptions.(map[string]interface{}) + for k, v := range newOptions.(map[string]interface{}) { + operation := "ADD" + if oldOptions.(map[string]interface{})[k] != nil { + operation = "SET" + delete(toRemove, k) + } + fmt.Fprintf(b, " %s %s %s ", operation, pq.QuoteIdentifier(k), pq.QuoteLiteral(v.(string))) + if cnt < len-1 { + fmt.Fprint(b, ", ") + } + cnt++ + } + + for k := range toRemove { + if cnt != 0 { // starting with 0 means to drop all the options. Cannot start with comma + fmt.Fprint(b, " , ") + } + fmt.Fprintf(b, " DROP %s ", pq.QuoteIdentifier(k)) + cnt++ + } + + fmt.Fprint(b, " ) ") + } + + sql := b.String() + if _, err := txn.Exec(sql); err != nil { + return fmt.Errorf("Error updating foreign server version and/or options: %w", err) + } + + return nil +} + +func setServerNameIfChanged(txn *sql.Tx, d *schema.ResourceData) error { + if !d.HasChange(serverNameAttr) { + return nil + } + + serverOldName, serverNewName := d.GetChange(serverNameAttr) + + b := bytes.NewBufferString("ALTER SERVER ") + fmt.Fprintf(b, "%s RENAME TO %s", pq.QuoteIdentifier(serverOldName.(string)), pq.QuoteIdentifier(serverNewName.(string))) + + sql := b.String() + if _, err := txn.Exec(sql); err != nil { + return fmt.Errorf("Error updating foreign server name: %w", err) + } + + return nil +} + +func setServerOwnerIfChanged(txn *sql.Tx, d *schema.ResourceData) error { + if !d.HasChange(serverOwnerAttr) { + return nil + } + return setServerOwner(txn, d) +} + +func setServerOwner(txn *sql.Tx, d *schema.ResourceData) error { + serverName := d.Get(serverNameAttr).(string) + serverNewOwner := d.Get(serverOwnerAttr).(string) + + b := bytes.NewBufferString("ALTER SERVER ") + fmt.Fprintf(b, "%s OWNER TO %s", pq.QuoteIdentifier(serverName), pq.QuoteIdentifier(serverNewOwner)) + + sql := b.String() + if _, err := txn.Exec(sql); err != nil { + return fmt.Errorf("Error updating foreign server owner: %w", err) + } + + return nil +} diff --git a/postgresql/resource_postgresql_server_test.go b/postgresql/resource_postgresql_server_test.go new file mode 100644 index 00000000..d45069f5 --- /dev/null +++ b/postgresql/resource_postgresql_server_test.go @@ -0,0 +1,497 @@ +package postgresql + +import ( + "database/sql" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccPostgresqlServer_Basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featureServer) + testSuperuserPreCheck(t) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPostgresqlServerDestroy, + Steps: []resource.TestStep{ + { + Config: testAccPostgresqlServerConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + testAccCheckPostgresqlServerExists("postgresql_server.myserver_file"), + testAccCheckPostgresqlServerExists("postgresql_server.myserver_with_owner"), + testAccCheckPostgresqlServerExists("postgresql_server.myserver_with_version"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "server_name", "myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "server_owner", "postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "fdw_name", "postgres_fdw"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.host", "foo"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.dbname", "foodb"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.port", "5432"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_file", "server_name", "myserver_file"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_file", "server_owner", "postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_file", "fdw_name", "file_fdw"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_with_owner", "server_owner", "owner"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_with_type", "server_type", "slave"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_with_version", "server_version", "1.1.1"), + ), + }, + }, + }) +} + +func testAccCheckPostgresqlServerDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*Client) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "postgresql_server" { + continue + } + + txn, err := startTransaction(client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + exists, err := checkServerExists(txn, rs.Primary.ID) + + if err != nil { + return fmt.Errorf("Error checking foreign server %s", err) + } + + if exists { + return fmt.Errorf("Foreign Server still exists after destroy") + } + } + + return nil +} + +func testAccCheckPostgresqlServerExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Resource not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + serverName, ok := rs.Primary.Attributes[serverNameAttr] + if !ok { + return fmt.Errorf("No Attribute for server name is set") + } + + client := testAccProvider.Meta().(*Client) + txn, err := startTransaction(client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + exists, err := checkServerExists(txn, serverName) + + if err != nil { + return fmt.Errorf("Error checking foreign server %s", err) + } + + if !exists { + return fmt.Errorf("Foreign server not found") + } + + return nil + } +} + +func TestAccPostgresqlServer_Update(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featureServer) + testSuperuserPreCheck(t) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPostgresqlServerDestroy, + Steps: []resource.TestStep{ + { + Config: testAccPostgresqlServerChanges1, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "server_name", "myserver_postgres"), + ), + }, + { + Config: testAccPostgresqlServerChanges2, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "server_name", "myserver_postgres_updated"), + ), + }, + { + Config: testAccPostgresqlServerChanges3, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "server_type", "custom"), + ), + }, + { + Config: testAccPostgresqlServerChanges4, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "server_version", "1.2.3"), + ), + }, + { + Config: testAccPostgresqlServerChanges5, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.host", "local"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.dbname", "mydb"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.port", "25432"), + ), + }, + { + Config: testAccPostgresqlServerChanges6, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.sslmode", "require"), + ), + }, + { + Config: testAccPostgresqlServerChanges7, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + ), + }, + { + Config: testAccPostgresqlServerChanges8, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_server.myserver_postgres", "options.%", "0"), + ), + }, + }, + }) +} + +func checkServerExists(txn *sql.Tx, serverName string) (bool, error) { + var _rez bool + err := txn.QueryRow("SELECT TRUE FROM pg_foreign_server WHERE srvname=$1", serverName).Scan(&_rez) + switch { + case err == sql.ErrNoRows: + return false, nil + case err != nil: + return false, fmt.Errorf("Error reading info about foreign server: %s", err) + } + + return true, nil +} + +func TestAccPostgresqlServer_DropCascade(t *testing.T) { + skipIfNotAcc(t) + testSuperuserPreCheck(t) + + var testAccPostgresqlServerConfig = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "cascade" { + server_name = "myserver" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + drop_cascade = true + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featureServer) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPostgresqlServerDestroy, + Steps: []resource.TestStep{ + { + Config: testAccPostgresqlServerConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_server.cascade"), + resource.TestCheckResourceAttr("postgresql_server.cascade", "server_name", "myserver"), + // This will create a dependency on the server. + testAccCreateServerDependency("myserver"), + ), + }, + }, + }) +} + +func testAccCreateServerDependency(serverName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + + client := testAccProvider.Meta().(*Client) + db, err := client.Connect() + if err != nil { + return err + } + currentUser, err := getCurrentUser(db) + if err != nil { + return err + } + _, err = db.Exec(fmt.Sprintf("CREATE USER MAPPING FOR %s SERVER %s OPTIONS (user 'admin', password 'admin');", currentUser, serverName)) + if err != nil { + return fmt.Errorf("could not create user mapping: %s", err) + } + + return nil + } +} + +var testAccPostgresqlServerConfig = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_extension" "ext_file_fdw" { + name = "file_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} + + +resource "postgresql_server" "myserver_file" { + server_name = "myserver_file" + fdw_name = "file_fdw" + depends_on = [postgresql_extension.ext_file_fdw] +} + +resource "postgresql_role" "owner" { + name = "owner" +} + +resource "postgresql_server" "myserver_with_owner" { + server_name = "with_owner" + server_owner = postgresql_role.owner.name + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} + +resource "postgresql_server" "myserver_with_type" { + server_name = "myserver_with_type" + server_type = "slave" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} + + +resource "postgresql_server" "myserver_with_version" { + server_name = "myserver_with_version" + server_version = "1.1.1" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + +depends_on = [postgresql_extension.ext_postgres_fdw] +} + +` + +var testAccPostgresqlServerChanges1 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + +var testAccPostgresqlServerChanges2 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres_updated" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + +var testAccPostgresqlServerChanges3 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres_updated" + server_type = "custom" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + +var testAccPostgresqlServerChanges4 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres_updated" + server_version = "1.2.3" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + +var testAccPostgresqlServerChanges5 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres_updated" + server_version = "1.2.3" + fdw_name = "postgres_fdw" + options = { + host = "local" + dbname = "mydb" + port = "25432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + +var testAccPostgresqlServerChanges6 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres_updated" + server_version = "1.2.3" + fdw_name = "postgres_fdw" + options = { + host = "local" + dbname = "mydb" + port = "25432" + sslmode = "require" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + +var testAccPostgresqlServerChanges7 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres_updated" + server_version = "1.2.3" + fdw_name = "postgres_fdw" + options = { + host = "local" + dbname = "mydb" + port = "25432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` + +var testAccPostgresqlServerChanges8 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres_updated" + server_version = "1.2.3" + fdw_name = "postgres_fdw" + depends_on = [postgresql_extension.ext_postgres_fdw] +} +` diff --git a/postgresql/resource_postgresql_user_mapping.go b/postgresql/resource_postgresql_user_mapping.go new file mode 100644 index 00000000..de4f6a89 --- /dev/null +++ b/postgresql/resource_postgresql_user_mapping.go @@ -0,0 +1,237 @@ +package postgresql + +import ( + "bytes" + "database/sql" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/lib/pq" +) + +const ( + userMappingUserNameAttr = "user_name" + userMappingServerNameAttr = "server_name" + userMappingOptionsAttr = "options" +) + +func resourcePostgreSQLUserMapping() *schema.Resource { + return &schema.Resource{ + Create: PGResourceFunc(resourcePostgreSQLUserMappingCreate), + Read: PGResourceFunc(resourcePostgreSQLUserMappingRead), + Update: PGResourceFunc(resourcePostgreSQLUserMappingUpdate), + Delete: PGResourceFunc(resourcePostgreSQLUserMappingDelete), + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + userMappingUserNameAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of an existing user that is mapped to foreign server. CURRENT_ROLE, CURRENT_USER, and USER match the name of the current user. When PUBLIC is specified, a so-called public mapping is created that is used when no user-specific mapping is applicable", + }, + userMappingServerNameAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of an existing server for which the user mapping is to be created", + }, + userMappingOptionsAttr: { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + Description: "This clause specifies the options of the user mapping. The options typically define the actual user name and password of the mapping. Option names must be unique. The allowed option names and values are specific to the server's foreign-data wrapper", + }, + }, + } +} + +func resourcePostgreSQLUserMappingCreate(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + username := d.Get(userMappingUserNameAttr).(string) + serverName := d.Get(userMappingServerNameAttr).(string) + + b := bytes.NewBufferString("CREATE USER MAPPING ") + fmt.Fprint(b, " FOR ", pq.QuoteIdentifier(username)) + fmt.Fprint(b, " SERVER ", pq.QuoteIdentifier(serverName)) + + if options, ok := d.GetOk(userMappingOptionsAttr); ok { + fmt.Fprint(b, " OPTIONS ( ") + cnt := 0 + len := len(options.(map[string]interface{})) + for k, v := range options.(map[string]interface{}) { + fmt.Fprint(b, " ", pq.QuoteIdentifier(k), " ", pq.QuoteLiteral(v.(string))) + if cnt < len-1 { + fmt.Fprint(b, ", ") + } + cnt++ + } + fmt.Fprint(b, " ) ") + } + + if _, err := db.Exec(b.String()); err != nil { + return fmt.Errorf("Could not create user mapping: %w", err) + } + + d.SetId(generateUserMappingID(d)) + + return resourcePostgreSQLUserMappingReadImpl(db, d) +} + +func resourcePostgreSQLUserMappingRead(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + return resourcePostgreSQLUserMappingReadImpl(db, d) +} + +func resourcePostgreSQLUserMappingReadImpl(db *DBConnection, d *schema.ResourceData) error { + username := d.Get(userMappingUserNameAttr).(string) + serverName := d.Get(userMappingServerNameAttr).(string) + + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + var userMappingOptions []string + query := "SELECT umoptions FROM pg_user_mappings WHERE usename = $1 and srvname = $2" + err = txn.QueryRow(query, username, serverName).Scan(pq.Array(&userMappingOptions)) + switch { + case err == sql.ErrNoRows: + log.Printf("[WARN] PostgreSQL user mapping (%s) for server (%s) not found", username, serverName) + d.SetId("") + return nil + case err != nil: + return fmt.Errorf("Error reading user mapping: %w", err) + } + + mappedOptions := make(map[string]interface{}) + for _, v := range userMappingOptions { + pair := strings.Split(v, "=") + mappedOptions[pair[0]] = pair[1] + } + + d.Set(userMappingUserNameAttr, username) + d.Set(userMappingServerNameAttr, serverName) + d.Set(userMappingOptionsAttr, mappedOptions) + d.SetId(generateUserMappingID(d)) + + return nil +} + +func resourcePostgreSQLUserMappingDelete(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + username := d.Get(userMappingUserNameAttr).(string) + serverName := d.Get(userMappingServerNameAttr).(string) + + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + sql := fmt.Sprintf("DROP USER MAPPING FOR %s SERVER %s ", pq.QuoteIdentifier(username), pq.QuoteIdentifier(serverName)) + if _, err := txn.Exec(sql); err != nil { + return err + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("Error deleting user mapping: %w", err) + } + + d.SetId("") + + return nil +} + +func resourcePostgreSQLUserMappingUpdate(db *DBConnection, d *schema.ResourceData) error { + if !db.featureSupported(featureServer) { + return fmt.Errorf( + "Foreign Server resource is not supported for this Postgres version (%s)", + db.version, + ) + } + + if err := setUserMappingOptionsIfChanged(db, d); err != nil { + return err + } + + return resourcePostgreSQLUserMappingReadImpl(db, d) +} + +func setUserMappingOptionsIfChanged(db *DBConnection, d *schema.ResourceData) error { + if !d.HasChange(userMappingOptionsAttr) { + return nil + } + + username := d.Get(userMappingUserNameAttr).(string) + serverName := d.Get(userMappingServerNameAttr).(string) + + b := bytes.NewBufferString("ALTER USER MAPPING ") + fmt.Fprintf(b, " FOR %s SERVER %s ", pq.QuoteIdentifier(username), pq.QuoteIdentifier(serverName)) + + oldOptions, newOptions := d.GetChange(userMappingOptionsAttr) + fmt.Fprint(b, " OPTIONS ( ") + cnt := 0 + len := len(newOptions.(map[string]interface{})) + toRemove := oldOptions.(map[string]interface{}) + for k, v := range newOptions.(map[string]interface{}) { + operation := "ADD" + if oldOptions.(map[string]interface{})[k] != nil { + operation = "SET" + delete(toRemove, k) + } + fmt.Fprintf(b, " %s %s %s ", operation, pq.QuoteIdentifier(k), pq.QuoteLiteral(v.(string))) + if cnt < len-1 { + fmt.Fprint(b, ", ") + } + cnt++ + } + + for k := range toRemove { + if cnt != 0 { // starting with 0 means to drop all the options. Cannot start with comma + fmt.Fprint(b, " , ") + } + fmt.Fprintf(b, " DROP %s ", pq.QuoteIdentifier(k)) + cnt++ + } + + fmt.Fprint(b, " ) ") + + if _, err := db.Exec(b.String()); err != nil { + return fmt.Errorf("Error updating user mapping options: %w", err) + } + + return nil +} + +func generateUserMappingID(d *schema.ResourceData) string { + return strings.Join([]string{ + d.Get(userMappingUserNameAttr).(string), + d.Get(userMappingServerNameAttr).(string), + }, ".") +} diff --git a/postgresql/resource_postgresql_user_mapping_test.go b/postgresql/resource_postgresql_user_mapping_test.go new file mode 100644 index 00000000..f89ff643 --- /dev/null +++ b/postgresql/resource_postgresql_user_mapping_test.go @@ -0,0 +1,252 @@ +package postgresql + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccPostgresqlUserMapping_Basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featureServer) + testSuperuserPreCheck(t) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPostgresqlUserMappingDestroy, + Steps: []resource.TestStep{ + { + Config: testAccPostgresqlUserMappingConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlUserMappingExists("postgresql_user_mapping.remote"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "server_name", "myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "user_name", "remote"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "options.user", "admin"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "options.password", "pass"), + ), + }, + }, + }) +} + +func TestAccPostgresqlUserMapping_Update(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featureServer) + testSuperuserPreCheck(t) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPostgresqlUserMappingDestroy, + Steps: []resource.TestStep{ + { + Config: testAccPostgresqlUserMappingConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_user_mapping.remote"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "server_name", "myserver_postgres"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "options.password", "pass"), + ), + }, + { + Config: testAccPostgresqlUserMappingChanges2, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_user_mapping.remote"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "options.password", "passUpdated"), + ), + }, + { + Config: testAccPostgresqlUserMappingChanges3, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlServerExists("postgresql_user_mapping.remote"), + resource.TestCheckResourceAttr( + "postgresql_user_mapping.remote", "options.%", "0"), + ), + }, + }, + }) +} + +func checkUserMappingExists(txn *sql.Tx, username string, serverName string) (bool, error) { + var _rez bool + err := txn.QueryRow("SELECT TRUE FROM pg_user_mappings WHERE usename = $1 AND srvname = $2", username, serverName).Scan(&_rez) + switch { + case err == sql.ErrNoRows: + return false, nil + case err != nil: + return false, fmt.Errorf("Error reading info about user mapping: %s", err) + } + + return true, nil +} + +func testAccCheckPostgresqlUserMappingDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*Client) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "postgresql_user_mapping" { + continue + } + + txn, err := startTransaction(client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + splitted := strings.Split(rs.Primary.ID, ".") + exists, err := checkUserMappingExists(txn, splitted[0], splitted[1]) + + if err != nil { + return fmt.Errorf("Error checking user mapping %s", err) + } + + if exists { + return fmt.Errorf("User mapping still exists after destroy") + } + } + + return nil +} + +func testAccCheckPostgresqlUserMappingExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Resource not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + username, ok := rs.Primary.Attributes[userMappingUserNameAttr] + if !ok { + return fmt.Errorf("No Attribute for username is set") + } + + serverName, ok := rs.Primary.Attributes[userMappingServerNameAttr] + if !ok { + return fmt.Errorf("No Attribute for server name is set") + } + + client := testAccProvider.Meta().(*Client) + txn, err := startTransaction(client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + exists, err := checkUserMappingExists(txn, username, serverName) + + if err != nil { + return fmt.Errorf("Error checking user mapping %s", err) + } + + if !exists { + return fmt.Errorf("User mapping not found") + } + + return nil + } +} + +var testAccPostgresqlUserMappingConfig = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} + +resource "postgresql_role" "remote" { + name = "remote" +} + +resource "postgresql_user_mapping" "remote" { + server_name = postgresql_server.myserver_postgres.server_name + user_name = postgresql_role.remote.name + options = { + user = "admin" + password = "pass" + } +} +` + +var testAccPostgresqlUserMappingChanges2 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" + } + + resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] + } + + resource "postgresql_role" "remote" { + name = "remote" + } + + resource "postgresql_user_mapping" "remote" { + server_name = postgresql_server.myserver_postgres.server_name + user_name = postgresql_role.remote.name + options = { + user = "admin" + password = "passUpdated" + } + } +` + +var testAccPostgresqlUserMappingChanges3 = ` +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" + } + + resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] + } + + resource "postgresql_role" "remote" { + name = "remote" + } + + resource "postgresql_user_mapping" "remote" { + server_name = postgresql_server.myserver_postgres.server_name + user_name = postgresql_role.remote.name + } +` diff --git a/website/docs/r/postgresql_server.html.markdown b/website/docs/r/postgresql_server.html.markdown new file mode 100644 index 00000000..fc16ff03 --- /dev/null +++ b/website/docs/r/postgresql_server.html.markdown @@ -0,0 +1,60 @@ +--- +layout: "postgresql" +page_title: "PostgreSQL: postgresql_server" +sidebar_current: "docs-postgresql-resource-postgresql_server" +description: |- + Creates and manages a foreign server on a PostgreSQL server. +--- + +# postgresql\_server + +The ``postgresql_server`` resource creates and manages a foreign server on a PostgreSQL server. + + +## Usage + +```hcl +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} +``` + +```hcl +resource "postgresql_extension" "ext_file_fdw" { + name = "file_fdw" +} + +resource "postgresql_server" "myserver_file" { + server_name = "myserver_file" + fdw_name = "file_fdw" + depends_on = [postgresql_extension.ext_file_fdw] +} +``` + +## Argument Reference + +* `server_name` - (Required) The name of the foreign server to be created. +* `fdw_name` - (Required) The name of the foreign-data wrapper that manages the server. +Changing this value + will force the creation of a new resource as this value can only be set + when the foreign server is created. +* `options` - (Optional) This clause specifies the options for the server. The options typically define the connection details of the server, but the actual names and values are dependent on the server's foreign-data wrapper. +* `server_type` - (Optional) Optional server type, potentially useful to foreign-data wrappers. +Changing this value + will force the creation of a new resource as this value can only be set + when the foreign server is created. +* `server_version` - (Optional) Optional server version, potentially useful to foreign-data wrappers. +* `server_owner` - (Optional) By default, the user who defines the server becomes its owner. Set this value to configure the new owner of the foreign server. +* `drop_cascade` - (Optional) When true, will drop objects that depend on the server (such as user mappings), and in turn all objects that depend on those objects . (Default: false) diff --git a/website/docs/r/postgresql_user_mapping.html.markdown b/website/docs/r/postgresql_user_mapping.html.markdown new file mode 100644 index 00000000..20ecdd89 --- /dev/null +++ b/website/docs/r/postgresql_user_mapping.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "postgresql" +page_title: "PostgreSQL: postgresql_user_mapping" +sidebar_current: "docs-postgresql-resource-postgresql_user_mapping" +description: |- + Creates and manages a user mapping on a PostgreSQL server. +--- + +# postgresql\_user\_mapping + +The ``postgresql_user_mapping`` resource creates and manages a user mapping on a PostgreSQL server. + + +## Usage + +```hcl +resource "postgresql_extension" "ext_postgres_fdw" { + name = "postgres_fdw" +} + +resource "postgresql_server" "myserver_postgres" { + server_name = "myserver_postgres" + fdw_name = "postgres_fdw" + options = { + host = "foo" + dbname = "foodb" + port = "5432" + } + + depends_on = [postgresql_extension.ext_postgres_fdw] +} + +resource "postgresql_role" "remote" { + name = "remote" +} + +resource "postgresql_user_mapping" "remote" { + server_name = postgresql_server.myserver_postgres.server_name + user_name = postgresql_role.remote.name + options = { + user = "admin" + password = "pass" + } +} +``` + +## Argument Reference + +* `user_name` - (Required) The name of an existing user that is mapped to foreign server. CURRENT_ROLE, CURRENT_USER, and USER match the name of the current user. When PUBLIC is specified, a so-called public mapping is created that is used when no user-specific mapping is applicable. +Changing this value + will force the creation of a new resource as this value can only be set + when the user mapping is created. +* `server_name` - (Required) The name of an existing server for which the user mapping is to be created. +Changing this value + will force the creation of a new resource as this value can only be set + when the user mapping is created. +* `options` - (Optional) This clause specifies the options of the user mapping. The options typically define the actual user name and password of the mapping. Option names must be unique. The allowed option names and values are specific to the server's foreign-data wrapper. diff --git a/website/postgresql.erb b/website/postgresql.erb index 17d2b955..8421842f 100644 --- a/website/postgresql.erb +++ b/website/postgresql.erb @@ -43,6 +43,12 @@ > postgresql_function + > + postgresql_server + + > + postgresql_user_mapping +