Skip to content

Lock schema on GRANT (fix for pq: tuple concurrently updated) #510

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/issues/178/locals.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
locals {
read_only_users = ["fu", "bar"]
read_write_users = ["blah", "bzz"]
}
156 changes: 156 additions & 0 deletions examples/issues/178/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = ">= 3.0.2"
}
postgresql = {
source = "cyrilgdn/postgresql"
version = "1.21"
}
}
}

provider "docker" {
host = "unix:///var/run/docker.sock"
}

resource "docker_image" "postgres" {
name = var.postgres_image
keep_locally = var.keep_image
}

resource "docker_container" "postgres" {
image = docker_image.postgres.image_id
name = var.container_name
wait = true
ports {
internal = var.POSTGRES_PORT
external = var.POSTGRES_PORT
}
env = [
"POSTGRES_PASSWORD=${var.POSTGRES_PASSWORD}"
]
healthcheck {
test = ["CMD-SHELL", "pg_isready"]
interval = "5s"
timeout = "5s"
retries = 5
start_period = "2s"
}
}

provider "postgresql" {
scheme = "postgres"
host = var.POSTGRES_HOST
port = docker_container.postgres.ports[0].external
database = var.POSTGRES_PASSWORD
username = var.POSTGRES_PASSWORD
password = var.POSTGRES_PASSWORD
sslmode = "disable"
superuser = var.superuser
}

resource "postgresql_database" "this" {
name = var.database_name
template = var.database_template
owner = var.POSTGRES_USER
}

resource "postgresql_role" "readonly_role" {
name = "readonly"
login = false
superuser = false
create_database = false
create_role = false
inherit = false
replication = false
connection_limit = -1
}

resource "postgresql_role" "readwrite_role" {
name = "readwrite"
login = false
superuser = false
create_database = false
create_role = false
inherit = false
replication = false
connection_limit = -1
}

resource "postgresql_grant" "readonly_role" {
database = postgresql_database.this.name
role = postgresql_role.readonly_role.name
object_type = "table"
schema = "public"
privileges = ["SELECT"]
with_grant_option = false
}

resource "postgresql_grant" "readwrite_role" {
database = postgresql_database.this.name
role = postgresql_role.readwrite_role.name
object_type = "table"
schema = "public"
privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]
with_grant_option = false
}

resource "postgresql_role" "readonly_users" {
for_each = toset(local.read_only_users)
name = each.key
roles = [postgresql_role.readonly_role.name]
login = true
superuser = false
create_database = false
create_role = false
inherit = true
replication = false
connection_limit = -1
}

resource "postgresql_role" "readwrite_users" {
for_each = toset(local.read_write_users)
name = each.key
roles = [postgresql_role.readonly_role.name]
login = true
superuser = false
create_database = false
create_role = false
inherit = true
replication = false
connection_limit = -1
}

resource "postgresql_grant" "connect_db_readonly_role" {
database = postgresql_database.this.name
object_type = "database"
privileges = ["CREATE", "CONNECT"]
role = postgresql_role.readonly_role.name
}

resource "postgresql_grant" "connect_db_readwrite_role" {
database = postgresql_database.this.name
object_type = "database"
privileges = ["CREATE", "CONNECT"]
role = postgresql_role.readwrite_role.name
}

resource "postgresql_grant" "usage_readonly_role" {
database = postgresql_database.this.name
role = postgresql_role.readonly_role.name
object_type = "schema"
schema = "public"
privileges = ["USAGE"]
with_grant_option = false
}

resource "postgresql_grant" "usage_readwrite_role" {
database = postgresql_database.this.name
role = postgresql_role.readwrite_role.name
object_type = "schema"
schema = "public"
privileges = ["USAGE"]
with_grant_option = false
}
10 changes: 10 additions & 0 deletions examples/issues/178/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/bash

set -eou pipefail
i=0

while [ $i -lt 10 ]; do
terraform apply -auto-approve
terraform destroy -auto-approve
i=$((i+1))
done
67 changes: 67 additions & 0 deletions examples/issues/178/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
variable "postgres_image" {
description = "Which postgres docker image to use."
default = "postgres:15"
type = string
sensitive = false
}

variable "POSTGRES_USER" {
default = "postgres"
type = string
sensitive = false
}

variable "POSTGRES_PASSWORD" {
description = "Password for docker POSTGRES_USER"
default = "postgres"
type = string
sensitive = false
}

variable "POSTGRES_HOST" {
default = "127.0.0.1"
type = string
sensitive = false
}

variable "POSTGRES_PORT" {
description = "Which port postgres should listen on."
default = 5432
type = number
sensitive = false
}

variable "keep_image" {
description = "If true, then the Docker image won't be deleted on destroy operation. If this is false, it will delete the image from the docker local storage on destroy operation."
default = true
type = bool
sensitive = false
}

variable "database_name" {
description = "Name for the database to be created."
default = "issue178"
type = string
sensitive = false
}

variable "database_template" {
description = "The name of the template database from which to create the database."
default = "template0"
type = string
sensitive = false
}

variable "superuser" {
description = "Whether the POSTGRES_USER is a PostgreSQL superuser."
default = false
type = bool
sensitive = false
}

variable "container_name" {
description = "The name for the docker container."
default = "postgres"
type = string
sensitive = false
}
12 changes: 12 additions & 0 deletions postgresql/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,18 @@ func pgLockRole(txn *sql.Tx, role string) error {
return nil
}

// Lock a schema to avoid concurrent updates
func pgLockSchema(txn *sql.Tx, schema string) error {
if _, err := txn.Exec("SET statement_timeout = 0"); err != nil {
return fmt.Errorf("could not disable statement_timeout: %w", err)
}
if _, err := txn.Exec("SELECT pg_advisory_xact_lock(oid::bigint) FROM pg_catalog.pg_namespace WHERE nspname = $1", schema); err != nil {
return fmt.Errorf("could not get advisory lock for schema %s: %w", schema, err)
}

return nil
}

// Lock a database and all his members to avoid concurrent updates on some resources
func pgLockDatabase(txn *sql.Tx, database string) error {
// Disable statement timeout for this connection otherwise the lock could fail
Expand Down
4 changes: 4 additions & 0 deletions postgresql/resource_postgresql_grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ func resourcePostgreSQLGrantCreateOrUpdate(db *DBConnection, d *schema.ResourceD
if err := pgLockDatabase(txn, database); err != nil {
return err
}
} else if objectType == "schema" {
if err := pgLockSchema(txn, database); err != nil {
return err
}
}

owners, err := getRolesToGrant(txn, d)
Expand Down