diff --git a/internal/provider/resource_definition_resource.go b/internal/provider/resource_definition_resource.go index 678e616..41cffab 100644 --- a/internal/provider/resource_definition_resource.go +++ b/internal/provider/resource_definition_resource.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strings" "time" "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" @@ -256,18 +255,117 @@ func parseResourceDefinitionResponse(ctx context.Context, driverInputSchema map[ } if data.DriverInputs != nil { - if driverInputs.SecretRefs == nil { - data.DriverInputs.SecretRefs = types.StringNull() + diags.Append(parseResourceDefinitionSecretRefResponse(driverInputs.SecretRefs, data)...) + } + return diags +} + +func parseResourceDefinitionSecretRefResponse(secretRefs *map[string]interface{}, data *DefinitionResourceModel) diag.Diagnostics { + var diags diag.Diagnostics + + if secretRefs == nil { + data.DriverInputs.SecretRefs = types.StringNull() + return diags + } + + existingStateSecretRefs := map[string]interface{}{} + + // unmarshal existing secret_refs + existingRefs := data.DriverInputs.SecretRefs.ValueString() + if existingRefs != "" { + if err := json.Unmarshal([]byte(existingRefs), &existingStateSecretRefs); err != nil { + diags.AddError(HUM_API_ERR, fmt.Sprintf("Failed to unmarshal existing secret_refs: %s, \"%s\"", err.Error(), existingRefs)) + return diags + } + } + + apiSecretRefs := *secretRefs + diags.Append(mergeResourceDefinitionSecretRefResponse(existingStateSecretRefs, apiSecretRefs)...) + if diags.HasError() { + return diags + } + + b, err := json.Marshal(apiSecretRefs) + if err != nil { + diags.AddError(HUM_API_ERR, fmt.Sprintf("Failed to marshal secret_refs: %s", err.Error())) + } + data.DriverInputs.SecretRefs = types.StringValue(string(b)) + + return diags +} + +type ResourceDefinitionSecretReference struct { + Store string `json:"store"` + Ref string `json:"ref"` + Version string `json:"version"` + Value string `json:"value"` +} + +func isResourceDefinitionSecretReference(data any) bool { + secretRefMapJson, err := json.Marshal(data) + if err != nil { + return false + } + + if err := strictUnmarshal(secretRefMapJson, &ResourceDefinitionSecretReference{}); err != nil { + return false + } + return true +} + +// mergeResourceDefinitionSecretRefResponse merges the existing state secret_refs with the new secret_refs. +func mergeResourceDefinitionSecretRefResponse(existingStateSecretRefs, apiSecretRefs map[string]interface{}) diag.Diagnostics { + return updateResourceDefinitionSecretRefResponse([]string{}, apiSecretRefs, existingStateSecretRefs) +} + +func updateResourceDefinitionSecretRefResponse(path []string, apiSecretRefI any, existingSecretRefI any) diag.Diagnostics { + var diags diag.Diagnostics + + switch typed := apiSecretRefI.(type) { + case map[string]interface{}: + if isResourceDefinitionSecretReference(typed) { + // value is never returned from the API, so take the value from the existing state + if existingRef, ok := existingSecretRefI.(map[string]interface{}); ok { + if val, ok := existingRef["value"]; ok { + if val == nil { + typed["value"] = val + } else { + overrideMap(typed, existingRef) + } + } + } } else { - if !strings.Contains(data.DriverInputs.SecretRefs.ValueString(), `{"value":"`) { - b, err := json.Marshal(driverInputs.SecretRefs) - if err != nil { - diags.AddError(HUM_API_ERR, fmt.Sprintf("Failed to marshal secret_refs: %s", err.Error())) + for k, v := range typed { + newPath := append(path, k) + var newExisting interface{} + if existingRef, ok := existingSecretRefI.(map[string]interface{}); ok { + newExisting = existingRef[k] } - data.DriverInputs.SecretRefs = types.StringValue(string(b)) + updateResourceDefinitionSecretRefResponse(newPath, v, newExisting) } } + case []map[string]interface{}: + for idx, v := range typed { + newPath := append(path, fmt.Sprintf("[%d]", idx)) + var newExisting interface{} + if existingRef, ok := existingSecretRefI.([]map[string]interface{}); ok { + newExisting = existingRef[idx] + } + updateResourceDefinitionSecretRefResponse(newPath, v, newExisting) + } + case []interface{}: + for idx, v := range typed { + newPath := append(path, fmt.Sprintf("[%d]", idx)) + var newExisting interface{} + if existingRef, ok := existingSecretRefI.([]interface{}); ok { + newExisting = existingRef[idx] + } + updateResourceDefinitionSecretRefResponse(newPath, v, newExisting) + } + default: + diags.AddError(HUM_API_ERR, fmt.Sprintf("Unknown secret_ref type in %s: %T", path, typed)) } + return diags } diff --git a/internal/provider/resource_definition_resource_test.go b/internal/provider/resource_definition_resource_test.go index 920eb26..4d91571 100644 --- a/internal/provider/resource_definition_resource_test.go +++ b/internal/provider/resource_definition_resource_test.go @@ -185,7 +185,9 @@ func TestAccResourceDefinition(t *testing.T) { resourceAttrNameIDValue: fmt.Sprintf("s3-test-with-secrets-%d", timestamp), resourceAttrNameUpdateKey: "driver_inputs.secret_refs", resourceAttrNameUpdateValue1: jsonString(map[string]interface{}{ - "aws_access_key_id": map[string]interface{}{"ref": "accessKeyIdPath1", "store": "external-secret-store", "version": "1"}, + "nested": map[string]interface{}{ + "aws_access_key_id": map[string]interface{}{"ref": "accessKeyIdPath1", "store": "external-secret-store", "version": "1"}, + }, "aws_secret_access_key": map[string]interface{}{"ref": "secretAccessKeyPath1", "store": "external-secret-store", "version": "1"}, }), resourceAttrName: "humanitec_resource_definition.s3_test_with_secrets", @@ -193,11 +195,34 @@ func TestAccResourceDefinition(t *testing.T) { return testAccResourceDefinitionS3taticResourceWithSecretRefs(fmt.Sprintf("s3-test-with-secrets-%d", timestamp), "accessKeyIdPath2", "secretAccessKeyPath2") }, resourceAttrNameUpdateValue2: jsonString(map[string]interface{}{ - "aws_access_key_id": map[string]interface{}{"ref": "accessKeyIdPath2", "store": "external-secret-store", "version": "1"}, + "nested": map[string]interface{}{ + "aws_access_key_id": map[string]interface{}{"ref": "accessKeyIdPath2", "store": "external-secret-store", "version": "1"}, + }, "aws_secret_access_key": map[string]interface{}{"ref": "secretAccessKeyPath2", "store": "external-secret-store", "version": "1"}, }), importStateVerifyIgnore: []string{"force_delete"}, }, + { + name: "S3 static - secret refs with null value", // "null" is injected when using a type like object({ .. value = optional(string) }) in the schema + configCreate: func() string { + return testAccResourceDefinitionS3taticResourceWithSecretRefsWithNull(fmt.Sprintf("s3-test-with-secrets-%d", timestamp), "accessKeyIdPath1", "secretAccessKeyPath1") + }, + resourceAttrNameIDValue: fmt.Sprintf("s3-test-with-secrets-%d", timestamp), + resourceAttrNameUpdateKey: "driver_inputs.secret_refs", + resourceAttrNameUpdateValue1: jsonString(map[string]interface{}{ + "aws_access_key_id": map[string]interface{}{"ref": "accessKeyIdPath1", "store": "external-secret-store", "version": "1", "value": nil}, + "aws_secret_access_key": map[string]interface{}{"ref": "secretAccessKeyPath1", "store": "external-secret-store", "version": "1", "value": nil}, + }), + resourceAttrName: "humanitec_resource_definition.s3_test_with_secrets", + configUpdate: func() string { + return testAccResourceDefinitionS3taticResourceWithSecretRefsWithNull(fmt.Sprintf("s3-test-with-secrets-%d", timestamp), "accessKeyIdPath2", "secretAccessKeyPath2") + }, + resourceAttrNameUpdateValue2: jsonString(map[string]interface{}{ + "aws_access_key_id": map[string]interface{}{"ref": "accessKeyIdPath2", "store": "external-secret-store", "version": "1", "value": nil}, + "aws_secret_access_key": map[string]interface{}{"ref": "secretAccessKeyPath2", "store": "external-secret-store", "version": "1", "value": nil}, + }), + importStateVerifyIgnore: []string{"force_delete", "driver_inputs.secret_refs"}, // refs are ignored as "value: null" is not returned from the API + }, { name: "S3 static - secret ref set values", configCreate: func() string { @@ -219,6 +244,27 @@ func TestAccResourceDefinition(t *testing.T) { }), importStateVerifyIgnore: []string{"driver_inputs.secret_refs", "force_delete"}, }, + { + name: "S3 static - secret ref nested", + configCreate: func() string { + return testAccResourceDefinitionS3taticResourceWithSecretRefValues(fmt.Sprintf("s3-test-with-secrets-%d", timestamp), "accessKeyId1", "secretAccessKey1") + }, + resourceAttrNameIDValue: fmt.Sprintf("s3-test-with-secrets-%d", timestamp), + resourceAttrNameUpdateKey: "driver_inputs.secret_refs", + resourceAttrNameUpdateValue1: jsonString(map[string]interface{}{ + "aws_access_key_id": map[string]interface{}{"value": "accessKeyId1"}, + "aws_secret_access_key": map[string]interface{}{"value": "secretAccessKey1"}, + }), + resourceAttrName: "humanitec_resource_definition.s3_test_with_secrets", + configUpdate: func() string { + return testAccResourceDefinitionS3taticResourceWithSecretRefValues(fmt.Sprintf("s3-test-with-secrets-%d", timestamp), "accessKeyId2", "secretAccessKey2") + }, + resourceAttrNameUpdateValue2: jsonString(map[string]interface{}{ + "aws_access_key_id": map[string]interface{}{"value": "accessKeyId2"}, + "aws_secret_access_key": map[string]interface{}{"value": "secretAccessKey2"}, + }), + importStateVerifyIgnore: []string{"driver_inputs.secret_refs", "force_delete"}, + }, } for _, tc := range tests { @@ -487,6 +533,38 @@ resource "humanitec_resource_definition" "s3_test_with_secrets" { type = "s3" driver_type = "humanitec/static" + driver_inputs = { + values_string = jsonencode({ + "bucket" = "test-bucket" + "region" = "us-east-1" + }) + secret_refs = jsonencode({ + "nested" : { + "aws_access_key_id" = { + "ref" = "%s" + "store" = "external-secret-store" + "version" = "1" + } + } + "aws_secret_access_key" = { + "ref" = "%s" + "store" = "external-secret-store" + "version" = "1" + } + }) + } +} +`, id, awsAccessKeyIDPath, awsSecretAccessKeyPath) +} + +func testAccResourceDefinitionS3taticResourceWithSecretRefsWithNull(id, awsAccessKeyIDPath, awsSecretAccessKeyPath string) string { + return fmt.Sprintf(` +resource "humanitec_resource_definition" "s3_test_with_secrets" { + id = "%s" + name = "s3-test-with-secrets" + type = "s3" + driver_type = "humanitec/static" + driver_inputs = { values_string = jsonencode({ "bucket" = "test-bucket" @@ -497,11 +575,13 @@ resource "humanitec_resource_definition" "s3_test_with_secrets" { "ref" = "%s" "store" = "external-secret-store" "version" = "1" + "value" = null } "aws_secret_access_key" = { "ref" = "%s" "store" = "external-secret-store" "version" = "1" + "value" = null } }) } @@ -565,3 +645,193 @@ func getDefinitionSecretPath(defID string) string { func getDefinitionSecretRef(id string, version int) string { return fmt.Sprintf("{\"aws_access_key_id\":{\"ref\":\"%s/aws_access_key_id/.value\",\"store\":\"humanitec\",\"version\":\"%d\"},\"aws_secret_access_key\":{\"ref\":\"%s/aws_secret_access_key/.value\",\"store\":\"humanitec\",\"version\":\"%d\"}}", getDefinitionSecretPath(id), version, getDefinitionSecretPath(id), version) } + +func TestMergeResourceDefinitionSecretRefResponse(t *testing.T) { + testCases := []struct { + name string + existing map[string]interface{} + new map[string]interface{} + expected map[string]interface{} + }{ + { + name: "untouched simple references", + existing: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + new: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + expected: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + }, + { + name: "retains null values", + existing: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + "value": nil, + }, + }, + new: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + expected: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + "value": nil, + }, + }, + }, + { + name: "omits other attributes when only value is defined", + existing: map[string]interface{}{ + "key": map[string]interface{}{ + "value": "a", + }, + }, + new: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + expected: map[string]interface{}{ + "key": map[string]interface{}{ + "value": "a", + }, + }, + }, + { + name: "omits other attributes when only value is defined (nested)", + existing: map[string]interface{}{ + "nested": map[string]interface{}{ + "key": map[string]interface{}{ + "value": "a", + }, + }, + }, + new: map[string]interface{}{ + "nested": map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + }, + expected: map[string]interface{}{ + "nested": map[string]interface{}{ + "key": map[string]interface{}{ + "value": "a", + }, + }, + }, + }, + { + name: "omits other attributes when only value is defined (nested list)", + existing: map[string]interface{}{ + "nested": []interface{}{ + map[string]interface{}{ + "key": map[string]interface{}{ + "value": "a", + }, + }, + }, + }, + new: map[string]interface{}{ + "nested": []interface{}{ + map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + }, + }, + expected: map[string]interface{}{ + "nested": []interface{}{ + map[string]interface{}{ + "key": map[string]interface{}{ + "value": "a", + }, + }, + }, + }, + }, + { + name: "keeps null attributes when only value is defined", + existing: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": nil, + "store": nil, + "version": nil, + "value": "a", + }, + }, + new: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + expected: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": nil, + "store": nil, + "version": nil, + "value": "a", + }, + }, + }, + { + name: "supports importing references", + existing: map[string]interface{}{}, + new: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + expected: map[string]interface{}{ + "key": map[string]interface{}{ + "ref": "path1", + "store": "store1", + "version": "1", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + diags := mergeResourceDefinitionSecretRefResponse(tc.existing, tc.new) + assert.Empty(t, diags) + assert.Equal(t, tc.expected, tc.new) + }) + } +} diff --git a/internal/provider/utils.go b/internal/provider/utils.go index 25e3e6b..a7424a4 100644 --- a/internal/provider/utils.go +++ b/internal/provider/utils.go @@ -1,7 +1,10 @@ package provider import ( + "bytes" + "encoding/json" "errors" + "maps" "os" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -112,3 +115,19 @@ func readConfig(data HumanitecProviderModel) (config Config, diags diag.Diagnost func toPtr[T any](value T) *T { return &value } + +// strictUnmarshal unmarshals the JSON data into the provided value and returns an error if the data contains unknown fields. +func strictUnmarshal(data []byte, v interface{}) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + return dec.Decode(v) +} + +// overrideMap overrides the base map with the values from the override map. +func overrideMap[M ~map[K]V, K comparable, V any](base M, override M) { + maps.DeleteFunc(base, func(K K, V V) bool { + _, ok := override[K] + return !ok + }) + maps.Copy(base, override) +} diff --git a/internal/provider/utils_test.go b/internal/provider/utils_test.go index 3225872..e0abc23 100644 --- a/internal/provider/utils_test.go +++ b/internal/provider/utils_test.go @@ -73,3 +73,56 @@ func TestReadConfigNonExistentFile(t *testing.T) { assert.Len(diags, 1) assert.Equal("Unable to read config file", diags[0].Summary()) } + +func TestStrictUnmarshal(t *testing.T) { + testCases := []struct { + name string + input string + expectErr bool + }{ + { + name: "valid", + input: `{"field": "value"}`, + expectErr: false, + }, + { + name: "additional fields", + input: `{"field": "value", "a": "b"}`, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f := struct { + Field string `json:"field"` + }{} + + err := strictUnmarshal([]byte(tc.input), &f) + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestOverrideMap(t *testing.T) { + assert := assert.New(t) + + original := map[string]interface{}{ + "key": "value", + "another key": "another value", + } + overrides := map[string]interface{}{ + "key": "new value 1", + "new key": "new value 2", + } + + overrideMap(original, overrides) + assert.Equal(map[string]interface{}{ + "key": "new value 1", + "new key": "new value 2", + }, original) +}