From 196134cbf91996eabc50bdc586a657fe7ac71900 Mon Sep 17 00:00:00 2001 From: Artur Sawicki Date: Thu, 30 Nov 2023 13:56:55 +0100 Subject: [PATCH] feat: Add unsafe_execute resource (#2225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add unsafe exec resource * Rename resource and make the basic tests pass * Simplify tests setup * Rename resource args * Fix checking db existence * Add random part to id * Rename migration * Remove unnecessary config param * Remove duplicated checks * Extract function to generate capitalized name * Test escaped identifier * Test unsafe execute with grants * Add TODO * Add test with HCL for_each * Fix before pushing * Update docs with warning * Update example usage in docs * Add negative tests * Add read (WIP) * Pass test without query * Add test for query removed * Move unsafe client and implementation to SDK * Fix query unsafe * Update docs and change query behavior * Update docs * Update logs * Fix test --------- Co-authored-by: Jan Cieślak --- docs/resources/unsafe_execute.md | 143 ++++ .../snowflake_unsafe_execute/resource.tf | 104 +++ pkg/acceptance/testing.go | 8 + pkg/provider/provider.go | 1 + .../TestAcc_UnsafeExecute_commonSetup/test.tf | 4 + .../variables.tf | 7 + .../test.tf | 5 + .../variables.tf | 7 + .../TestAcc_UnsafeExecute_withRead/test.tf | 5 + .../variables.tf | 11 + pkg/resources/unsafe_execute.go | 156 ++++ .../unsafe_execute_acceptance_test.go | 746 ++++++++++++++++++ pkg/sdk/client_extensions_unsafe.go | 65 ++ ...ient_unsafe_extensions_integration_test.go | 54 ++ templates/resources/unsafe_execute.md.tmpl | 33 + 15 files changed, 1349 insertions(+) create mode 100644 docs/resources/unsafe_execute.md create mode 100644 examples/resources/snowflake_unsafe_execute/resource.tf create mode 100644 pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/test.tf create mode 100644 pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/variables.tf create mode 100644 pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/test.tf create mode 100644 pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/variables.tf create mode 100644 pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/test.tf create mode 100644 pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/variables.tf create mode 100644 pkg/resources/unsafe_execute.go create mode 100644 pkg/resources/unsafe_execute_acceptance_test.go create mode 100644 pkg/sdk/client_extensions_unsafe.go create mode 100644 pkg/sdk/testint/client_unsafe_extensions_integration_test.go create mode 100644 templates/resources/unsafe_execute.md.tmpl diff --git a/docs/resources/unsafe_execute.md b/docs/resources/unsafe_execute.md new file mode 100644 index 0000000000..e43ad8a456 --- /dev/null +++ b/docs/resources/unsafe_execute.md @@ -0,0 +1,143 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "snowflake_unsafe_execute Resource - terraform-provider-snowflake" +subcategory: "" +description: |- + Experimental resource used for testing purposes only. Allows to execute ANY SQL statement. +--- + +# snowflake_unsafe_execute (Resource) + +!> **Warning** This is a dangerous resource that allows executing **ANY** SQL statement. It may destroy resources if used incorrectly. It may behave incorrectly combined with other resources. Will be deleted in the upcoming versions. Use at your own risk. + +~> **Note** It can be theoretically used to manage resource that are not supported by the provider. This is risky and may brake other resources if used incorrectly. + +~> **Note** Use `query` parameter with caution. It will fetch **ALL** the results returned by the query provided. Try to limit the number of results by writing query with filters. Query failure does not stop resource creation; it simply results in `query_results` being empty. + +Experimental resource used for testing purposes only. Allows to execute ANY SQL statement. + +## Example Usage + +```terraform +################################## +### simple use cases +################################## + +# create and destroy resource +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# create and destroy resource using qualified name +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE \"abc\"" + revert = "DROP DATABASE \"abc\"" +} + +# with query +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" + query = "SHOW DATABASES LIKE '%ABC%'" +} + +################################## +### grants example +################################## + +# grant and revoke privilege USAGE to ROLE on database +resource "snowflake_unsafe_execute" "test" { + execute = "GRANT USAGE ON DATABASE ABC TO ROLE XYZ" + revert = "REVOKE USAGE ON DATABASE ABC FROM ROLE XYZ" +} + +# grant and revoke with for_each +variable "database_grants" { + type = list(object({ + database_name = string + role_id = string + privileges = list(string) + })) +} + +resource "snowflake_unsafe_execute" "test" { + for_each = { for index, db_grant in var.database_grants : index => db_grant } + execute = "GRANT ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} TO ROLE ${each.value.role_id}" + revert = "REVOKE ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} FROM ROLE ${each.value.role_id}" +} + +################################## +### fixing bad configuration +################################## + +# bad revert - simple +# 1 - resource created with a bad revert; it is constructed, revert is not validated before destroy happens +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "SELECT 1" +} + +# 2 - fix the revert first; resource won't be recreated +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# bad revert - complex (we assume that the problem is spotted after trying to change the execute) +# 1 - resource created with a bad revert; it is constructed, revert is not validated before destroy happens +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "SELECT 1" +} + +# 2 - try to create different database; it will fail on bad destroy +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE XYZ" + revert = "SELECT 1" +} + +# 3 - fix the revert first +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# 4 - create different database updating revert also +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE XYZ" + revert = "DROP DATABASE XYZ" +} + +# bad query +# 1 - resource will be created; query_results will be empty +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" + query = "bad query" +} + +# 2 - fix the query; query_results will be calculated; resource won't be recreated +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" + query = "SHOW DATABASES LIKE '%ABC%'" +} +``` + + +## Schema + +### Required + +- `execute` (String) SQL statement to execute. Forces recreation of resource on change. +- `revert` (String) SQL statement to revert the execute statement. Invoked when resource is being destroyed. + +### Optional + +- `query` (String) Optional SQL statement to do a read. Invoked after creation and every time it is changed. + +### Read-Only + +- `id` (String) The ID of this resource. +- `query_results` (List of Map of String) List of key-value maps (text to text) retrieved after executing read query. Will be empty if the query results in an error. diff --git a/examples/resources/snowflake_unsafe_execute/resource.tf b/examples/resources/snowflake_unsafe_execute/resource.tf new file mode 100644 index 0000000000..efca62f160 --- /dev/null +++ b/examples/resources/snowflake_unsafe_execute/resource.tf @@ -0,0 +1,104 @@ +################################## +### simple use cases +################################## + +# create and destroy resource +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# create and destroy resource using qualified name +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE \"abc\"" + revert = "DROP DATABASE \"abc\"" +} + +# with query +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" + query = "SHOW DATABASES LIKE '%ABC%'" +} + +################################## +### grants example +################################## + +# grant and revoke privilege USAGE to ROLE on database +resource "snowflake_unsafe_execute" "test" { + execute = "GRANT USAGE ON DATABASE ABC TO ROLE XYZ" + revert = "REVOKE USAGE ON DATABASE ABC FROM ROLE XYZ" +} + +# grant and revoke with for_each +variable "database_grants" { + type = list(object({ + database_name = string + role_id = string + privileges = list(string) + })) +} + +resource "snowflake_unsafe_execute" "test" { + for_each = { for index, db_grant in var.database_grants : index => db_grant } + execute = "GRANT ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} TO ROLE ${each.value.role_id}" + revert = "REVOKE ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} FROM ROLE ${each.value.role_id}" +} + +################################## +### fixing bad configuration +################################## + +# bad revert - simple +# 1 - resource created with a bad revert; it is constructed, revert is not validated before destroy happens +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "SELECT 1" +} + +# 2 - fix the revert first; resource won't be recreated +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# bad revert - complex (we assume that the problem is spotted after trying to change the execute) +# 1 - resource created with a bad revert; it is constructed, revert is not validated before destroy happens +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "SELECT 1" +} + +# 2 - try to create different database; it will fail on bad destroy +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE XYZ" + revert = "SELECT 1" +} + +# 3 - fix the revert first +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# 4 - create different database updating revert also +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE XYZ" + revert = "DROP DATABASE XYZ" +} + +# bad query +# 1 - resource will be created; query_results will be empty +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" + query = "bad query" +} + +# 2 - fix the query; query_results will be calculated; resource won't be recreated +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" + query = "SHOW DATABASES LIKE '%ABC%'" +} diff --git a/pkg/acceptance/testing.go b/pkg/acceptance/testing.go index a9601b1e5b..0cd29e41f4 100644 --- a/pkg/acceptance/testing.go +++ b/pkg/acceptance/testing.go @@ -83,3 +83,11 @@ func ConfigurationSameAsStepN(step int) func(config.TestStepConfigRequest) strin return filepath.Join("testdata", req.TestName, strconv.Itoa(step)) } } + +// ConfigurationDirectory should be used to obtain configuration if the same can be shared between multiple tests to avoid duplication of configuration and var files. +// Based on config.TestNameDirectory. Similar to config.StaticDirectory but prefixed provided directory with `testdata`. +func ConfigurationDirectory(directory string) func(config.TestStepConfigRequest) string { + return func(req config.TestStepConfigRequest) string { + return filepath.Join("testdata", directory) + } +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index e2270cf62c..f51cb5e706 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -476,6 +476,7 @@ func getResources() map[string]*schema.Resource { "snowflake_tag_association": resources.TagAssociation(), "snowflake_tag_masking_policy_association": resources.TagMaskingPolicyAssociation(), "snowflake_task": resources.Task(), + "snowflake_unsafe_execute": resources.UnsafeExecute(), "snowflake_user": resources.User(), "snowflake_user_ownership_grant": resources.UserOwnershipGrant(), "snowflake_user_public_keys": resources.UserPublicKeys(), diff --git a/pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/test.tf b/pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/test.tf new file mode 100644 index 0000000000..a71f75afd3 --- /dev/null +++ b/pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/test.tf @@ -0,0 +1,4 @@ +resource "snowflake_unsafe_execute" "test" { + execute = var.execute + revert = var.revert +} diff --git a/pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/variables.tf b/pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/variables.tf new file mode 100644 index 0000000000..e1cdc1640a --- /dev/null +++ b/pkg/resources/testdata/TestAcc_UnsafeExecute_commonSetup/variables.tf @@ -0,0 +1,7 @@ +variable "execute" { + type = string +} + +variable "revert" { + type = string +} diff --git a/pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/test.tf b/pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/test.tf new file mode 100644 index 0000000000..96c70bae50 --- /dev/null +++ b/pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/test.tf @@ -0,0 +1,5 @@ +resource "snowflake_unsafe_execute" "test" { + for_each = { for index, db_grant in var.database_grants : index => db_grant } + execute = "GRANT ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} TO ROLE ${each.value.role_id}" + revert = "REVOKE ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} FROM ROLE ${each.value.role_id}" +} diff --git a/pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/variables.tf b/pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/variables.tf new file mode 100644 index 0000000000..3a024e5399 --- /dev/null +++ b/pkg/resources/testdata/TestAcc_UnsafeExecute_grantsComplex/variables.tf @@ -0,0 +1,7 @@ +variable "database_grants" { + type = list(object({ + database_name = string + role_id = string + privileges = list(string) + })) +} diff --git a/pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/test.tf b/pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/test.tf new file mode 100644 index 0000000000..ff23b05f4e --- /dev/null +++ b/pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/test.tf @@ -0,0 +1,5 @@ +resource "snowflake_unsafe_execute" "test" { + execute = var.execute + revert = var.revert + query = var.query +} diff --git a/pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/variables.tf b/pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/variables.tf new file mode 100644 index 0000000000..769ca5a105 --- /dev/null +++ b/pkg/resources/testdata/TestAcc_UnsafeExecute_withRead/variables.tf @@ -0,0 +1,11 @@ +variable "execute" { + type = string +} + +variable "revert" { + type = string +} + +variable "query" { + type = string +} diff --git a/pkg/resources/unsafe_execute.go b/pkg/resources/unsafe_execute.go new file mode 100644 index 0000000000..af58617587 --- /dev/null +++ b/pkg/resources/unsafe_execute.go @@ -0,0 +1,156 @@ +package resources + +import ( + "context" + "database/sql" + "fmt" + "log" + + "github.com/Snowflake-Labs/terraform-provider-snowflake/pkg/sdk" + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var unsafeExecuteSchema = map[string]*schema.Schema{ + "execute": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "SQL statement to execute. Forces recreation of resource on change.", + }, + "revert": { + Type: schema.TypeString, + Required: true, + Description: "SQL statement to revert the execute statement. Invoked when resource is being destroyed.", + }, + "query": { + Type: schema.TypeString, + Optional: true, + Description: "Optional SQL statement to do a read. Invoked after creation and every time it is changed.", + }, + "query_results": { + Type: schema.TypeList, + Computed: true, + Description: "List of key-value maps (text to text) retrieved after executing read query. Will be empty if the query results in an error.", + Elem: &schema.Schema{ + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + }, +} + +func UnsafeExecute() *schema.Resource { + return &schema.Resource{ + Create: CreateUnsafeExecute, + Read: ReadUnsafeExecute, + Delete: DeleteUnsafeExecute, + Update: UpdateUnsafeExecute, + + Schema: unsafeExecuteSchema, + + DeprecationMessage: "Experimental resource. Will be deleted in the upcoming versions. Use at your own risk.", + Description: "Experimental resource used for testing purposes only. Allows to execute ANY SQL statement.", + } +} + +func ReadUnsafeExecute(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + ctx := context.Background() + client := sdk.NewClientFromDB(db) + + readStatement := d.Get("query").(string) + + setNilResults := func() error { + log.Printf(`[DEBUG] Clearing query_results`) + err := d.Set("query_results", nil) + if err != nil { + return err + } + return nil + } + + if readStatement == "" { + return setNilResults() + } else { + rows, err := client.QueryUnsafe(ctx, readStatement) + if err != nil { + log.Printf(`[WARN] SQL query "%s" failed with err %v`, readStatement, err) + return setNilResults() + } + log.Printf(`[INFO] SQL query "%s" executed successfully, returned rows count: %d`, readStatement, len(rows)) + rowsTransformed := make([]map[string]any, len(rows)) + for i, row := range rows { + t := make(map[string]any) + for k, v := range row { + if *v == nil { + t[k] = nil + } else { + switch (*v).(type) { + case fmt.Stringer: + t[k] = fmt.Sprintf("%v", *v) + case string: + t[k] = *v + default: + return fmt.Errorf("currently only objects convertible to String are supported by query; got %v", *v) + } + } + } + rowsTransformed[i] = t + } + err = d.Set("query_results", rowsTransformed) + if err != nil { + return err + } + } + + return nil +} + +func CreateUnsafeExecute(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + ctx := context.Background() + client := sdk.NewClientFromDB(db) + + id, err := uuid.GenerateUUID() + if err != nil { + return err + } + + executeStatement := d.Get("execute").(string) + _, err = client.ExecUnsafe(ctx, executeStatement) + if err != nil { + return err + } + + d.SetId(id) + log.Printf(`[INFO] SQL "%s" applied successfully\n`, executeStatement) + + return ReadUnsafeExecute(d, meta) +} + +func DeleteUnsafeExecute(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + ctx := context.Background() + client := sdk.NewClientFromDB(db) + + revertStatement := d.Get("revert").(string) + _, err := client.ExecUnsafe(ctx, revertStatement) + if err != nil { + return err + } + + d.SetId("") + log.Printf(`[INFO] SQL "%s" applied successfully\n`, revertStatement) + + return nil +} + +func UpdateUnsafeExecute(d *schema.ResourceData, meta interface{}) error { + if d.HasChange("query") { + return ReadUnsafeExecute(d, meta) + } + return nil +} diff --git a/pkg/resources/unsafe_execute_acceptance_test.go b/pkg/resources/unsafe_execute_acceptance_test.go new file mode 100644 index 0000000000..3a35b04caa --- /dev/null +++ b/pkg/resources/unsafe_execute_acceptance_test.go @@ -0,0 +1,746 @@ +package resources_test + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "regexp" + "strings" + "testing" + + acc "github.com/Snowflake-Labs/terraform-provider-snowflake/pkg/acceptance" + "github.com/Snowflake-Labs/terraform-provider-snowflake/pkg/sdk" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAcc_UnsafeExecute_basic(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + idLowerCase := strings.ToLower(generateUnsafeExecuteTestDatabaseName(t)) + idLowerCaseEscaped := fmt.Sprintf(`"%s"`, idLowerCase) + createDatabaseStatement := func(id string) string { return fmt.Sprintf("create database %s", id) } + dropDatabaseStatement := func(id string) string { return fmt.Sprintf("drop database %s", id) } + + resourceName := "snowflake_unsafe_execute.test" + createConfigVariables := func(id string) map[string]config.Variable { + return map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(dropDatabaseStatement(id)), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: testAccCheckDatabaseExistence(t, id, false), + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(id), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(id)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(id)), + resource.TestCheckNoResourceAttr(resourceName, "query"), + resource.TestCheckNoResourceAttr(resourceName, "query_results.#"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + }, + }) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: testAccCheckDatabaseExistence(t, idLowerCase, false), + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(idLowerCaseEscaped), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(idLowerCaseEscaped)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(idLowerCaseEscaped)), + resource.TestCheckNoResourceAttr(resourceName, "query"), + resource.TestCheckNoResourceAttr(resourceName, "query_results.#"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, idLowerCase, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_withRead(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + createDatabaseStatement := func(id string) string { return fmt.Sprintf("create database %s", id) } + dropDatabaseStatement := func(id string) string { return fmt.Sprintf("drop database %s", id) } + showDatabaseStatement := func(id string) string { return fmt.Sprintf("show databases like '%%%s%%'", id) } + + resourceName := "snowflake_unsafe_execute.test" + createConfigVariables := func(id string) map[string]config.Variable { + return map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(dropDatabaseStatement(id)), + "query": config.StringVariable(showDatabaseStatement(id)), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: testAccCheckDatabaseExistence(t, id, false), + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_withRead"), + ConfigVariables: createConfigVariables(id), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(id)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(id)), + resource.TestCheckResourceAttr(resourceName, "query", showDatabaseStatement(id)), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, id, true), + resource.TestCheckResourceAttrSet(resourceName, "query_results.#"), + resource.TestCheckResourceAttr(resourceName, "query_results.0.name", id), + resource.TestCheckResourceAttrSet(resourceName, "query_results.0.created_on"), + resource.TestCheckResourceAttr(resourceName, "query_results.0.budget", ""), + resource.TestCheckResourceAttr(resourceName, "query_results.0.comment", ""), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_readRemoved(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + createDatabaseStatement := func(id string) string { return fmt.Sprintf("create database %s", id) } + dropDatabaseStatement := func(id string) string { return fmt.Sprintf("drop database %s", id) } + showDatabaseStatement := func(id string) string { return fmt.Sprintf("show databases like '%%%s%%'", id) } + resourceName := "snowflake_unsafe_execute.test" + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: testAccCheckDatabaseExistence(t, id, false), + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_withRead"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(dropDatabaseStatement(id)), + "query": config.StringVariable(showDatabaseStatement(id)), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "query", showDatabaseStatement(id)), + resource.TestCheckResourceAttrSet(resourceName, "query_results.#"), + ), + }, + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_withRead"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(dropDatabaseStatement(id)), + "query": config.StringVariable(""), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "query", ""), + resource.TestCheckNoResourceAttr(resourceName, "query_results.#"), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_badQuery(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + createDatabaseStatement := func(id string) string { return fmt.Sprintf("create database %s", id) } + dropDatabaseStatement := func(id string) string { return fmt.Sprintf("drop database %s", id) } + showDatabaseStatement := func(id string) string { return fmt.Sprintf("show databases like '%%%s%%'", id) } + resourceName := "snowflake_unsafe_execute.test" + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: testAccCheckDatabaseExistence(t, id, false), + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_withRead"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(dropDatabaseStatement(id)), + "query": config.StringVariable("bad query"), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(id)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(id)), + resource.TestCheckResourceAttr(resourceName, "query", "bad query"), + resource.TestCheckNoResourceAttr(resourceName, "query_results.#"), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_withRead"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(dropDatabaseStatement(id)), + "query": config.StringVariable(showDatabaseStatement(id)), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "query", showDatabaseStatement(id)), + resource.TestCheckResourceAttrSet(resourceName, "query_results.#"), + resource.TestCheckResourceAttr(resourceName, "query_results.0.name", id), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_invalidExecuteStatement(t *testing.T) { + invalidCreateStatement := "create database" + invalidDropStatement := "drop database" + + createConfigVariables := func() map[string]config.Variable { + return map[string]config.Variable{ + "execute": config.StringVariable(invalidCreateStatement), + "revert": config.StringVariable(invalidDropStatement), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + ExpectError: regexp.MustCompile("SQL compilation error"), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_invalidRevertStatement(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + updatedId := generateUnsafeExecuteTestDatabaseName(t) + createDatabaseStatement := func(id string) string { return fmt.Sprintf("create database %s", id) } + dropDatabaseStatement := func(id string) string { return fmt.Sprintf("drop database %s", id) } + invalidDropStatement := "drop database" + + resourceName := "snowflake_unsafe_execute.test" + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: func(state *terraform.State) error { + err := testAccCheckDatabaseExistence(t, id, false)(state) + if err != nil { + return err + } + err = testAccCheckDatabaseExistence(t, updatedId, false)(state) + if err != nil { + return err + } + return nil + }, + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(invalidDropStatement), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(id)), + resource.TestCheckResourceAttr(resourceName, "revert", invalidDropStatement), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(updatedId)), + "revert": config.StringVariable(invalidDropStatement), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + ExpectError: regexp.MustCompile("SQL compilation error"), + }, + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(id)), + "revert": config.StringVariable(dropDatabaseStatement(id)), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(id)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(id)), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, id, true), + testAccCheckDatabaseExistence(t, updatedId, false), + ), + }, + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: map[string]config.Variable{ + "execute": config.StringVariable(createDatabaseStatement(updatedId)), + "revert": config.StringVariable(dropDatabaseStatement(updatedId)), + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(updatedId)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(updatedId)), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, id, false), + testAccCheckDatabaseExistence(t, updatedId, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_revertUpdated(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + execute := fmt.Sprintf("create database %s", id) + revert := fmt.Sprintf("drop database %s", id) + notMatchingRevert := "select 1" + var savedId string + + resourceName := "snowflake_unsafe_execute.test" + createConfigVariables := func(execute string, revert string) map[string]config.Variable { + return map[string]config.Variable{ + "execute": config.StringVariable(execute), + "revert": config.StringVariable(revert), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: testAccCheckDatabaseExistence(t, id, false), + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(execute, notMatchingRevert), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", notMatchingRevert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + savedId = value + return nil + }), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(execute, revert), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", revert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + if savedId != value { + return errors.New("different id after revert update") + } + return nil + }), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_executeUpdated(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + execute := fmt.Sprintf("create database %s", id) + revert := fmt.Sprintf("drop database %s", id) + + newId := fmt.Sprintf("%s_2", id) + newExecute := fmt.Sprintf("create database %s", newId) + newRevert := fmt.Sprintf("drop database %s", newId) + + var savedId string + + resourceName := "snowflake_unsafe_execute.test" + createConfigVariables := func(execute string, revert string) map[string]config.Variable { + return map[string]config.Variable{ + "execute": config.StringVariable(execute), + "revert": config.StringVariable(revert), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: func(state *terraform.State) error { + err := testAccCheckDatabaseExistence(t, id, false)(state) + if err != nil { + return err + } + err = testAccCheckDatabaseExistence(t, newId, false)(state) + if err != nil { + return err + } + return nil + }, + Steps: []resource.TestStep{ + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(execute, revert), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", revert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + savedId = value + return nil + }), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(newExecute, newRevert), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", newExecute), + resource.TestCheckResourceAttr(resourceName, "revert", newRevert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + if savedId == value { + return errors.New("same id after execute update") + } + return nil + }), + testAccCheckDatabaseExistence(t, id, false), + testAccCheckDatabaseExistence(t, newId, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_grants(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + roleId := generateUnsafeExecuteTestRoleName(t) + privilege := sdk.AccountObjectPrivilegeCreateSchema + execute := fmt.Sprintf("GRANT %s ON DATABASE %s TO ROLE %s", privilege, id, roleId) + revert := fmt.Sprintf("REVOKE %s ON DATABASE %s FROM ROLE %s", privilege, id, roleId) + + resourceName := "snowflake_unsafe_execute.test" + createConfigVariables := func(execute string, revert string) map[string]config.Variable { + return map[string]config.Variable{ + "execute": config.StringVariable(execute), + "revert": config.StringVariable(revert), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: func(state *terraform.State) error { + err := verifyGrantExists(t, roleId, privilege, false)(state) + dropResourcesForUnsafeExecuteTestCaseForGrants(t, id, roleId) + return err + }, + Steps: []resource.TestStep{ + { + PreConfig: func() { createResourcesForExecuteUnsafeTestCaseForGrants(t, id, roleId) }, + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_UnsafeExecute_commonSetup"), + ConfigVariables: createConfigVariables(execute, revert), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", revert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + verifyGrantExists(t, roleId, privilege, true), + ), + }, + }, + }) +} + +// TestAcc_UnsafeExecute_grantsComplex test fails with: +// +// testing_new_config.go:156: unexpected index type (string) for "snowflake_unsafe_execute.test[\"0\"]", for_each is not supported +// testing_new.go:68: unexpected index type (string) for "snowflake_unsafe_execute.test[\"0\"]", for_each is not supported +// +// Quick search unveiled this issue: https://github.com/hashicorp/terraform-plugin-sdk/issues/536. +// +// It also seems that it is working correctly underneath; with TF_LOG set to DEBUG we have: +// +// 2023/11/26 17:16:03 [DEBUG] SQL "GRANT CREATE SCHEMA,MODIFY ON DATABASE UNSAFE_EXECUTE_TEST_DATABASE_4397 TO ROLE UNSAFE_EXECUTE_TEST_ROLE_1145" applied successfully +// 2023/11/26 17:16:03 [DEBUG] SQL "GRANT MODIFY,USAGE ON DATABASE UNSAFE_EXECUTE_TEST_DATABASE_3740 TO ROLE UNSAFE_EXECUTE_TEST_ROLE_3008" applied successfully +func TestAcc_UnsafeExecute_grantsComplex(t *testing.T) { + t.Skip("Skipping TestAcc_UnsafeExecute_grantsComplex because of https://github.com/hashicorp/terraform-plugin-sdk/issues/536 issue") + + dbId1 := generateUnsafeExecuteTestDatabaseName(t) + dbId2 := generateUnsafeExecuteTestDatabaseName(t) + roleId1 := generateUnsafeExecuteTestRoleName(t) + roleId2 := generateUnsafeExecuteTestRoleName(t) + privilege1 := sdk.AccountObjectPrivilegeCreateSchema + privilege2 := sdk.AccountObjectPrivilegeModify + privilege3 := sdk.AccountObjectPrivilegeUsage + + // resourceName1 := "snowflake_unsafe_execute.test.0" + // resourceName2 := "snowflake_unsafe_execute.test.1" + createConfigVariables := func() map[string]config.Variable { + return map[string]config.Variable{ + "database_grants": config.ListVariable(config.ObjectVariable(map[string]config.Variable{ + "database_name": config.StringVariable(dbId1), + "role_id": config.StringVariable(roleId1), + "privileges": config.ListVariable(config.StringVariable(privilege1.String()), config.StringVariable(privilege2.String())), + }), config.ObjectVariable(map[string]config.Variable{ + "database_name": config.StringVariable(dbId2), + "role_id": config.StringVariable(roleId2), + "privileges": config.ListVariable(config.StringVariable(privilege2.String()), config.StringVariable(privilege3.String())), + })), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + PreCheck: func() { acc.TestAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: func(state *terraform.State) error { + err := verifyGrantExists(t, roleId1, privilege1, false)(state) + if err != nil { + return err + } + err = verifyGrantExists(t, roleId1, privilege2, false)(state) + if err != nil { + return err + } + err = verifyGrantExists(t, roleId1, privilege3, false)(state) + if err != nil { + return err + } + err = verifyGrantExists(t, roleId2, privilege1, false)(state) + if err != nil { + return err + } + err = verifyGrantExists(t, roleId2, privilege2, false)(state) + if err != nil { + return err + } + err = verifyGrantExists(t, roleId2, privilege3, false)(state) + if err != nil { + return err + } + dropResourcesForUnsafeExecuteTestCaseForGrants(t, dbId1, roleId1) + dropResourcesForUnsafeExecuteTestCaseForGrants(t, dbId2, roleId2) + return err + }, + Steps: []resource.TestStep{ + { + PreConfig: func() { + createResourcesForExecuteUnsafeTestCaseForGrants(t, dbId1, roleId1) + createResourcesForExecuteUnsafeTestCaseForGrants(t, dbId2, roleId2) + }, + ConfigDirectory: config.TestNameDirectory(), + ConfigVariables: createConfigVariables(), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{plancheck.ExpectNonEmptyPlan()}, + }, + Check: resource.ComposeTestCheckFunc( + // resource.TestCheckResourceAttrSet(resourceName1, "id"), + // resource.TestCheckResourceAttrSet(resourceName2, "id"), + verifyGrantExists(t, roleId1, privilege1, true), + verifyGrantExists(t, roleId1, privilege2, true), + verifyGrantExists(t, roleId1, privilege3, false), + verifyGrantExists(t, roleId2, privilege1, false), + verifyGrantExists(t, roleId2, privilege2, true), + verifyGrantExists(t, roleId2, privilege3, true), + ), + }, + }, + }) +} + +// generateUnsafeExecuteTestDatabaseName returns capitalized name on purpose. +// Using small caps without escaping creates problem with later using sdk client which uses identifier that is escaped by default. +func generateUnsafeExecuteTestDatabaseName(t *testing.T) string { + t.Helper() + id, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + t.Fatalf("Failed to generate database id: %v", err) + } + return fmt.Sprintf("UNSAFE_EXECUTE_TEST_DATABASE_%d", id) +} + +// generateUnsafeExecuteTestRoleName returns capitalized name on purpose. +// Using small caps without escaping creates problem with later using sdk client which uses identifier that is escaped by default. +func generateUnsafeExecuteTestRoleName(t *testing.T) string { + t.Helper() + id, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + t.Fatalf("Failed to generate role id: %v", err) + } + return fmt.Sprintf("UNSAFE_EXECUTE_TEST_ROLE_%d", id) +} + +func testAccCheckDatabaseExistence(t *testing.T, id string, shouldExist bool) func(state *terraform.State) error { + t.Helper() + return func(state *terraform.State) error { + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + _, err = client.Databases.ShowByID(ctx, sdk.NewAccountObjectIdentifier(id)) + if shouldExist { + if err != nil { + return fmt.Errorf("error while retrieving database %s, err = %w", id, err) + } + } else { + if err == nil { + return fmt.Errorf("database %v still exists", id) + } + } + return nil + } +} + +func createResourcesForExecuteUnsafeTestCaseForGrants(t *testing.T, dbId string, roleId string) { + t.Helper() + + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + err = client.Databases.Create(ctx, sdk.NewAccountObjectIdentifier(dbId), &sdk.CreateDatabaseOptions{}) + require.NoError(t, err) + + err = client.Roles.Create(ctx, sdk.NewCreateRoleRequest(sdk.NewAccountObjectIdentifier(roleId))) + require.NoError(t, err) +} + +func dropResourcesForUnsafeExecuteTestCaseForGrants(t *testing.T, dbId string, roleId string) { + t.Helper() + + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + err = client.Databases.Drop(ctx, sdk.NewAccountObjectIdentifier(dbId), &sdk.DropDatabaseOptions{}) + assert.NoError(t, err) + + err = client.Roles.Drop(ctx, sdk.NewDropRoleRequest(sdk.NewAccountObjectIdentifier(roleId))) + assert.NoError(t, err) +} + +func verifyGrantExists(t *testing.T, roleId string, privilege sdk.AccountObjectPrivilege, shouldExist bool) func(state *terraform.State) error { + t.Helper() + return func(state *terraform.State) error { + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + grants, err := client.Grants.Show(ctx, &sdk.ShowGrantOptions{ + To: &sdk.ShowGrantsTo{ + Role: sdk.NewAccountObjectIdentifier(roleId), + }, + }) + require.NoError(t, err) + + if shouldExist { + require.Equal(t, 1, len(grants)) + assert.Equal(t, privilege.String(), grants[0].Privilege) + assert.Equal(t, sdk.ObjectTypeDatabase, grants[0].GrantedOn) + assert.Equal(t, sdk.ObjectTypeRole, grants[0].GrantedTo) + assert.Equal(t, sdk.NewAccountObjectIdentifier(roleId).FullyQualifiedName(), grants[0].GranteeName.FullyQualifiedName()) + } else { + require.Equal(t, 0, len(grants)) + } + + // it does not matter what we return, because we have assertions above + return nil + } +} diff --git a/pkg/sdk/client_extensions_unsafe.go b/pkg/sdk/client_extensions_unsafe.go new file mode 100644 index 0000000000..fe0afd7302 --- /dev/null +++ b/pkg/sdk/client_extensions_unsafe.go @@ -0,0 +1,65 @@ +package sdk + +import ( + "context" + "database/sql" +) + +func (c *Client) ExecUnsafe(ctx context.Context, sql string) (sql.Result, error) { + return c.exec(ctx, sql) +} + +// QueryUnsafe for now only supports single query. For more queries we will have to adjust the behavior. From the gosnowflake driver docs: +// +// (...) while using the multi-statement feature, pass a Context that specifies the number of statements in the string. +// When multiple queries are executed by a single call to QueryContext(), multiple result sets are returned. After you process the first result set, get the next result set (for the next SQL statement) by calling NextResultSet(). +// +// Therefore, only single resultSet is processed. +func (c *Client) QueryUnsafe(ctx context.Context, sql string) ([]map[string]*any, error) { + rows, err := c.db.QueryContext(ctx, sql) + if err != nil { + return nil, err + } + allRows, err := unsafeExecuteProcessRows(rows) + if err != nil { + return nil, err + } + return allRows, nil +} + +func unsafeExecuteProcessRows(rows *sql.Rows) ([]map[string]*any, error) { + defer rows.Close() + + columnNames, err := rows.Columns() + if err != nil { + return nil, err + } + processedRows := make([]map[string]*any, 0) + for rows.Next() { + row, err := unsafeExecuteProcessRow(rows, columnNames) + if err != nil { + return nil, err + } + processedRows = append(processedRows, row) + } + + return processedRows, nil +} + +func unsafeExecuteProcessRow(rows *sql.Rows, columnNames []string) (map[string]*any, error) { + values := make([]any, len(columnNames)) + for i := range values { + values[i] = new(any) + } + + err := rows.Scan(values...) + if err != nil { + return nil, err + } + + row := make(map[string]*any) + for i, col := range columnNames { + row[col] = values[i].(*any) + } + return row, nil +} diff --git a/pkg/sdk/testint/client_unsafe_extensions_integration_test.go b/pkg/sdk/testint/client_unsafe_extensions_integration_test.go new file mode 100644 index 0000000000..9070a1315d --- /dev/null +++ b/pkg/sdk/testint/client_unsafe_extensions_integration_test.go @@ -0,0 +1,54 @@ +package testint + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInt_Client_UnsafeQuery(t *testing.T) { + client := testClient(t) + ctx := testContext(t) + + t.Run("test show databases", func(t *testing.T) { + sql := fmt.Sprintf("SHOW DATABASES LIKE '%%%s%%'", testDb(t).Name) + results, err := client.QueryUnsafe(ctx, sql) + require.NoError(t, err) + + assert.Len(t, results, 1) + row := results[0] + assert.Equal(t, testDb(t).Name, *row["name"]) + assert.NotEmpty(t, *row["created_on"]) + assert.Equal(t, "STANDARD", *row["kind"]) + assert.Equal(t, "ACCOUNTADMIN", *row["owner"]) + assert.Equal(t, "", *row["options"]) + assert.Equal(t, "", *row["comment"]) + assert.Equal(t, "N", *row["is_default"]) + assert.Nil(t, *row["budget"]) + }) + + t.Run("test more results", func(t *testing.T) { + db1, db1Cleanup := createDatabase(t, client) + t.Cleanup(db1Cleanup) + db2, db2Cleanup := createDatabase(t, client) + t.Cleanup(db2Cleanup) + db3, db3Cleanup := createDatabase(t, client) + t.Cleanup(db3Cleanup) + + sql := "SHOW DATABASES" + results, err := client.QueryUnsafe(ctx, sql) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(results), 4) + names := make([]any, len(results)) + for i, r := range results { + names[i] = *r["name"] + } + assert.Contains(t, names, testDb(t).Name) + assert.Contains(t, names, db1.Name) + assert.Contains(t, names, db2.Name) + assert.Contains(t, names, db3.Name) + }) +} diff --git a/templates/resources/unsafe_execute.md.tmpl b/templates/resources/unsafe_execute.md.tmpl new file mode 100644 index 0000000000..ab72c4bb07 --- /dev/null +++ b/templates/resources/unsafe_execute.md.tmpl @@ -0,0 +1,33 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +!> **Warning** This is a dangerous resource that allows executing **ANY** SQL statement. It may destroy resources if used incorrectly. It may behave incorrectly combined with other resources. Will be deleted in the upcoming versions. Use at your own risk. + +~> **Note** It can be theoretically used to manage resource that are not supported by the provider. This is risky and may brake other resources if used incorrectly. + +~> **Note** Use `query` parameter with caution. It will fetch **ALL** the results returned by the query provided. Try to limit the number of results by writing query with filters. Query failure does not stop resource creation; it simply results in `query_results` being empty. + +{{ .Description | trimspace }} + +{{ if .HasExample -}} +## Example Usage + +{{ tffile (printf "examples/resources/%s/resource.tf" .Name)}} +{{- end }} + +{{ .SchemaMarkdown | trimspace }} +{{- if .HasImport }} + +## Import + +Import is supported using the following syntax: + +{{ printf "{{codefile \"shell\" %q}}" .ImportFile }} +{{- end }}