Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,4 @@ sa_key.json
sa_key_test.json

.streamnative_*
/PLAN.md
1 change: 1 addition & 0 deletions cloud/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func init() {
"secret_name": "The secret name",
"secret_data": "The secret data map",
"secret_string_data": "Write-only string data that will be stored encrypted by the API server",
"secret_binary_data": "Write-only base64-encoded binary data that will be stored encrypted by the API server",
"secret_type": "The Kubernetes secret type",
"availability-mode": "The availability mode, supporting 'zonal' and 'regional'",
"pool_name": "The infrastructure pool name",
Expand Down
102 changes: 99 additions & 3 deletions cloud/resource_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package cloud

import (
"context"
"encoding/base64"
"fmt"
"strings"

Expand All @@ -34,6 +35,7 @@ func resourceSecret() *schema.Resource {
ReadContext: resourceSecretRead,
UpdateContext: resourceSecretUpdate,
DeleteContext: resourceSecretDelete,
CustomizeDiff: validateSecretDataKeyUniqueness,
Importer: &schema.ResourceImporter{
StateContext: func(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) {
parts := strings.Split(d.Id(), "/")
Expand Down Expand Up @@ -93,7 +95,7 @@ func resourceSecret() *schema.Resource {
Computed: true,
Sensitive: true,
ForceNew: true,
AtLeastOneOf: []string{"data", "string_data"},
AtLeastOneOf: []string{"data", "string_data", "binary_data"},
Description: descriptions["secret_data"],
Elem: &schema.Schema{
Type: schema.TypeString,
Expand All @@ -104,12 +106,24 @@ func resourceSecret() *schema.Resource {
Optional: true,
Sensitive: true,
ForceNew: true,
AtLeastOneOf: []string{"data", "string_data"},
AtLeastOneOf: []string{"data", "string_data", "binary_data"},
Description: descriptions["secret_string_data"],
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
"binary_data": {
Type: schema.TypeMap,
Optional: true,
Sensitive: true,
ForceNew: true,
AtLeastOneOf: []string{"data", "string_data", "binary_data"},
Description: descriptions["secret_binary_data"],
Elem: &schema.Schema{
Type: schema.TypeString,
ValidateFunc: validateBase64String,
},
},
},
}
}
Expand Down Expand Up @@ -197,6 +211,74 @@ func resourceSecretDelete(ctx context.Context, d *schema.ResourceData, meta inte
return nil
}

func validateBase64String(i interface{}, k string) (warnings []string, errors []error) {
value, ok := i.(string)
if !ok {
errors = append(errors, fmt.Errorf("%q must be a string", k))
return warnings, errors
}
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
errors = append(errors, fmt.Errorf("%q must be a valid base64-encoded string: %w", k, err))
}
return warnings, errors
}

func validateSecretDataKeyUniqueness(_ context.Context, diff *schema.ResourceDiff, _ interface{}) error {
binaryDataKeys, configured := configuredSecretDataKeys(diff, "binary_data")
if !configured {
return nil
}

for _, field := range []string{"data", "string_data"} {
keys, configured := configuredSecretDataKeys(diff, field)
if !configured {
continue
}
for key := range binaryDataKeys {
if _, ok := keys[key]; ok {
return fmt.Errorf("secret data key %q is configured in both %q and %q", key, field, "binary_data")
}
}
}
return nil
}

func configuredSecretDataKeys(diff *schema.ResourceDiff, field string) (map[string]struct{}, bool) {
rawConfig := diff.GetRawConfig()
if !rawConfig.IsNull() && rawConfig.IsKnown() {
value := rawConfig.GetAttr(field)
if value.IsNull() {
return nil, false
}
if !value.IsKnown() || !value.CanIterateElements() {
return nil, false
}

keys := make(map[string]struct{})
iterator := value.ElementIterator()
for iterator.Next() {
key, _ := iterator.Element()
if !key.IsKnown() || key.IsNull() {
continue
}
keys[key.AsString()] = struct{}{}
}
return keys, true
}

// Resource.SimpleDiff does not populate RawConfig, so keep a fallback for
// legacy SDK callers and unit tests.
value, ok := diff.GetOk(field)
if !ok {
return nil, false
}
keys := make(map[string]struct{})
for key := range value.(map[string]interface{}) {
keys[key] = struct{}{}
}
return keys, true
}

func buildSecretFromResourceData(d *schema.ResourceData) *v1alpha1.Secret {
namespace := d.Get("organization").(string)
name := d.Get("name").(string)
Expand Down Expand Up @@ -254,6 +336,14 @@ func applySecretPlan(secret *v1alpha1.Secret, d *schema.ResourceData, includeUns
secret.StringData = nil
}
}

if includeUnset || d.HasChange("binary_data") {
if binaryDataRaw, ok := d.GetOk("binary_data"); ok {
secret.BinaryData = convertToStringMap(binaryDataRaw.(map[string]interface{}))
} else {
Comment on lines +340 to +343
secret.BinaryData = nil
}
}
}

func setSecretState(d *schema.ResourceData, secret *v1alpha1.Secret) diag.Diagnostics {
Expand Down Expand Up @@ -293,12 +383,18 @@ func setSecretState(d *schema.ResourceData, secret *v1alpha1.Secret) diag.Diagno
}
}

// Preserve user-supplied string_data without attempting to read it from the API server.
// Preserve user-supplied string_data and binary_data without attempting to read
// write-only fields from the API server.
if stringData, ok := d.GetOk("string_data"); ok {
if err := d.Set("string_data", stringData); err != nil {
return diag.FromErr(fmt.Errorf("ERROR_SET_STRING_DATA: %w", err))
}
}
if binaryData, ok := d.GetOk("binary_data"); ok {
if err := d.Set("binary_data", binaryData); err != nil {
return diag.FromErr(fmt.Errorf("ERROR_SET_BINARY_DATA: %w", err))
}
}

d.SetId(fmt.Sprintf("%s/%s", secret.Namespace, secret.Name))
return nil
Expand Down
166 changes: 166 additions & 0 deletions cloud/secret_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright 2024 StreamNative, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cloud

import (
"context"
"strings"
"testing"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
cloudv1alpha1 "github.com/streamnative/cloud-api-server/pkg/apis/cloud/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestSecretDataSourceDoesNotExposeBinaryData(t *testing.T) {
if _, ok := dataSourceSecret().Schema["binary_data"]; ok {
t.Fatal("data source should not expose write-only binary_data")
}
}

func TestSecretBinaryDataSchema(t *testing.T) {
binaryDataSchema := resourceSecret().Schema["binary_data"]
if binaryDataSchema == nil {
t.Fatal("binary_data schema is missing")
}
if binaryDataSchema.Type != schema.TypeMap {
t.Fatalf("binary_data schema type = %v, expected %v", binaryDataSchema.Type, schema.TypeMap)
}
if !binaryDataSchema.Optional {
t.Fatal("binary_data should be optional")
}
if !binaryDataSchema.Sensitive {
t.Fatal("binary_data should be sensitive")
}
if !binaryDataSchema.ForceNew {
t.Fatal("binary_data should force replacement")
}
if got, want := binaryDataSchema.AtLeastOneOf, []string{"data", "string_data", "binary_data"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("binary_data AtLeastOneOf = %v, expected %v", got, want)
}
}

func TestValidateBase64String(t *testing.T) {
validValues := []string{"", "Y2VydA==", "AAECAw=="}
for _, value := range validValues {
_, errs := validateBase64String(value, "binary_data.key")
if len(errs) != 0 {
t.Fatalf("validateBase64String(%q) returned errors: %v", value, errs)
}
}

_, errs := validateBase64String("not base64", "binary_data.key")
if len(errs) == 0 {
t.Fatal("validateBase64String should reject invalid base64")
}
}

func TestValidateSecretDataKeyUniqueness(t *testing.T) {
resource := resourceSecret()
config := terraform.NewResourceConfigRaw(map[string]interface{}{
"organization": "org-a",
"name": "secret-a",
"string_data": map[string]interface{}{
"shared": "plain text",
},
"binary_data": map[string]interface{}{
"shared": "YmluYXJ5",
},
})

_, err := resource.SimpleDiff(context.Background(), nil, config, nil)
if err == nil {
t.Fatal("SimpleDiff should reject duplicate binary_data payload keys")
}
if !strings.Contains(err.Error(), `secret data key "shared"`) {
t.Fatalf("unexpected duplicate-key error: %v", err)
}
}

func TestValidateSecretDataKeyUniquenessAllowsLegacyDataAndStringDataOverlap(t *testing.T) {
resource := resourceSecret()
config := terraform.NewResourceConfigRaw(map[string]interface{}{
"organization": "org-a",
"name": "secret-a",
"data": map[string]interface{}{
"shared": "ciphertext",
},
"string_data": map[string]interface{}{
"shared": "plain text",
},
})

_, err := resource.SimpleDiff(context.Background(), nil, config, nil)
if err != nil {
t.Fatalf("SimpleDiff should allow legacy data/string_data duplicate keys: %v", err)
}
}

func TestBuildSecretFromResourceDataWithBinaryData(t *testing.T) {
resourceData := resourceSecret().TestResourceData()
mustSetResourceData(t, resourceData, "organization", "org-a")
mustSetResourceData(t, resourceData, "name", "secret-a")
mustSetResourceData(t, resourceData, "binary_data", map[string]string{
"cert.p12": "YmluYXJ5LWNlcnQ=",
})

secret := buildSecretFromResourceData(resourceData)
if got := secret.BinaryData["cert.p12"]; got != "YmluYXJ5LWNlcnQ=" {
t.Fatalf("secret.BinaryData[cert.p12] = %q", got)
}
if secret.StringData != nil {
t.Fatalf("secret.StringData = %v, expected nil", secret.StringData)
}
}

func TestSetSecretStatePreservesBinaryData(t *testing.T) {
resourceData := resourceSecret().TestResourceData()
mustSetResourceData(t, resourceData, "organization", "stale-org")
mustSetResourceData(t, resourceData, "name", "stale-secret")
mustSetResourceData(t, resourceData, "binary_data", map[string]string{
"cert.p12": "YmluYXJ5LWNlcnQ=",
})

secret := &cloudv1alpha1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-a",
Name: "secret-a",
},
Data: map[string]string{
"cert.p12": "encrypted-value",
},
}

diags := setSecretState(resourceData, secret)
if diags.HasError() {
t.Fatalf("setSecretState returned diagnostics: %v", diags)
}

binaryData := resourceData.Get("binary_data").(map[string]interface{})
if got := binaryData["cert.p12"]; got != "YmluYXJ5LWNlcnQ=" {
t.Fatalf("binary_data[cert.p12] = %q", got)
}
if got := resourceData.Get("data").(map[string]interface{})["cert.p12"]; got != "encrypted-value" {
t.Fatalf("data[cert.p12] = %q", got)
}
}

func mustSetResourceData(t *testing.T, d *schema.ResourceData, key string, value interface{}) {
t.Helper()
if err := d.Set(key, value); err != nil {
t.Fatalf("set %s: %v", key, err)
}
}
45 changes: 45 additions & 0 deletions cloud/secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,28 @@ func TestSecretStringData(t *testing.T) {
})
}

func TestSecretBinaryData(t *testing.T) {
binaryData := map[string]string{
"certificate": "YmluYXJ5LWNlcnQ=",
}
secretName := randomSecretName("terraform-test-secret-binarydata")
resource.Test(t, resource.TestCase{
PreCheck: func() {
testAccPreCheck(t)
},
ProviderFactories: testAccProviderFactories,
CheckDestroy: testCheckSecretDestroy,
Steps: []resource.TestStep{
{
Config: testResourceDataSourceSecretWithBinaryData("sndev", secretName, binaryData),
Check: resource.ComposeTestCheckFunc(
testCheckSecretExistsWithEncryptedData("streamnative_secret.test-secret", binaryData),
),
},
},
})
}

func TestSecretRemovedExternally(t *testing.T) {
data := map[string]string{
"token": "removed-secret",
Expand Down Expand Up @@ -223,6 +245,29 @@ func testResourceDataSourceSecretWithStringData(organization string, name string
return testResourceDataSourceSecretWithParams(organization, name, nil, stringData, "", "")
}

func testResourceDataSourceSecretWithBinaryData(organization string, name string, binaryData map[string]string) string {
var resourceBuilder strings.Builder
resourceBuilder.WriteString(fmt.Sprintf(`resource "streamnative_secret" "test-secret" {
organization = "%s"
name = "%s"
binary_data = {
%s }
}
`, organization, name, buildHCLMap(binaryData)))

return fmt.Sprintf(`
provider "streamnative" {
}

%s
data "streamnative_secret" "test-secret" {
depends_on = [streamnative_secret.test-secret]
organization = streamnative_secret.test-secret.organization
name = streamnative_secret.test-secret.name
}
`, resourceBuilder.String())
}

func testResourceDataSourceSecretWithParams(
organization string,
name string,
Expand Down
Loading
Loading