From 9186304d5d4398eeec11b7bd4ca8c8c92e2d7e5a Mon Sep 17 00:00:00 2001 From: Elim Tsiagbey Date: Wed, 28 Jun 2023 10:38:22 -0400 Subject: [PATCH] feat: add storage bucket resource (#417) * feat: add storage bucket resource - Create a new resource for storage bucket - Add examples and docs for how to use new resource - Tests added but they do not run on purpose and have not been validated yet because we cannot run tests for enterprise features yet. That will be addressed separately ## Considerations - Even though only AWS can be supported today, still added the `pluginID` & `pluginName` fields so it's easy to extend once other plugin types are supported - There is some logic around handling secrets sent to Boundary and validating secret HMAC changes that exist from `boundary_host_catalog_plugin` that was introduced in [PR-159](https://github.com/hashicorp/terraform-provider-boundary/pull/159). Used the same logic we've been using for that --- CHANGELOG.md | 5 + docs/resources/storage_bucket.md | 75 +++ .../boundary_storage_bucket/import.sh | 1 + .../boundary_storage_bucket/resource.tf | 24 + internal/provider/const.go | 2 + internal/provider/provider.go | 1 + internal/provider/resource_storage_bucket.go | 552 ++++++++++++++++++ .../provider/resource_storage_bucket_test.go | 369 ++++++++++++ 8 files changed, 1029 insertions(+) create mode 100644 docs/resources/storage_bucket.md create mode 100644 examples/resources/boundary_storage_bucket/import.sh create mode 100644 examples/resources/boundary_storage_bucket/resource.tf create mode 100644 internal/provider/resource_storage_bucket.go create mode 100644 internal/provider/resource_storage_bucket_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b4374dcb..6142ac4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Canonical reference for changes, improvements, and bugfixes for the Boundary Ter ## Next +### New and Improved + +* Add support for a storage bucket as a resource + ([PR](https://github.com/hashicorp/terraform-provider-boundary/pull/417)) + ## 1.1.8 (June 13, 2023) ### New and Improved diff --git a/docs/resources/storage_bucket.md b/docs/resources/storage_bucket.md new file mode 100644 index 00000000..f231a9ba --- /dev/null +++ b/docs/resources/storage_bucket.md @@ -0,0 +1,75 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "boundary_storage_bucket Resource - terraform-provider-boundary" +subcategory: "" +description: |- + The storage bucket resource allows you to configure a Boundary storage bucket. A storage bucket can only belong to the Global scope or an Org scope. At this time, the only supported storage for storage buckets is AWS S3. This feature requires Boundary Enterprise or Boundary HCP. +--- + +# boundary_storage_bucket (Resource) + +The storage bucket resource allows you to configure a Boundary storage bucket. A storage bucket can only belong to the Global scope or an Org scope. At this time, the only supported storage for storage buckets is AWS S3. This feature requires Boundary Enterprise or Boundary HCP. + +## Example Usage + +```terraform +resource "boundary_scope" "org" { + name = "organization_one" + description = "My first scope!" + scope_id = boundary_scope.global.id + auto_create_admin_role = true + auto_create_default_role = true +} + +resource "boundary_storage_bucket" "aws_example" { + name = "My aws storage bucket" + description = "My first storage bucket!" + scope_id = boundary_scope.org.id + plugin_name = "aws" + bucket_name = "mybucket" + attributes_json = jsonencode({ "region" = "us-east-1" }) + + # recommended to pass in aws secrets using a file() or using environment variables + # the secrets below must be generated in aws by creating a aws iam user with programmatic access + secrets_json = jsonencode({ + "access_key_id" = "aws_access_key_id_value", + "secret_access_key" = "aws_secret_access_key_value" + }) + worker_filter = "\"pki\" in \"/tags/type\"" +} +``` + + +## Schema + +### Required + +- `bucket_name` (String) The name of the bucket within the external object store service. +- `scope_id` (String) The scope for this storage bucket. +- `secrets_json` (String, Sensitive) The secrets for the storage bucket. Either values encoded with the "jsonencode" function, pre-escaped JSON string, or a file:// or env:// path. Set to a string "null" to clear any existing values. NOTE: Unlike "attributes_json", removing this block will NOT clear secrets from the storage bucket; this allows injecting secrets for one call, then removing them for storage. +- `worker_filter` (String) Filters to the worker(s) that can handle requests for this storage bucket. The filter must match an existing worker in order to create a storage bucket. + +### Optional + +- `attributes_json` (String) The attributes for the storage bucket. The "region" attribute field is required when creating an AWS storage bucket. Values are either encoded with the "jsonencode" function, pre-escaped JSON string, or a file:// or env:// path. Set to a string "null" or remove the block to clear all attributes in the storage bucket. +- `bucket_prefix` (String) The prefix used to organize the data held within the external object store. +- `description` (String) The storage bucket description. +- `name` (String) The storage bucket name. Defaults to the resource name. +- `plugin_id` (String) The ID of the plugin that should back the resource. This or plugin_name must be defined. +- `plugin_name` (String) The name of the plugin that should back the resource. This or plugin_id must be defined. + +### Read-Only + +- `id` (String) The ID of the storage bucket. +- `internal_force_update` (String) Internal only. Used to force update so that we can always check the value of secrets. +- `internal_hmac_used_for_secrets_config_hmac` (String) Internal only. The Boundary-provided HMAC used to calculate the current value of the HMAC'd config. Used for drift detection. +- `internal_secrets_config_hmac` (String) Internal only. HMAC of (serverSecretsHmac + config secrets). Used for proper secrets handling. +- `secrets_hmac` (String) The HMAC'd secrets value returned from the server. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import boundary_storage_bucket.foo +``` diff --git a/examples/resources/boundary_storage_bucket/import.sh b/examples/resources/boundary_storage_bucket/import.sh new file mode 100644 index 00000000..0b947cab --- /dev/null +++ b/examples/resources/boundary_storage_bucket/import.sh @@ -0,0 +1 @@ +terraform import boundary_storage_bucket.foo \ No newline at end of file diff --git a/examples/resources/boundary_storage_bucket/resource.tf b/examples/resources/boundary_storage_bucket/resource.tf new file mode 100644 index 00000000..7a50bd23 --- /dev/null +++ b/examples/resources/boundary_storage_bucket/resource.tf @@ -0,0 +1,24 @@ +resource "boundary_scope" "org" { + name = "organization_one" + description = "My first scope!" + scope_id = boundary_scope.global.id + auto_create_admin_role = true + auto_create_default_role = true +} + +resource "boundary_storage_bucket" "aws_example" { + name = "My aws storage bucket" + description = "My first storage bucket!" + scope_id = boundary_scope.org.id + plugin_name = "aws" + bucket_name = "mybucket" + attributes_json = jsonencode({ "region" = "us-east-1" }) + + # recommended to pass in aws secrets using a file() or using environment variables + # the secrets below must be generated in aws by creating a aws iam user with programmatic access + secrets_json = jsonencode({ + "access_key_id" = "aws_access_key_id_value", + "secret_access_key" = "aws_secret_access_key_value" + }) + worker_filter = "\"pki\" in \"/tags/type\"" +} diff --git a/internal/provider/const.go b/internal/provider/const.go index 1a95d6a0..1e069257 100644 --- a/internal/provider/const.go +++ b/internal/provider/const.go @@ -44,4 +44,6 @@ const ( // internalForceUpdateKey is used to force updates so we can always check // the value of secrets internalForceUpdateKey = "internal_force_update" + // workerFilter is used for common "worker_filter" resource attribute + WorkerFilterKey = "worker_filter" ) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8db0e929..b35b5301 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -122,6 +122,7 @@ func New() *schema.Provider { "boundary_host_set_plugin": resourceHostSetPlugin(), "boundary_role": resourceRole(), "boundary_scope": resourceScope(), + "boundary_storage_bucket": resourceStorageBucket(), "boundary_target": resourceTarget(), "boundary_user": resourceUser(), "boundary_worker": resourceWorker(), diff --git a/internal/provider/resource_storage_bucket.go b/internal/provider/resource_storage_bucket.go new file mode 100644 index 00000000..6b5c34a3 --- /dev/null +++ b/internal/provider/resource_storage_bucket.go @@ -0,0 +1,552 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/rand" + "net/http" + "strconv" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/storagebuckets" + "github.com/hashicorp/go-secure-stdlib/parseutil" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + storageBucketNameKey = "bucket_name" + storageBucketPrefixKey = "bucket_prefix" +) + +func resourceStorageBucket() *schema.Resource { + return &schema.Resource{ + Description: `The storage bucket resource allows you to configure a Boundary storage bucket. ` + + `A storage bucket can only belong to the Global scope or an Org scope. ` + + `At this time, the only supported storage for storage buckets is AWS S3. ` + + `This feature requires Boundary Enterprise or Boundary HCP.`, + CreateContext: resourceStorageBucketCreate, + ReadContext: resourceStorageBucketRead, + UpdateContext: resourceStorageBucketUpdate, + DeleteContext: resourceStorageBucketDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + IDKey: { + Description: "The ID of the storage bucket.", + Type: schema.TypeString, + Computed: true, + }, + NameKey: { + Description: "The storage bucket name. Defaults to the resource name.", + Type: schema.TypeString, + Optional: true, + }, + DescriptionKey: { + Description: "The storage bucket description.", + Type: schema.TypeString, + Optional: true, + }, + PluginIdKey: { + Description: "The ID of the plugin that should back the resource. This or " + PluginNameKey + " must be defined.", + Type: schema.TypeString, + ConflictsWith: []string{PluginNameKey}, + ExactlyOneOf: []string{PluginIdKey, PluginNameKey}, + Optional: true, + ForceNew: true, + Computed: true, // If name is provided this will be computed + }, + PluginNameKey: { + Description: "The name of the plugin that should back the resource. This or " + PluginIdKey + " must be defined.", + Type: schema.TypeString, + ConflictsWith: []string{PluginIdKey}, + ExactlyOneOf: []string{PluginIdKey, PluginNameKey}, + Optional: true, + ForceNew: true, + }, + ScopeIdKey: { + Description: "The scope for this storage bucket.", + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + SecretsJsonKey: { + Description: `The secrets for the storage bucket. Either values encoded with the "jsonencode" function, pre-escaped JSON string, ` + + `or a file:// or env:// path. Set to a string "null" to clear any existing values. NOTE: Unlike "attributes_json", removing ` + + `this block will NOT clear secrets from the storage bucket; this allows injecting secrets for one call, then removing them for storage.`, + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + SecretsHmacKey: { + Description: "The HMAC'd secrets value returned from the server.", + Type: schema.TypeString, + Computed: true, + }, + storageBucketNameKey: { + Description: "The name of the bucket within the external object store service.", + Type: schema.TypeString, + Required: true, + }, + storageBucketPrefixKey: { + Description: "The prefix used to organize the data held within the external object store.", + Type: schema.TypeString, + Optional: true, + }, + internalSecretsConfigHmacKey: { + Description: "Internal only. HMAC of (serverSecretsHmac + config secrets). Used for proper secrets handling.", + Type: schema.TypeString, + Computed: true, + }, + internalHmacUsedForSecretsConfigHmacKey: { + Description: "Internal only. The Boundary-provided HMAC used to calculate the current value of the HMAC'd config. Used for drift detection.", + Type: schema.TypeString, + Computed: true, + }, + AttributesJsonKey: { + Description: `The attributes for the storage bucket. The "region" attribute field is required when creating an AWS storage bucket. ` + + `Values are either encoded with the "jsonencode" function, pre-escaped JSON string, ` + + `or a file:// or env:// path. Set to a string "null" or remove the block to clear all attributes in the storage bucket.`, + Type: schema.TypeString, + Optional: true, + // If set to null in config and nothing comes from API, consider + // it the same. Same if config changes from empty to null. + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + sanitizedNew, err := sanitizeJson(new) + if err != nil { + return false + } + new = string(sanitizedNew) + switch { + case old == new: + return true + case old == "null" && new == "": + return true + case old == "" && new == "null": + return true + default: + return false + } + }, + }, + WorkerFilterKey: { + Description: `Filters to the worker(s) that can handle requests for this storage bucket. The filter must match an existing ` + + `worker in order to create a storage bucket.`, + Type: schema.TypeString, + Required: true, + }, + internalForceUpdateKey: { + Description: "Internal only. Used to force update so that we can always check the value of secrets.", + Type: schema.TypeString, + Computed: true, + }, + }, + + // We want to always force an update (which itself may not actually do + // anything) so that we can properly check secrets state. + CustomizeDiff: func(_ context.Context, d *schema.ResourceDiff, _ interface{}) error { + return d.SetNewComputed(internalForceUpdateKey) + }, + } +} + +func setFromStorageBucketResponseMap(d *schema.ResourceData, raw map[string]interface{}) error { + if err := d.Set(NameKey, raw[NameKey]); err != nil { + return err + } + if err := d.Set(DescriptionKey, raw[DescriptionKey]); err != nil { + return err + } + if err := d.Set(ScopeIdKey, raw[ScopeIdKey]); err != nil { + return err + } + + // Plugin stuff + { + if err := d.Set(PluginIdKey, raw[PluginIdKey]); err != nil { + return err + } + pluginRaw, ok := raw["plugin"] + if !ok { + return fmt.Errorf("plugin field not found in response") + } + pluginInfo, ok := pluginRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("plugin field in response has wrong type") + } + pluginNameRaw, ok := pluginInfo["name"] + if !ok { + return fmt.Errorf("plugin name field not found in response") + } + pluginName, ok := pluginNameRaw.(string) + if !ok { + return fmt.Errorf("plugin name field in response has wrong type") + } + if err := d.Set(PluginNameKey, pluginName); err != nil { + return err + } + } + + // Attributes stuff + { + attrRaw, ok := raw["attributes"] + switch ok { + case true: + encodedAttributes, err := json.Marshal(attrRaw) + if err != nil { + return err + } + if err := d.Set(AttributesJsonKey, string(encodedAttributes)); err != nil { + return err + } + default: + d.Set(AttributesJsonKey, nil) + } + } + + // Secrets stuff + { + // We do not save secrets into the state file, and they're not returned in + // the response + secretsHmacRaw, ok := raw[SecretsHmacKey] + switch ok { + case true: + if err := d.Set(SecretsHmacKey, secretsHmacRaw); err != nil { + return err + } + default: + d.Set(SecretsHmacKey, nil) + } + } + + d.SetId(raw[IDKey].(string)) + + if err := d.Set(internalForceUpdateKey, strconv.FormatInt(rand.Int63(), 10)); err != nil { + return err + } + + return nil +} + +func resourceStorageBucketCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + sbClient := storagebuckets.NewClient(md.client) + opts := []storagebuckets.Option{} + + var scopeId string + if scopeIdVal, ok := d.GetOk(ScopeIdKey); ok { + scopeId = scopeIdVal.(string) + } else { + return diag.Errorf("no scope ID provided") + } + + if pluginIdVal, ok := d.GetOk(PluginIdKey); ok { + opts = append(opts, storagebuckets.WithPluginId(pluginIdVal.(string))) + } + + if pluginNameVal, ok := d.GetOk(PluginNameKey); ok { + opts = append(opts, storagebuckets.WithPluginName(pluginNameVal.(string))) + } + + if nameVal, ok := d.GetOk(NameKey); ok { + opts = append(opts, storagebuckets.WithName(nameVal.(string))) + } + + if descVal, ok := d.GetOk(DescriptionKey); ok { + opts = append(opts, storagebuckets.WithDescription(descVal.(string))) + } + + if storageBucketNameVal, ok := d.GetOk(storageBucketNameKey); ok { + opts = append(opts, storagebuckets.WithBucketName(storageBucketNameVal.(string))) + } + + if storageBucketPrefixVal, ok := d.GetOk(storageBucketPrefixKey); ok { + opts = append(opts, storagebuckets.WithBucketPrefix(storageBucketPrefixVal.(string))) + } + + if workerFilterVal, ok := d.GetOk(WorkerFilterKey); ok { + opts = append(opts, storagebuckets.WithWorkerFilter(workerFilterVal.(string))) + } + + attrsVal, ok := d.GetOk(AttributesJsonKey) + if ok { + attrsStr, err := parseutil.ParsePath(attrsVal.(string)) + if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) { + return diag.Errorf("error parsing path with attributes: %v", err) + } + switch attrsStr { + case "null": + opts = append(opts, storagebuckets.DefaultAttributes()) + default: + // What comes in is json-encoded but we want to set a + // map[string]interface{} so we unmarshal it and set that + var m map[string]interface{} + if err := json.Unmarshal([]byte(attrsStr), &m); err != nil { + return diag.Errorf("error unmarshaling attributes: %v", err) + } + opts = append(opts, storagebuckets.WithAttributes(m)) + } + } + + secretsVal, ok := d.GetOk(SecretsJsonKey) + var secretsJson string + if ok { + var err error + secretsJson, err = parseutil.ParsePath(secretsVal.(string)) + if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) { + return diag.Errorf("error parsing path with secrets: %v", err) + } + switch secretsJson { + case "null": + opts = append(opts, storagebuckets.DefaultSecrets()) + default: + // What comes in is json-encoded but we want to set a + // map[string]interface{} so we unmarshal it and set that + var m map[string]interface{} + if err := json.Unmarshal([]byte(secretsJson), &m); err != nil { + return diag.Errorf("error unmarshaling secrets: %v", err) + } + opts = append(opts, storagebuckets.WithSecrets(m)) + } + } + + sbr, err := sbClient.Create(ctx, scopeId, opts...) + if err != nil { + return diag.Errorf("error creating storage bucket: %v", err) + } + if sbr == nil { + return diag.Errorf("nil storage bucket after create") + } + + if err := setFromStorageBucketResponseMap(d, sbr.GetResponse().Map); err != nil { + return diag.FromErr(err) + } + + if serverHmac := d.Get(SecretsHmacKey).(string); serverHmac != "" { + configHmac, err := calculateCurrentConfigHmac(serverHmac, secretsJson) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set(internalSecretsConfigHmacKey, configHmac); err != nil { + return diag.FromErr(err) + } + if err := d.Set(internalHmacUsedForSecretsConfigHmacKey, serverHmac); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceStorageBucketRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + sbClient := storagebuckets.NewClient(md.client) + + sbrr, err := sbClient.Read(ctx, d.Id()) + if err != nil { + if apiErr := api.AsServerError(err); apiErr != nil && apiErr.Response().StatusCode() == http.StatusNotFound { + d.SetId("") + return nil + } + return diag.Errorf("error reading storage bucket: %v", err) + } + if sbrr == nil { + return diag.Errorf("storage bucket nil after read") + } + + if err := setFromStorageBucketResponseMap(d, sbrr.GetResponse().Map); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceStorageBucketUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + sbClient := storagebuckets.NewClient(md.client) + + opts := []storagebuckets.Option{} + + // We need to refresh the current server hmac value to figure out what to do + // next around secrets handling + var clearStateSecrets, sendSecretsToBoundary bool + var secretsJson string + var currentDiagnostics diag.Diagnostics + { + sbrr, err := sbClient.Read(ctx, d.Id()) + if err != nil { + if apiErr := api.AsServerError(err); apiErr != nil && apiErr.Response().StatusCode() == http.StatusNotFound { + d.SetId("") + return nil + } + return diag.Errorf("error reading storage bucket in update: %v", err) + } + if sbrr == nil { + return diag.Errorf("storage bucket nil after read in update") + } + var serverSecretsHmac string + if secretsHmacRaw, ok := sbrr.GetResponse().Map[SecretsHmacKey]; ok { + serverSecretsHmac = secretsHmacRaw.(string) + } + // Get current secrets_json value + secretsJson, err = parseutil.ParsePath(d.Get(SecretsJsonKey).(string)) + if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) { + return diag.Errorf("error parsing path with secrets: %v", err) + } + // Now that we have the value from the server, see if anything needs to be + // done + var diagWarning *diag.Diagnostic + clearStateSecrets, sendSecretsToBoundary, diagWarning, err = calculateConfigHmacPlan(serverSecretsHmac, secretsJson, d) + if err != nil { + return diag.FromErr(err) + } + if diagWarning != nil { + currentDiagnostics = append(currentDiagnostics, *diagWarning) + } + } + + if d.HasChange(NameKey) { + opts = append(opts, storagebuckets.DefaultName()) + nameVal, ok := d.GetOk(NameKey) + if ok { + nameStr := nameVal.(string) + opts = append(opts, storagebuckets.WithName(nameStr)) + } + } + + if d.HasChange(DescriptionKey) { + opts = append(opts, storagebuckets.DefaultDescription()) + descVal, ok := d.GetOk(DescriptionKey) + if ok { + descStr := descVal.(string) + opts = append(opts, storagebuckets.WithDescription(descStr)) + } + } + + if d.HasChange(storageBucketNameKey) { + opts = append(opts, storagebuckets.DefaultBucketName()) + storageBucketNameVal, ok := d.GetOk(storageBucketNameKey) + if ok { + storageBucketNameStr := storageBucketNameVal.(string) + opts = append(opts, storagebuckets.WithBucketName(storageBucketNameStr)) + } + } + + if d.HasChange(storageBucketPrefixKey) { + opts = append(opts, storagebuckets.DefaultBucketPrefix()) + storageBucketPrefixVal, ok := d.GetOk(storageBucketPrefixKey) + if ok { + storageBucketPrefixStr := storageBucketPrefixVal.(string) + opts = append(opts, storagebuckets.WithBucketPrefix(storageBucketPrefixStr)) + } + } + + if d.HasChange(WorkerFilterKey) { + opts = append(opts, storagebuckets.DefaultBucketName()) + workerFilterVal, ok := d.GetOk(WorkerFilterKey) + if ok { + workerFilterStr := workerFilterVal.(string) + opts = append(opts, storagebuckets.WithWorkerFilter(workerFilterStr)) + } + } + + if d.HasChange(AttributesJsonKey) { + attrsVal, ok := d.GetOk(AttributesJsonKey) + if ok { + attrsStr, err := parseutil.ParsePath(attrsVal.(string)) + if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) { + return append(currentDiagnostics, diag.Errorf("error parsing path with attributes: %v", err)...) + } + switch attrsStr { + case "null", "": + opts = append(opts, storagebuckets.DefaultAttributes()) + default: + // What comes in is json-encoded but we want to set a + // map[string]interface{} so we unmarshal it and set that + var m map[string]interface{} + if err := json.Unmarshal([]byte(attrsStr), &m); err != nil { + return append(currentDiagnostics, diag.Errorf("error unmarshaling attributes: %v", err)...) + } + opts = append(opts, storagebuckets.WithAttributes(m)) + } + } else { + opts = append(opts, storagebuckets.DefaultAttributes()) + } + } + + if sendSecretsToBoundary { + switch secretsJson { + case "null": + opts = append(opts, storagebuckets.DefaultSecrets()) + default: + // What comes in is json-encoded but we want to set a + // map[string]interface{} so we unmarshal it and set that + var m map[string]interface{} + if err := json.Unmarshal([]byte(secretsJson), &m); err != nil { + return append(currentDiagnostics, diag.Errorf("error unmarshaling secrets: %v", err)...) + } + opts = append(opts, storagebuckets.WithSecrets(m)) + } + } + + if len(opts) > 0 { + opts = append(opts, storagebuckets.WithAutomaticVersioning(true)) + sbur, err := sbClient.Update(ctx, d.Id(), 0, opts...) + if err != nil { + return append(currentDiagnostics, diag.Errorf("error updating storage bucket: %v", err)...) + } + if sbur == nil { + return append(currentDiagnostics, diag.Errorf("storage bucket nil after update")...) + } + + if err := setFromStorageBucketResponseMap(d, sbur.GetResponse().Map); err != nil { + return append(currentDiagnostics, diag.FromErr(err)...) + } + } + + // Save any updated secrets information if needed + switch { + case clearStateSecrets: + if err := d.Set(internalSecretsConfigHmacKey, nil); err != nil { + return append(currentDiagnostics, diag.FromErr(err)...) + } + if err := d.Set(internalHmacUsedForSecretsConfigHmacKey, nil); err != nil { + return append(currentDiagnostics, diag.FromErr(err)...) + } + + case sendSecretsToBoundary: + if serverHmac := d.Get(SecretsHmacKey).(string); serverHmac != "" { + configHmac, err := calculateCurrentConfigHmac(serverHmac, secretsJson) + if err != nil { + return append(currentDiagnostics, diag.FromErr(err)...) + } + if err := d.Set(internalSecretsConfigHmacKey, configHmac); err != nil { + return append(currentDiagnostics, diag.FromErr(err)...) + } + if err := d.Set(internalHmacUsedForSecretsConfigHmacKey, serverHmac); err != nil { + return append(currentDiagnostics, diag.FromErr(err)...) + } + } + } + + return nil +} + +func resourceStorageBucketDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + sbClient := storagebuckets.NewClient(md.client) + + _, err := sbClient.Delete(ctx, d.Id()) + if err != nil { + return diag.Errorf("error deleting storage bucket: %v", err) + } + + return nil +} diff --git a/internal/provider/resource_storage_bucket_test.go b/internal/provider/resource_storage_bucket_test.go new file mode 100644 index 00000000..e0a1c7c0 --- /dev/null +++ b/internal/provider/resource_storage_bucket_test.go @@ -0,0 +1,369 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/storagebuckets" + "github.com/hashicorp/boundary/testing/controller" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +const ( + testStorageBucketDescription = "bar" + testStorageBucketDescriptionUpdate = "bar foo" + testStorageBucketDescriptionUpdate2 = "bar foo foo" +) + +var projStorageBucketBase = ` +resource "boundary_storage_bucket" "foo" { + name = "foo" + scope_id = boundary_scope.proj1.id + plugin_name = "loopback" + bucket_name = "testbucket123" + worker_filter = "\"pki\" in \"/tags/type\"" +%s + depends_on = [boundary_role.proj1_admin] +}` + +var ( + currentStorageBucketSecretsHmacValue string + currentStorageBucketAttributesValue string +) + +func TestAccStorageBucket(t *testing.T) { + t.Skip("Skipping test until Boundary Terraform Provider can unit tests for Boundary Enterprise only features") + + tc := controller.NewTestController(t, tcConfig...) + defer tc.Shutdown() + url := tc.ApiAddrs()[0] + + resName := "boundary_storage_bucket.foo" + workerFilter := "\"pki\" in \"/tags/type\"" + initialValuesStr := fmt.Sprintf(` + description = "%s" + attributes_json = jsonencode({ + foo = "bar" + zip = "zap" + }) + workerFilter = "%s" + secrets_json = jsonencode({ + hush = "puppies" + }) + `, + testStorageBucketDescription, + workerFilter, + ) + + // Changed description and secrets + valuesStrUpdate1 := fmt.Sprintf(` + description = "%s" + attributes_json = jsonencode({ + foo = "bar" + zip = "zoop" + }) + workerFilter = "%s" + secrets_json = jsonencode({ + flush = "fluppies" + }) + `, + testStorageBucketDescriptionUpdate, + workerFilter, + ) + + // Changed description, no secrets update + valuesStrUpdate2 := fmt.Sprintf(` + description = "%s" + attributes_json = jsonencode({ + foo = "bar" + zip = "zoop" + }) + workerFilter = "%s" + secrets_json = jsonencode({ + flush = "fluppies" + }) + `, + testStorageBucketDescriptionUpdate2, + workerFilter, + ) + + // Same description, now explicitly unset secrets and blankify attrs + valuesStrUpdate3 := fmt.Sprintf(` + description = "%s" + secrets_json = jsonencode({ + flush = "fluppies" + }) + `, + testStorageBucketDescriptionUpdate2, + ) + + // Set values again + valuesStrUpdate4 := fmt.Sprintf(` + description = "%s" + attributes_json = jsonencode({ + foo = "bar" + zip = "zoop" + }) + secrets_json = jsonencode({ + flush = "fluppies" + }) + `, + testStorageBucketDescriptionUpdate2, + ) + + // Explicitly set both secrets and attributes to null + valuesStrUpdate5 := fmt.Sprintf(` + description = "%s" + attributes_json = "null" + secrets_json = "null" + `, + testStorageBucketDescriptionUpdate2, + ) + + initialHcl := fmt.Sprintf(projStorageBucketBase, initialValuesStr) + update1Hcl := fmt.Sprintf(projStorageBucketBase, valuesStrUpdate1) + update2Hcl := fmt.Sprintf(projStorageBucketBase, valuesStrUpdate2) + update3Hcl := fmt.Sprintf(projStorageBucketBase, valuesStrUpdate3) + update4Hcl := fmt.Sprintf(projStorageBucketBase, valuesStrUpdate4) + update5Hcl := fmt.Sprintf(projStorageBucketBase, valuesStrUpdate5) + + var provider *schema.Provider + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories(&provider), + CheckDestroy: testAccCheckStorageBucketResourceDestroy(t, provider, "boundary_storage_bucket"), + Steps: []resource.TestStep{ + { + // test create + Config: testConfig(url, fooOrg, firstProjectFoo, initialHcl), + Check: resource.ComposeTestCheckFunc( + testAccCheckScopeResourceExists(provider, "boundary_scope.org1"), + testAccCheckScopeResourceExists(provider, "boundary_scope.proj1"), + testAccCheckStorageBucketResourceExists(provider, resName, expectedAttributesStatePreviouslyEmptyNowSet), + resource.TestCheckResourceAttr(resName, DescriptionKey, testStorageBucketDescription), + ), + ExpectNonEmptyPlan: true, + }, + importStep(resName, SecretsJsonKey, internalHmacUsedForSecretsConfigHmacKey, internalForceUpdateKey, internalSecretsConfigHmacKey), + { + // test update + Config: testConfig(url, fooOrg, firstProjectFoo, update1Hcl), + Check: resource.ComposeTestCheckFunc( + testAccCheckStorageBucketResourceExists(provider, resName, expectedAttributesStatePreviouslySetButChanged), + resource.TestCheckResourceAttr(resName, DescriptionKey, testStorageBucketDescriptionUpdate), + ), + ExpectNonEmptyPlan: true, + }, + importStep(resName, SecretsJsonKey, internalHmacUsedForSecretsConfigHmacKey, internalForceUpdateKey, internalSecretsConfigHmacKey), + { + // test update + Config: testConfig(url, fooOrg, firstProjectFoo, update2Hcl), + Check: resource.ComposeTestCheckFunc( + testAccCheckStorageBucketResourceExists(provider, resName, expectedAttributesStatePreviouslySetNoChange), + resource.TestCheckResourceAttr(resName, DescriptionKey, testStorageBucketDescriptionUpdate2), + ), + ExpectNonEmptyPlan: true, + }, + importStep(resName, SecretsJsonKey, internalHmacUsedForSecretsConfigHmacKey, internalForceUpdateKey, internalSecretsConfigHmacKey), + { + // this runs the same HCL; mostly used in some manual checking + // to ensure update is still called even when nothing has + // changed (as we need for secrets) + Config: testConfig(url, fooOrg, firstProjectFoo, update2Hcl), + Check: resource.ComposeTestCheckFunc( + testAccCheckStorageBucketResourceExists(provider, resName, expectedAttributesStatePreviouslySetNoChange), + resource.TestCheckResourceAttr(resName, DescriptionKey, testStorageBucketDescriptionUpdate2), + ), + ExpectNonEmptyPlan: true, + }, + importStep(resName, SecretsJsonKey, internalHmacUsedForSecretsConfigHmacKey, internalForceUpdateKey, internalSecretsConfigHmacKey), + { + // test update + Config: testConfig(url, fooOrg, firstProjectFoo, update3Hcl), + Check: resource.ComposeTestCheckFunc( + testAccCheckStorageBucketResourceExists(provider, resName, expectedAttributesStatePreviouslySetNoChange), + resource.TestCheckResourceAttr(resName, DescriptionKey, testStorageBucketDescriptionUpdate2), + ), + ExpectNonEmptyPlan: true, + }, + importStep(resName, SecretsJsonKey, internalHmacUsedForSecretsConfigHmacKey, internalForceUpdateKey, internalSecretsConfigHmacKey), + { + // test update + Config: testConfig(url, fooOrg, firstProjectFoo, update4Hcl), + Check: resource.ComposeTestCheckFunc( + testAccCheckStorageBucketResourceExists(provider, resName, expectedAttributesStatePreviouslySetNowEmpty), + resource.TestCheckResourceAttr(resName, DescriptionKey, testStorageBucketDescriptionUpdate2), + ), + ExpectNonEmptyPlan: true, + }, + importStep(resName, SecretsJsonKey, internalHmacUsedForSecretsConfigHmacKey, internalForceUpdateKey, internalSecretsConfigHmacKey), + { + // test update + Config: testConfig(url, fooOrg, firstProjectFoo, update5Hcl), + Check: resource.ComposeTestCheckFunc( + testAccCheckStorageBucketResourceExists(provider, resName, expectedAttributesStatePreviouslyEmptyNowSet), + resource.TestCheckResourceAttr(resName, DescriptionKey, testStorageBucketDescriptionUpdate2), + ), + ExpectNonEmptyPlan: true, + }, + importStep(resName, SecretsJsonKey, internalHmacUsedForSecretsConfigHmacKey, internalForceUpdateKey, internalSecretsConfigHmacKey), + }, + }) +} + +func testAccCheckStorageBucketResourceExists(testProvider *schema.Provider, name string, expAttrState expectedAttributesState) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("Not found: %s", name) + } + + id := rs.Primary.ID + if id == "" { + return fmt.Errorf("No ID is set") + } + + md := testProvider.Meta().(*metaData) + sbClient := storagebuckets.NewClient(md.client) + + val, err := sbClient.Read(context.Background(), id) + if err != nil { + return fmt.Errorf("Got an error when reading storage bucket %q: %v", id, err) + } + if val == nil { + return errors.New("empty val returned") + } + secretsHmac := val.GetResponse().Map[SecretsHmacKey] + attrs := val.GetResponse().Map["attributes"] + switch expAttrState { + case expectedAttributesStatePreviouslyEmptyNowSet: + if currentStorageBucketSecretsHmacValue != "" { + return errors.New("expected no previous secrets hmac value") + } + val := secretsHmac.(string) + if val == "" { + return errors.New("expected non-empty new secrets hmac value") + } + currentStorageBucketSecretsHmacValue = val + if currentStorageBucketAttributesValue != "" { + return fmt.Errorf("expected no previous attributes value, got %s", currentStorageBucketAttributesValue) + } + if attrs == nil { + return errors.New("expected non-empty new attributes value") + } + attrsVal, err := json.Marshal(attrs) + if err != nil { + return fmt.Errorf("error marshaling attrs: %w", err) + } + currentStorageBucketAttributesValue = string(attrsVal) + + case expectedAttributesStatePreviouslySetButChanged: + if currentStorageBucketSecretsHmacValue == "" { + return errors.New("expected previous secrets hmac value") + } + val := secretsHmac.(string) + if val == "" { + return errors.New("expected non-empty new secrets hmac value") + } + if val == currentStorageBucketSecretsHmacValue { + return errors.New("expected changed secrets hmac value") + } + currentStorageBucketSecretsHmacValue = val + if currentStorageBucketAttributesValue == "" { + return errors.New("expected previous attributes value") + } + if attrs == nil { + return errors.New("expected non-empty new attributes value") + } + attrsVal, err := json.Marshal(attrs) + if err != nil { + return fmt.Errorf("error marshaling attrs: %w", err) + } + if string(attrsVal) == currentStorageBucketAttributesValue { + return errors.New("expected changed attrs value") + } + currentStorageBucketAttributesValue = string(attrsVal) + + case expectedAttributesStatePreviouslySetNoChange: + if currentStorageBucketSecretsHmacValue == "" { + return errors.New("expected previous secrets hmac value") + } + val := secretsHmac.(string) + if val == "" { + return errors.New("expected non-empty new secrets hmac value") + } + if val != currentStorageBucketSecretsHmacValue { + return errors.New("expected same secrets hmac value") + } + if currentStorageBucketAttributesValue == "" { + return errors.New("expected previous attributes value") + } + if attrs == nil { + return errors.New("expected non-empty new attributes value") + } + attrsVal, err := json.Marshal(attrs) + if err != nil { + return fmt.Errorf("error marshaling attrs: %w", err) + } + if string(attrsVal) == "" { + return errors.New("expected non-empty new attributes value") + } + if string(attrsVal) != currentStorageBucketAttributesValue { + return errors.New("expected same attributes value") + } + + case expectedAttributesStatePreviouslySetNowEmpty: + if currentStorageBucketSecretsHmacValue == "" { + return errors.New("expected previous secrets hmac value") + } + if secretsHmac != nil { + return fmt.Errorf("expected empty new secrets hmac value, got %s", secretsHmac.(string)) + } + currentStorageBucketSecretsHmacValue = "" + if currentStorageBucketAttributesValue == "" { + return errors.New("expected previous attributes value") + } + if attrs != nil { + return fmt.Errorf("expected empty new attributes value, got %s", attrs) + } + currentStorageBucketAttributesValue = "" + } + return nil + } +} + +func testAccCheckStorageBucketResourceDestroy(t *testing.T, testProvider *schema.Provider, typ string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if testProvider.Meta() == nil { + t.Fatal("got nil provider metadata") + } + md := testProvider.Meta().(*metaData) + + for _, rs := range s.RootModule().Resources { + switch rs.Type { + case string(typ): + + id := rs.Primary.ID + sbClient := storagebuckets.NewClient(md.client) + + _, err := sbClient.Read(context.Background(), id) + if apiErr := api.AsServerError(err); apiErr == nil || apiErr.Response().StatusCode() != http.StatusNotFound { + return fmt.Errorf("didn't get a 404 when reading destroyed storage bucket %q: %v", id, err) + } + + default: + continue + } + } + return nil + } +}