diff --git a/postgresql/provider.go b/postgresql/provider.go index f0df3ea9..d5e3256e 100644 --- a/postgresql/provider.go +++ b/postgresql/provider.go @@ -207,6 +207,7 @@ func Provider() *schema.Provider { "postgresql_extension": resourcePostgreSQLExtension(), "postgresql_grant": resourcePostgreSQLGrant(), "postgresql_grant_role": resourcePostgreSQLGrantRole(), + "postgresql_event_trigger": resourcePostgreSQLEventTrigger(), "postgresql_replication_slot": resourcePostgreSQLReplicationSlot(), "postgresql_publication": resourcePostgreSQLPublication(), "postgresql_subscription": resourcePostgreSQLSubscription(), diff --git a/postgresql/resource_postgresql_event_trigger.go b/postgresql/resource_postgresql_event_trigger.go new file mode 100644 index 00000000..080a39e7 --- /dev/null +++ b/postgresql/resource_postgresql_event_trigger.go @@ -0,0 +1,403 @@ +package postgresql + +import ( + "bytes" + "database/sql" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/lib/pq" +) + +const ( + eventTriggerNameAttr = "name" + eventTriggerOnAttr = "on" + eventTriggerFunctionAttr = "function" + eventTriggerFunctionSchemaAttr = "function_schema" + eventTriggerFilterAttr = "filter" + eventTriggerFilterVariableAttr = "variable" + eventTriggerFilterValueAttr = "values" + eventTriggerDatabaseAttr = "database" + eventTriggerOwnerAttr = "owner" + eventTriggerStatusAttr = "status" +) + +var eventTriggerStatusMap = map[string]string{ + "disable": "DISABLE", + "enable": "ENABLE", + "enable_replica": "ENABLE REPLICA", + "enable_always": "ENABLE ALWAYS", +} + +func resourcePostgreSQLEventTrigger() *schema.Resource { + return &schema.Resource{ + Create: PGResourceFunc(resourcePostgreSQLEventTriggerCreate), + Read: PGResourceFunc(resourcePostgreSQLEventTriggerRead), + Update: PGResourceFunc(resourcePostgreSQLEventTriggerUpdate), + Delete: PGResourceFunc(resourcePostgreSQLEventTriggerDelete), + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + eventTriggerNameAttr: { + Type: schema.TypeString, + Required: true, + Description: "The name of the event trigger to create", + }, + eventTriggerOnAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The event the trigger will listen to", + ValidateFunc: validation.StringInSlice([]string{ + "ddl_command_start", + "ddl_command_end", + "sql_drop", + "table_rewrite", + }, false), + }, + eventTriggerFunctionAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "A function that is declared as taking no argument and returning type event_trigger", + }, + eventTriggerFilterAttr: { + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + eventTriggerFilterVariableAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of a variable used to filter events. Currently the only supported value is TAG", + ValidateFunc: validation.StringInSlice([]string{ + "TAG", + }, false), + }, + + eventTriggerFilterValueAttr: { + Type: schema.TypeList, + Required: true, + ForceNew: true, + MinItems: 1, + Description: "A list of values for the associated filter_variable for which the trigger should fire", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + eventTriggerDatabaseAttr: { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + Description: "The database where the event trigger is located. If not specified, the provider default database is used.", + }, + eventTriggerFunctionSchemaAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Schema where the function is located.", + }, + eventTriggerStatusAttr: { + Type: schema.TypeString, + Optional: true, + Default: "enable", + Description: "These configure the firing of event triggers. A disabled trigger is still known to the system, but is not executed when its triggering event occurs", + ValidateFunc: validation.StringInSlice([]string{ + "disable", + "enable", + "enable_replica", + "enable_always", + }, false), + }, + eventTriggerOwnerAttr: { + Type: schema.TypeString, + Required: true, + Description: "The user name of the owner of the event trigger. You can't use 'current_role', 'current_user' or 'session_user' in order to avoid drifts", + ValidateFunc: validation.StringNotInSlice([]string{ + "current_role", + "current_user", + "session_user", + }, true), + }, + }, + } +} + +func resourcePostgreSQLEventTriggerCreate(db *DBConnection, d *schema.ResourceData) error { + eventTriggerName := d.Get(eventTriggerNameAttr).(string) + database := getDatabase(d, db.client.databaseName) + + d.SetId(generateSchemaID(d, database)) + + b := bytes.NewBufferString("CREATE EVENT TRIGGER ") + fmt.Fprint(b, pq.QuoteIdentifier(eventTriggerName)) + + fmt.Fprint(b, " ON ", d.Get(eventTriggerOnAttr).(string)) + + if filters, ok := d.GetOk(eventTriggerFilterAttr); ok { + filters := filters.([]interface{}) + + for i, filter := range filters { + filter := filter.(map[string]interface{}) + + if variable, ok := filter[eventTriggerFilterVariableAttr]; ok { + if i == 0 { + fmt.Fprint(b, " WHEN ", variable) + } else { + fmt.Fprint(b, " AND ", variable) + } + } + + if values, ok := filter[eventTriggerFilterValueAttr]; ok { + var new_values []string + + for _, value := range values.([]interface{}) { + new_values = append(new_values, pq.QuoteLiteral(value.(string))) + } + + fmt.Fprint(b, " IN (", strings.Join(new_values, ","), ")") + } + } + } + + eventTriggerFunction := d.Get(eventTriggerFunctionAttr).(string) + eventTriggerSchema := d.Get(eventTriggerFunctionSchemaAttr).(string) + fmt.Fprint(b, " EXECUTE FUNCTION ", pq.QuoteIdentifier(eventTriggerSchema), ".", pq.QuoteIdentifier(eventTriggerFunction), "()") + + createSql := b.String() + + // Enable or disable the event trigger + b = bytes.NewBufferString("ALTER EVENT TRIGGER ") + fmt.Fprint(b, pq.QuoteIdentifier(eventTriggerName)) + + eventTriggerEnabled := d.Get(eventTriggerStatusAttr).(string) + fmt.Fprint(b, " ", eventTriggerStatusMap[eventTriggerEnabled]) + + statusSql := b.String() + + // Table owner + b = bytes.NewBufferString("ALTER EVENT TRIGGER ") + eventTriggerOwner := d.Get(eventTriggerOwnerAttr).(string) + fmt.Fprint(b, pq.QuoteIdentifier(eventTriggerName), " OWNER TO ", pq.QuoteIdentifier(eventTriggerOwner)) + + ownerSql := b.String() + + // Start transaction + txn, err := startTransaction(db.client, d.Get(eventTriggerDatabaseAttr).(string)) + if err != nil { + return err + } + defer deferredRollback(txn) + + if _, err := txn.Exec(createSql); err != nil { + return err + } + + if _, err := txn.Exec(statusSql); err != nil { + return err + } + + if _, err := txn.Exec(ownerSql); err != nil { + return err + } + + if err := txn.Commit(); err != nil { + return err + } + + return nil +} + +func resourcePostgreSQLEventTriggerUpdate(db *DBConnection, d *schema.ResourceData) error { + var eventTriggerName, eventTriggerNameNew string + + if d.HasChange(eventTriggerNameAttr) { + old, new := d.GetChange(eventTriggerNameAttr) + eventTriggerName = old.(string) + eventTriggerNameNew = new.(string) + } else { + eventTriggerName = d.Get(eventTriggerNameAttr).(string) + } + + database := getDatabase(d, db.client.databaseName) + + d.SetId(generateSchemaID(d, database)) + + // Enable or disable the event trigger + b := bytes.NewBufferString("ALTER EVENT TRIGGER ") + fmt.Fprint(b, pq.QuoteIdentifier(eventTriggerName)) + + eventTriggerEnabled := d.Get(eventTriggerStatusAttr).(string) + fmt.Fprint(b, " ", eventTriggerStatusMap[eventTriggerEnabled]) + + statusSql := b.String() + + // Table owner + b = bytes.NewBufferString("ALTER EVENT TRIGGER ") + eventTriggerOwner := d.Get(eventTriggerOwnerAttr).(string) + fmt.Fprint(b, pq.QuoteIdentifier(eventTriggerName), " OWNER TO ", eventTriggerOwner) + + ownerSql := b.String() + + txn, err := startTransaction(db.client, d.Get(eventTriggerDatabaseAttr).(string)) + if err != nil { + return err + } + defer deferredRollback(txn) + + if _, err := txn.Exec(statusSql); err != nil { + return err + } + + if _, err := txn.Exec(ownerSql); err != nil { + return err + } + + if eventTriggerNameNew != "" { + if _, err := txn.Exec( + fmt.Sprintf("ALTER EVENT TRIGGER %s RENAME TO %s", pq.QuoteIdentifier(eventTriggerName), pq.QuoteIdentifier(eventTriggerNameNew)), + ); err != nil { + return err + } + } + + if err := txn.Commit(); err != nil { + return err + } + + return nil +} + +func resourcePostgreSQLEventTriggerDelete(db *DBConnection, d *schema.ResourceData) error { + eventTriggerName := d.Get(eventTriggerNameAttr).(string) + database := getDatabase(d, db.client.databaseName) + + d.SetId(generateSchemaID(d, database)) + + sql := fmt.Sprintf("DROP EVENT TRIGGER %s", pq.QuoteIdentifier(eventTriggerName)) + + txn, err := startTransaction(db.client, d.Get(eventTriggerDatabaseAttr).(string)) + if err != nil { + return err + } + defer deferredRollback(txn) + + if _, err := txn.Exec(sql); err != nil { + return err + } + + if err := txn.Commit(); err != nil { + return err + } + + return nil +} + +func resourcePostgreSQLEventTriggerRead(db *DBConnection, d *schema.ResourceData) error { + database, eventTriggerName, err := getDBEventTriggerName(d, db.client.databaseName) + if err != nil { + return err + } + + query := `SELECT evtname, evtevent, proname, nspname, evtenabled, evttags, pg_get_userbyid(evtowner) ` + + `FROM pg_catalog.pg_event_trigger ` + + `JOIN pg_catalog.pg_proc on pg_catalog.pg_event_trigger.evtfoid = pg_catalog.pg_proc.oid ` + + `JOIN pg_catalog.pg_namespace on pg_catalog.pg_proc.pronamespace = pg_catalog.pg_namespace.oid ` + + `WHERE evtname=$1` + + var name, on, owner, function, schema, status string + var tags []string + + values := []interface{}{ + &name, + &on, + &function, + &schema, + &status, + (*pq.StringArray)(&tags), + &owner, + } + + txn, err := startTransaction(db.client, database) + if err != nil { + return err + } + defer deferredRollback(txn) + + err = txn.QueryRow(query, eventTriggerName).Scan(values...) + switch { + case err == sql.ErrNoRows: + d.SetId("") + return nil + case err != nil: + return fmt.Errorf("error reading event trigger: %w", err) + } + + if err := txn.Commit(); err != nil { + return err + } + + d.SetId(generateSchemaID(d, database)) + d.Set(eventTriggerNameAttr, name) + d.Set(eventTriggerOnAttr, on) + d.Set(eventTriggerFunctionAttr, function) + d.Set(eventTriggerOwnerAttr, owner) + d.Set(eventTriggerDatabaseAttr, database) + d.Set(eventTriggerFunctionSchemaAttr, schema) + + switch status { + case "D": + d.Set(eventTriggerStatusAttr, "disable") + case "O": + d.Set(eventTriggerStatusAttr, "enable") + case "R": + d.Set(eventTriggerStatusAttr, "enable_replica") + case "A": + d.Set(eventTriggerStatusAttr, "enable_always") + } + + var filters []interface{} + + if len(tags) > 0 { + var values []string + values = append(values, tags...) + + filter := map[string]interface{}{ + "variable": "TAG", + "values": values, + } + + filters = append(filters, filter) + } + + d.Set(eventTriggerFilterAttr, filters) + + return nil +} + +func getDBEventTriggerName(d *schema.ResourceData, databaseName string) (string, string, error) { + database := getDatabase(d, databaseName) + eventTriggerName := d.Get(eventTriggerNameAttr).(string) + + // When importing, we have to parse the ID to find event trigger and database names. + if eventTriggerName == "" { + parsed := strings.Split(d.Id(), ".") + if len(parsed) != 2 { + return "", "", fmt.Errorf("schema ID %s has not the expected format 'database.event_trigger': %v", d.Id(), parsed) + } + database = parsed[0] + eventTriggerName = parsed[1] + } + + return database, eventTriggerName, nil +} diff --git a/postgresql/resource_postgresql_event_trigger_test.go b/postgresql/resource_postgresql_event_trigger_test.go new file mode 100644 index 00000000..3bb427f6 --- /dev/null +++ b/postgresql/resource_postgresql_event_trigger_test.go @@ -0,0 +1,159 @@ +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 TestAccPostgresqlEventTrigger_Basic(t *testing.T) { + skipIfNotAcc(t) + testSuperuserPreCheck(t) + + // Create the database outside of resource.Test + // because we need to create test schemas. + dbSuffix, teardown := setupTestDatabase(t, true, true) + defer teardown() + + schemas := []string{"test_schema1"} + createTestSchemas(t, dbSuffix, schemas, "") + + dbName, _ := getTestDBNames(dbSuffix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPostgresqlEventTriggerDestroy, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testAccPostgreSQLEventTriggerConfig, dbName, schemas[0], "test_event"), + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlEventTriggerExists("postgresql_event_trigger.event_trigger", dbName), + ), + }, + { + Config: fmt.Sprintf(testAccPostgreSQLEventTriggerConfig, dbName, schemas[0], "test_event_renamed"), + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlEventTriggerExists("postgresql_event_trigger.event_trigger", dbName), + ), + }, + }, + }) +} + +func testAccCheckPostgresqlEventTriggerExists(n string, database 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") + } + + client := testAccProvider.Meta().(*Client) + txn, err := startTransaction(client, database) + if err != nil { + return err + } + defer deferredRollback(txn) + + exists, err := checkEventTriggerExists(txn, rs.Primary.Attributes["name"]) + + if err != nil { + return fmt.Errorf("Error checking event trigger %s", err) + } + + if !exists { + return fmt.Errorf("Event trigger not found") + } + + return nil + } +} + +func testAccCheckPostgresqlEventTriggerDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*Client) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "postgresql_event_trigger" { + continue + } + + var database string + for k, v := range rs.Primary.Attributes { + if k == "database" { + database = v + } + } + + txn, err := startTransaction(client, database) + if err != nil { + return err + } + defer deferredRollback(txn) + + exists, err := checkEventTriggerExists(txn, rs.Primary.Attributes["name"]) + + if err != nil { + return fmt.Errorf("Error checking event trigger %s", err) + } + + if exists { + return fmt.Errorf("Event trigger still exists after destroy") + } + } + + return nil +} + +func checkEventTriggerExists(txn *sql.Tx, signature string) (bool, error) { + var _rez string + err := txn.QueryRow("SELECT oid FROM pg_catalog.pg_event_trigger WHERE evtname=$1", signature).Scan(&_rez) + switch { + case err == sql.ErrNoRows: + return false, nil + case err != nil: + return false, fmt.Errorf("Error reading info about event trigger: %s", err) + } + + return true, nil +} + +var testAccPostgreSQLEventTriggerConfig = ` +resource "postgresql_function" "function" { + name = "test_function" + database = "%[1]s" + schema = "%[2]s" + + returns = "event_trigger" + language = "plpgsql" + body = <<-EOF + BEGIN + RAISE EXCEPTION 'command % is disabled', tg_tag; + END; + EOF +} + +resource "postgresql_event_trigger" "event_trigger" { + name = "%[3]s" + database = "%[1]s" + function = postgresql_function.function.name + function_schema = postgresql_function.function.schema + on = "ddl_command_end" + owner = "postgres" + status = "enable" + + filter { + variable = "TAG" + values = [ + "CREATE TABLE", + "ALTER TABLE", + ] + } +} +` diff --git a/website/docs/r/postgresql_event_trigger.html.markdown b/website/docs/r/postgresql_event_trigger.html.markdown new file mode 100644 index 00000000..f736be62 --- /dev/null +++ b/website/docs/r/postgresql_event_trigger.html.markdown @@ -0,0 +1,74 @@ +--- +layout: "postgresql" +page_title: "PostgreSQL: postgresql_event_trigger" +sidebar_current: "docs-postgresql-resource-postgresql_event_trigger" +description: |- + Creates and manages an event trigger on a PostgreSQL server. +--- + +# postgresql\_event_trigger + +The ``postgresql_event_trigger`` resource creates and manages [event trigger +objects](https://www.postgresql.org/docs/current/static/event-triggers.html) +within a PostgreSQL server instance. + +## Usage + +```hcl +resource "postgresql_function" "function" { + name = "test_function" + + returns = "event_trigger" + language = "plpgsql" + body = <<-EOF + BEGIN + RAISE EXCEPTION 'command % is disabled', tg_tag; + END; + EOF +} + +resource "postgresql_event_trigger" "event_trigger" { + name = "event_trigger_test" + function = postgresql_function.function.name + function_schema = postgresql_function.function.schema + on = "ddl_command_start" + owner = "postgres" + + filter { + variable = "TAG" + values = [ + "DROP TABLE" + ] + } +} +``` + +## Argument Reference + +* `name` - (Required) The name of the event trigger. + +* `on` - (Required) The name of the on event the trigger will listen to. The allowed names are "ddl_command_start", "ddl_command_end", "sql_drop" or "table_rewrite". + +* `function` - (Required) A function that is declared as taking no argument and returning type event_trigger. + +* `function_schema` - (Required) Schema where the function is located. + +* `filter` - (Optional) Lists of filter variables to restrict the firing of the trigger. Currently the only supported filter_variable is TAG. + * `variable` - (Required) The name of a variable used to filter events. Currently the only supported value is TAG. + * `values` - (Required) The name of the filter variable name. For TAG, this means a list of command tags (e.g., 'DROP FUNCTION'). + +* `database` - (Optional) The database where the event trigger is located. + If not specified, the function is created in the current database. + +* `status` - (Optional) These configure the firing of event triggers. The allowed names are "disable", "enable", "enable_replica" or "enable_always". Default is "enable". + +* `owner` - (Required) The user name of the owner of the event trigger. You can't use 'current_role', 'current_user' or 'session_user' in order to avoid drifts. + +## Import Example + +It is possible to import a `postgresql_event_trigger` resource with the following +command: + +``` +$ terraform import postgresql_event_trigger.event_trigger_test "database.event_trigger" +``` \ No newline at end of file