diff --git a/.changelog/5663.txt b/.changelog/5663.txt new file mode 100644 index 00000000000..ccd1fa7ceed --- /dev/null +++ b/.changelog/5663.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +gcf: Added SecretManager integration support to GCF. +``` diff --git a/google/resource_cloudfunctions_function.go b/google/resource_cloudfunctions_function.go index 3925c3feaad..319d1575862 100644 --- a/google/resource_cloudfunctions_function.go +++ b/google/resource_cloudfunctions_function.go @@ -315,6 +315,82 @@ func resourceCloudFunctionsFunction() *schema.Resource { ForceNew: true, Description: `Region of function. If it is not provided, the provider region is used.`, }, + + "secret_environment_variables": { + Type: schema.TypeList, + Optional: true, + Description: `Secret environment variables configuration`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Required: true, + Description: `Name of the environment variable.`, + }, + "project_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: `Project identifier (due to a known limitation, only project number is supported by this field) of the project that contains the secret. If not set, it will be populated with the function's project, assuming that the secret exists in the same project as of the function.`, + }, + "secret": { + Type: schema.TypeString, + Required: true, + Description: `ID of the secret in secret manager (not the full resource name).`, + }, + "version": { + Type: schema.TypeString, + Required: true, + Description: `Version of the secret (version number or the string "latest"). It is recommended to use a numeric version for secret environment variables as any updates to the secret value is not reflected until new clones start.`, + }, + }, + }, + }, + + "secret_volumes": { + Type: schema.TypeList, + Optional: true, + Description: `Secret volumes configuration.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "mount_path": { + Type: schema.TypeString, + Required: true, + Description: `The path within the container to mount the secret volume. For example, setting the mount_path as "/etc/secrets" would mount the secret value files under the "/etc/secrets" directory. This directory will also be completely shadowed and unavailable to mount any other secrets. Recommended mount paths: "/etc/secrets" Restricted mount paths: "/cloudsql", "/dev/log", "/pod", "/proc", "/var/log".`, + }, + "project_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: `Project identifier (due to a known limitation, only project number is supported by this field) of the project that contains the secret. If not set, it will be populated with the function's project, assuming that the secret exists in the same project as of the function.`, + }, + "secret": { + Type: schema.TypeString, + Required: true, + Description: `ID of the secret in secret manager (not the full resource name).`, + }, + "versions": { + Type: schema.TypeList, + Optional: true, + Description: `List of secret versions to mount for this secret. If empty, the "latest" version of the secret will be made available in a file named after the secret under the mount point.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "path": { + Type: schema.TypeString, + Required: true, + Description: `Relative path of the file under the mount path where the secret value for this version will be fetched and made available. For example, setting the mount_path as "/etc/secrets" and path as "/secret_foo" would mount the secret value file at "/etc/secrets/secret_foo".`, + }, + "version": { + Type: schema.TypeString, + Required: true, + Description: `Version of the secret (version number or the string "latest"). It is preferable to use "latest" version with secret volumes as secret value changes are reflected immediately.`, + }, + }, + }, + }, + }, + }, + }, }, UseJSONNumber: true, } @@ -362,6 +438,16 @@ func resourceCloudFunctionsCreate(d *schema.ResourceData, meta interface{}) erro function.SourceArchiveUrl = fmt.Sprintf("gs://%v/%v", sourceArchiveBucket, sourceArchiveObj) } + secretEnv := d.Get("secret_environment_variables").([]interface{}) + if len(secretEnv) > 0 { + function.SecretEnvironmentVariables = expandSecretEnvironmentVariables(secretEnv) + } + + secretVolume := d.Get("secret_volumes").([]interface{}) + if len(secretVolume) > 0 { + function.SecretVolumes = expandSecretVolumes(secretVolume) + } + if v, ok := d.GetOk("available_memory_mb"); ok { availableMemoryMb := v.(int) function.AvailableMemoryMb = int64(availableMemoryMb) @@ -523,6 +609,14 @@ func resourceCloudFunctionsRead(d *schema.ResourceData, meta interface{}) error return fmt.Errorf("Error setting source_repository: %s", err) } + if err := d.Set("secret_environment_variables", flattenSecretEnvironmentVariables(function.SecretEnvironmentVariables)); err != nil { + return fmt.Errorf("Error setting secret_environment_variables: %s", err) + } + + if err := d.Set("secret_volumes", flattenSecretVolumes(function.SecretVolumes)); err != nil { + return fmt.Errorf("Error setting secret_volumes: %s", err) + } + if function.HttpsTrigger != nil { if err := d.Set("trigger_http", true); err != nil { return fmt.Errorf("Error setting trigger_http: %s", err) @@ -600,6 +694,16 @@ func resourceCloudFunctionsUpdate(d *schema.ResourceData, meta interface{}) erro updateMaskArr = append(updateMaskArr, "sourceRepository") } + if d.HasChange("secret_environment_variables") { + function.SecretEnvironmentVariables = expandSecretEnvironmentVariables(d.Get("secret_environment_variables").([]interface{})) + updateMaskArr = append(updateMaskArr, "secretEnvironmentVariables") + } + + if d.HasChange("secret_volumes") { + function.SecretVolumes = expandSecretVolumes(d.Get("secret_volumes").([]interface{})) + updateMaskArr = append(updateMaskArr, "secretVolumes") + } + if d.HasChange("description") { function.Description = d.Get("description").(string) updateMaskArr = append(updateMaskArr, "description") @@ -818,3 +922,110 @@ func flattenSourceRepository(sourceRepo *cloudfunctions.SourceRepository) []map[ return result } + +func expandSecretEnvironmentVariables(configured []interface{}) []*cloudfunctions.SecretEnvVar { + if len(configured) == 0 { + return nil + } + result := make([]*cloudfunctions.SecretEnvVar, 0, len(configured)) + for _, e := range configured { + data := e.(map[string]interface{}) + result = append(result, &cloudfunctions.SecretEnvVar{ + Key: data["key"].(string), + ProjectId: data["project_id"].(string), + Secret: data["secret"].(string), + Version: data["version"].(string), + }) + } + return result +} + +func flattenSecretEnvironmentVariables(envVars []*cloudfunctions.SecretEnvVar) []map[string]interface{} { + if envVars == nil { + return nil + } + var result []map[string]interface{} + + for _, envVar := range envVars { + if envVar != nil { + data := map[string]interface{}{ + "key": envVar.Key, + "project_id": envVar.ProjectId, + "secret": envVar.Secret, + "version": envVar.Version, + } + result = append(result, data) + } + } + return result +} + +func expandSecretVolumes(configured []interface{}) []*cloudfunctions.SecretVolume { + if len(configured) == 0 { + return nil + } + result := make([]*cloudfunctions.SecretVolume, 0, len(configured)) + for _, e := range configured { + data := e.(map[string]interface{}) + result = append(result, &cloudfunctions.SecretVolume{ + MountPath: data["mount_path"].(string), + ProjectId: data["project_id"].(string), + Secret: data["secret"].(string), + Versions: expandSecretVersion(data["versions"].([]interface{})), //TODO + }) + } + return result +} + +func flattenSecretVolumes(secretVolumes []*cloudfunctions.SecretVolume) []map[string]interface{} { + if secretVolumes == nil { + return nil + } + var result []map[string]interface{} + + for _, secretVolume := range secretVolumes { + if secretVolume != nil { + data := map[string]interface{}{ + "mount_path": secretVolume.MountPath, + "project_id": secretVolume.ProjectId, + "secret": secretVolume.Secret, + "versions": flattenSecretVersion(secretVolume.Versions), + } + result = append(result, data) + } + } + return result +} + +func expandSecretVersion(configured []interface{}) []*cloudfunctions.SecretVersion { + if len(configured) == 0 { + return nil + } + result := make([]*cloudfunctions.SecretVersion, 0, len(configured)) + for _, e := range configured { + data := e.(map[string]interface{}) + result = append(result, &cloudfunctions.SecretVersion{ + Path: data["path"].(string), + Version: data["version"].(string), + }) + } + return result +} + +func flattenSecretVersion(secretVersions []*cloudfunctions.SecretVersion) []map[string]interface{} { + if secretVersions == nil { + return nil + } + var result []map[string]interface{} + + for _, secretVersion := range secretVersions { + if secretVersion != nil { + data := map[string]interface{}{ + "path": secretVersion.Path, + "version": secretVersion.Version, + } + result = append(result, data) + } + } + return result +} diff --git a/google/resource_cloudfunctions_function_test.go b/google/resource_cloudfunctions_function_test.go index 49c8819a591..5af360b6b89 100644 --- a/google/resource_cloudfunctions_function_test.go +++ b/google/resource_cloudfunctions_function_test.go @@ -25,6 +25,8 @@ const testHTTPTriggerUpdatePath = "./test-fixtures/cloudfunctions/http_trigger_u const testPubSubTriggerPath = "./test-fixtures/cloudfunctions/pubsub_trigger.js" const testBucketTriggerPath = "./test-fixtures/cloudfunctions/bucket_trigger.js" const testFirestoreTriggerPath = "./test-fixtures/cloudfunctions/firestore_trigger.js" +const testSecretEnvVarFunctionPath = "./test-fixtures/cloudfunctions/secret_environment_variables.js" +const testSecretVolumesMountFunctionPath = "./test-fixtures/cloudfunctions/secret_volumes_mount.js" const testFunctionsSourceArchivePrefix = "cloudfunczip" func init() { @@ -420,6 +422,89 @@ func TestAccCloudFunctionsFunction_vpcConnector(t *testing.T) { }) } +func TestAccCloudFunctionsFunction_secretEnvVar(t *testing.T) { + t.Parallel() + + randomSecretSuffix := randString(t, 10) + accountId := fmt.Sprintf("tf-test-account-%s", randomSecretSuffix) + secretName := fmt.Sprintf("tf-test-secret-%s", randomSecretSuffix) + versionName1 := fmt.Sprintf("tf-test-version1-%s", randomSecretSuffix) + versionName2 := fmt.Sprintf("tf-test-version2-%s", randomSecretSuffix) + bucketName := fmt.Sprintf("tf-test-bucket-%d", randInt(t)) + functionName := fmt.Sprintf("tf-test-%s", randomSecretSuffix) + zipFilePath := createZIPArchiveForCloudFunctionSource(t, testSecretEnvVarFunctionPath) + funcResourceName := "google_cloudfunctions_function.function" + defer os.Remove(zipFilePath) // clean up + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudFunctionsFunctionDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccCloudFunctionsFunction_secretEnvVar(secretName, versionName1, bucketName, functionName, "1", zipFilePath, accountId), + }, + { + ResourceName: funcResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"build_environment_variables"}, + }, + { + Config: testAccCloudFunctionsFunction_secretEnvVar(secretName, versionName2, bucketName+"-update", functionName, "2", zipFilePath, accountId), + }, + { + ResourceName: funcResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"build_environment_variables"}, + }, + }, + }) +} + +func TestAccCloudFunctionsFunction_secretMount(t *testing.T) { + t.Parallel() + + projectNumber := os.Getenv("GOOGLE_PROJECT_NUMBER") + randomSecretSuffix := randString(t, 10) + accountId := fmt.Sprintf("tf-test-account-%s", randomSecretSuffix) + secretName := fmt.Sprintf("tf-test-secret-%s", randomSecretSuffix) + versionName1 := fmt.Sprintf("tf-test-version1-%s", randomSecretSuffix) + versionName2 := fmt.Sprintf("tf-test-version2-%s", randomSecretSuffix) + bucketName := fmt.Sprintf("tf-test-bucket-%d", randInt(t)) + functionName := fmt.Sprintf("tf-test-%s", randomSecretSuffix) + zipFilePath := createZIPArchiveForCloudFunctionSource(t, testSecretVolumesMountFunctionPath) + funcResourceName := "google_cloudfunctions_function.function" + defer os.Remove(zipFilePath) // clean up + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudFunctionsFunctionDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccCloudFunctionsFunction_secretMount(projectNumber, secretName, versionName1, bucketName, functionName, "1", zipFilePath, accountId), + }, + { + ResourceName: funcResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"build_environment_variables"}, + }, + { + Config: testAccCloudFunctionsFunction_secretMount(projectNumber, secretName, versionName2, bucketName, functionName, "2", zipFilePath, accountId), + }, + { + ResourceName: funcResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"build_environment_variables"}, + }, + }, + }) +} + func testAccCheckCloudFunctionsFunctionDestroyProducer(t *testing.T) func(s *terraform.State) error { return func(s *terraform.State) error { config := googleProviderConfig(t) @@ -917,3 +1002,137 @@ resource "google_cloudfunctions_function" "function" { } `, projectNumber, networkName, vpcConnectorName, vpcConnectorName, vpcIp, bucketName, zipFilePath, functionName, vpcConnectorName) } + +func testAccCloudFunctionsFunction_secretEnvVar(secretName, versionName, bucketName, functionName, versionNumber, zipFilePath, accountId string) string { + return fmt.Sprintf(` +data "google_project" "project" {} + +resource "google_service_account" "cloud_function_runner" { + account_id = "%s" + display_name = "Testing Cloud Function Secrets integration" +} + +resource "google_secret_manager_secret" "test_secret" { + secret_id = "%s" + + replication { + user_managed { + replicas { + location = "us-central1" + } + replicas { + location = "us-east1" + } + } + } +} + +resource "google_secret_manager_secret_version" "%s" { + secret = google_secret_manager_secret.test_secret.id + secret_data = "This is my secret data." +} + +resource "google_secret_manager_secret_iam_member" "cloud_function_iam_member" { + secret_id = google_secret_manager_secret.test_secret.id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.cloud_function_runner.email}" +} + +resource "google_storage_bucket" "cloud_functions" { + name = "%s" + location = "US" + uniform_bucket_level_access = true +} + +resource "google_storage_bucket_object" "cloud_function_zip_object" { + name = "cloud-function.zip" + bucket = google_storage_bucket.cloud_functions.name + source = "%s" +} + +resource "google_cloudfunctions_function" "function" { + name = "%s" + runtime = "nodejs14" + service_account_email = google_service_account.cloud_function_runner.email + entry_point = "echoSecret" + source_archive_bucket = google_storage_bucket.cloud_functions.id + source_archive_object = google_storage_bucket_object.cloud_function_zip_object.name + trigger_http = true + secret_environment_variables { + key = "MY_SECRET" + secret = google_secret_manager_secret.test_secret.secret_id + version = "%s" + } + +} +`, accountId, secretName, versionName, bucketName, zipFilePath, functionName, versionNumber) +} + +func testAccCloudFunctionsFunction_secretMount(projectNumber, secretName, versionName, bucketName, functionName, versionNumber, zipFilePath, accountId string) string { + return fmt.Sprintf(` +data "google_project" "project" {} + +resource "google_service_account" "cloud_function_runner" { + account_id = "%s" + display_name = "Testing Cloud Function Secrets integration" +} + +resource "google_secret_manager_secret" "test_secret" { + secret_id = "%s" + + replication { + user_managed { + replicas { + location = "us-central1" + } + replicas { + location = "us-east1" + } + } + } +} + +resource "google_secret_manager_secret_version" "%s" { + secret = google_secret_manager_secret.test_secret.id + secret_data = "This is my secret data." +} + +resource "google_secret_manager_secret_iam_member" "cloud_function_iam_member" { + secret_id = google_secret_manager_secret.test_secret.id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.cloud_function_runner.email}" +} + +resource "google_storage_bucket" "cloud_functions" { + name = "%s" + location = "US" + uniform_bucket_level_access = true +} + +resource "google_storage_bucket_object" "cloud_function_zip_object" { + name = "cloud-function.zip" + bucket = google_storage_bucket.cloud_functions.name + source = "%s" +} + +resource "google_cloudfunctions_function" "function" { + name = "%s" + runtime = "nodejs14" + service_account_email = google_service_account.cloud_function_runner.email + entry_point = "echoSecret" + source_archive_bucket = google_storage_bucket.cloud_functions.id + source_archive_object = google_storage_bucket_object.cloud_function_zip_object.name + trigger_http = true + secret_volumes { + secret = google_secret_manager_secret.test_secret.secret_id + mount_path = "/etc/secrets" + project_id = "%s" + versions { + version = "%s" + path = "/test-secret" + } + } + +} +`, accountId, secretName, versionName, bucketName, zipFilePath, functionName, projectNumber, versionNumber) +} diff --git a/google/test-fixtures/cloudfunctions/secret_environment_variables.js b/google/test-fixtures/cloudfunctions/secret_environment_variables.js new file mode 100644 index 00000000000..60c4fe52fe6 --- /dev/null +++ b/google/test-fixtures/cloudfunctions/secret_environment_variables.js @@ -0,0 +1,7 @@ +/** + * HTTP Cloud Function for testing environment variable Secrets. + */ +exports.echoSecret = (req, res) => { + let message = req.query.message || req.body.message || "Secret: " + process.env.MY_SECRET; + res.status(200).send(message); +}; \ No newline at end of file diff --git a/google/test-fixtures/cloudfunctions/secret_volumes_mount.js b/google/test-fixtures/cloudfunctions/secret_volumes_mount.js new file mode 100644 index 00000000000..3b100a8237b --- /dev/null +++ b/google/test-fixtures/cloudfunctions/secret_volumes_mount.js @@ -0,0 +1,18 @@ +/** + * HTTP Cloud Function for testing volume mount Secrets. + */ +const fs = require('fs') +exports.echoSecret = (req, res) => { + const path = '/etc/secrets/test-secret' + fs.access(path, fs.F_OK, (err) => { + if (err) { + console.error(err) + res.status(200).send(err) + return + } + fs.readFile(path, 'utf8', function (err, data) { + res.status(200).send("Secret: " + data) + + }); + }) +}; \ No newline at end of file diff --git a/website/docs/r/cloudfunctions_function.html.markdown b/website/docs/r/cloudfunctions_function.html.markdown index aa1b86cde4e..5afb8abd136 100644 --- a/website/docs/r/cloudfunctions_function.html.markdown +++ b/website/docs/r/cloudfunctions_function.html.markdown @@ -152,6 +152,10 @@ Eg. `"nodejs10"`, `"nodejs12"`, `"nodejs14"`, `"python37"`, `"python38"`, `"pyth * `min_instances` - (Optional) The limit on the minimum number of function instances that may coexist at a given time. +* `secret_environment_variables` - (Optional) Secret environment variables configuration. Structure is [documented below](#nested_secret_environment_variables). + +* `secret_volumes` - (Optional) Secret volumes configuration. Structure is [documented below](#nested_secret_volumes). + The `event_trigger` block supports: * `event_type` - (Required) The type of event to observe. For example: `"google.storage.object.finalize"`. @@ -175,6 +179,32 @@ which to observe events. For example, `"myBucket"` or `"projects/my-project/topi * To refer to a moveable alias (branch): `https://source.developers.google.com/projects/*/repos/*/moveable-aliases/*/paths/*`. To refer to HEAD, use the `master` moveable alias. * To refer to a specific fixed alias (tag): `https://source.developers.google.com/projects/*/repos/*/fixed-aliases/*/paths/*` +The `secret_environment_variables` block supports: + +* `key` - (Required) Name of the environment variable. + +* `project_id` - (Optional) Project identifier (due to a known limitation, only project number is supported by this field) of the project that contains the secret. If not set, it will be populated with the function's project, assuming that the secret exists in the same project as of the function. + +* `secret` - (Required) ID of the secret in secret manager (not the full resource name). + +* `version` - (Required) Version of the secret (version number or the string "latest"). It is recommended to use a numeric version for secret environment variables as any updates to the secret value is not reflected until new clones start. + +The `secret_volumes` block supports: + +* `mount_path` - (Required) The path within the container to mount the secret volume. For example, setting the mount_path as "/etc/secrets" would mount the secret value files under the "/etc/secrets" directory. This directory will also be completely shadowed and unavailable to mount any other secrets. Recommended mount paths: "/etc/secrets" Restricted mount paths: "/cloudsql", "/dev/log", "/pod", "/proc", "/var/log". + +* `project_id` - (Optional) Project identifier (due to a known limitation, only project number is supported by this field) of the project that contains the secret. If not set, it will be populated with the function's project, assuming that the secret exists in the same project as of the function. + +* `secret` - (Required) ID of the secret in secret manager (not the full resource name). + +* `versions` - (Optional) List of secret versions to mount for this secret. If empty, the "latest" version of the secret will be made available in a file named after the secret under the mount point. Structure is [documented below](#nested_nested_versions). + +The `versions` block supports: + +* `path` - (Required) Relative path of the file under the mount path where the secret value for this version will be fetched and made available. For example, setting the mount_path as "/etc/secrets" and path as "/secret_foo" would mount the secret value file at "/etc/secrets/secret_foo". + +* `version` - (Required) Version of the secret (version number or the string "latest"). It is preferable to use "latest" version with secret volumes as secret value changes are reflected immediately. + ## Attributes Reference In addition to the arguments listed above, the following computed attributes are