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
This commit is contained in:
Elim Tsiagbey authored and GitHub committed 2023-06-28 10:38:22 -04:00
1 parent 1c05eab410
commit 9186304d5d
8 files changed
+1029

No files matched your search

+5
View File
@@ -4,6 +4,11 @@ Canonical reference for changes, improvements, and bugfixes for the Boundary Ter
## Next ## 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) ## 1.1.8 (June 13, 2023)
### New and Improved ### New and Improved
+75
View File
@@ -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 generated by tfplugindocs -->
## 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 <my-id>
```
@@ -0,0 +1 @@
terraform import boundary_storage_bucket.foo <my-id>
@@ -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\""
}
+2
View File
@@ -44,4 +44,6 @@ const (
// internalForceUpdateKey is used to force updates so we can always check // internalForceUpdateKey is used to force updates so we can always check
// the value of secrets // the value of secrets
internalForceUpdateKey = "internal_force_update" internalForceUpdateKey = "internal_force_update"
// workerFilter is used for common "worker_filter" resource attribute
WorkerFilterKey = "worker_filter"
) )
+1
View File
@@ -122,6 +122,7 @@ func New() *schema.Provider {
"boundary_host_set_plugin": resourceHostSetPlugin(), "boundary_host_set_plugin": resourceHostSetPlugin(),
"boundary_role": resourceRole(), "boundary_role": resourceRole(),
"boundary_scope": resourceScope(), "boundary_scope": resourceScope(),
"boundary_storage_bucket": resourceStorageBucket(),
"boundary_target": resourceTarget(), "boundary_target": resourceTarget(),
"boundary_user": resourceUser(), "boundary_user": resourceUser(),
"boundary_worker": resourceWorker(), "boundary_worker": resourceWorker(),
@@ -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
}
@@ -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
}
}