diff --git a/.changelog/2604.txt b/.changelog/2604.txt new file mode 100644 index 0000000000..bfb657a013 --- /dev/null +++ b/.changelog/2604.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +Adding the `kubernetes_secret_v1_data` resource to the kubernetes provider. This resource will allow users to manage kubernetes secrets +``` \ No newline at end of file diff --git a/docs/resources/secret_v1_data.md b/docs/resources/secret_v1_data.md new file mode 100644 index 0000000000..4f8d92f68f --- /dev/null +++ b/docs/resources/secret_v1_data.md @@ -0,0 +1,58 @@ +--- +subcategory: "core/v1" +page_title: "Kubernetes: kubernetes_secret_v1_data" +description: |- + This resource allows Terraform to manage the data for a Secret that already exists. +--- + +# kubernetes_secret_v1_data + +This resource allows Terraform to manage data within a pre-existing Secret. This resource uses [field management](https://kubernetes.io/docs/reference/using-api/server-side-apply/#field-management) and [server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) to manage only the data that is defined in the Terraform configuration. Existing data not specified in the configuration will be ignored. If data specified in the config is already managed by another client, it will cause a conflict which can be overridden by setting `force` to true. + + +## Schema + +### Required + +- `data` (Map of String) The data we want to add to the Secret. +- `metadata` (Block List, Min: 1, Max: 1) (see [below for nested schema](#nestedblock--metadata)) + +### Optional + +- `field_manager` (String) Set the name of the field manager for the specified labels. +- `force` (Boolean) Force overwriting data that is managed outside of Terraform. + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `metadata` + +Required: + +- `name` (String) The name of the Secret. + +Optional: + +- `namespace` (String) The namespace of the Secret. + +## Example Usage + +```terraform +resource "kubernetes_secret_v1_data" "example" { + metadata { + name = "my-secret" + } + data = { + "username" = "admin" + "password" = "s3cr3t" + } +} +``` + +## Import + +This resource does not support the `import` command. As this resource operates on Kubernetes resources that already exist, creating the resource is equivalent to importing it. + + diff --git a/kubernetes/provider.go b/kubernetes/provider.go index 6ae08a37c1..f0018379df 100644 --- a/kubernetes/provider.go +++ b/kubernetes/provider.go @@ -264,6 +264,7 @@ func Provider() *schema.Provider { "kubernetes_config_map_v1_data": resourceKubernetesConfigMapV1Data(), "kubernetes_secret": resourceKubernetesSecretV1(), "kubernetes_secret_v1": resourceKubernetesSecretV1(), + "kubernetes_secret_v1_data": resourceKubernetesSecretV1Data(), "kubernetes_pod": resourceKubernetesPodV1(), "kubernetes_pod_v1": resourceKubernetesPodV1(), "kubernetes_endpoints": resourceKubernetesEndpointsV1(), diff --git a/kubernetes/resource_kubernetes_secret_v1_data.go b/kubernetes/resource_kubernetes_secret_v1_data.go new file mode 100644 index 0000000000..75a59eb408 --- /dev/null +++ b/kubernetes/resource_kubernetes_secret_v1_data.go @@ -0,0 +1,245 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubernetes + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" +) + +func resourceKubernetesSecretV1Data() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceKubernetesSecretV1DataCreate, + ReadContext: resourceKubernetesSecretV1DataRead, + UpdateContext: resourceKubernetesSecretV1DataUpdate, + DeleteContext: resourceKubernetesSecretV1DataDelete, + + Schema: map[string]*schema.Schema{ + "metadata": { + Type: schema.TypeList, + Description: "Metadata for the kubernetes Secret.", + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "The name of the Secret.", + Required: true, + ForceNew: true, + }, + "namespace": { + Type: schema.TypeString, + Description: "The namespace of the Secret.", + Optional: true, + ForceNew: true, + Default: "default", + }, + }, + }, + }, + "data": { + Type: schema.TypeMap, + Description: "Data to be stored in the Kubernetes Secret.", + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "force": { + Type: schema.TypeBool, + Description: "Flag to force updates to the Kubernetes Secret.", + Optional: true, + }, + "field_manager": { + Type: schema.TypeString, + Description: "Set the name of the field manager for the specified labels", + Optional: true, + Default: defaultFieldManagerName, + }, + }, + } +} + +func resourceKubernetesSecretV1DataCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + metadata := expandMetadata(d.Get("metadata").([]any)) + // Sets the resource id based on the metadata + d.SetId(buildId(metadata)) + + //Calling the update function ensuring resource config is correct + diag := resourceKubernetesSecretV1DataUpdate(ctx, d, m) + if diag.HasError() { + d.SetId("") + } + return diag +} + +// Retrieves the current state of the k8s secret, and update the current sate +func resourceKubernetesSecretV1DataRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + conn, err := m.(KubeClientsets).MainClientset() + if err != nil { + return diag.FromErr(err) + } + + namespace, name, err := idParts(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + // getting the secret data + res, err := conn.CoreV1().Secrets(namespace).Get(ctx, name, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return diag.Diagnostics{{ + Severity: diag.Warning, + Summary: "Secret deleted", + Detail: fmt.Sprintf("The underlying secret %q has been deleted. You should recreate the underlying secret, or remove it from your configuration.", name), + }} + } + return diag.FromErr(err) + } + + configuredData := d.Get("data").(map[string]any) + + // stripping out the data not managed by Terraform + fieldManagerName := d.Get("field_manager").(string) + + managedSecretData, err := getManagedSecretData(res.GetManagedFields(), fieldManagerName) + if err != nil { + return diag.FromErr(err) + } + data := res.Data + for k := range data { + _, managed := managedSecretData["f:"+k] + _, configured := configuredData[k] + if !managed && !configured { + delete(data, k) + } + + } + decodedData := make(map[string]string, len(data)) + for k, v := range data { + decodedData[k] = string(v) + } + + d.Set("data", decodedData) + + return nil +} + +// getManagedSecretData reads the field manager metadata to discover which fields we're managing +func getManagedSecretData(managedFields []v1.ManagedFieldsEntry, manager string) (map[string]interface{}, error) { + var data map[string]any + for _, m := range managedFields { + // Only consider entries managed by the specified manager + if m.Manager != manager { + continue + } + var mm map[string]any + err := json.Unmarshal(m.FieldsV1.Raw, &mm) + if err != nil { + return nil, err + } + // Check if the "data" field exists and extract it + if l, ok := mm["f:data"].(map[string]any); ok { + data = l + } + } + return data, nil +} + +func resourceKubernetesSecretV1DataUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + conn, err := m.(KubeClientsets).MainClientset() + if err != nil { + return diag.FromErr(err) + } + + metadata := expandMetadata(d.Get("metadata").([]any)) + name := metadata.GetName() + namespace := metadata.GetNamespace() + + _, err = conn.CoreV1().Secrets(namespace).Get(ctx, name, v1.GetOptions{}) + if err != nil { + if d.Id() == "" { + // If we are deleting then there is nothing to do if the resource is gone + return nil + } + if statusErr, ok := err.(*errors.StatusError); ok && errors.IsNotFound(statusErr) { + return diag.Errorf("The Secret %q does not exist", name) + } + return diag.Errorf("Have got the following error while validating the existence of the Secret %q: %v", name, err) + } + + // Craft the patch to update the data + data := d.Get("data").(map[string]any) + if d.Id() == "" { + // If we're deleting then we just patch with an empty data map + data = map[string]interface{}{} + } + + encodedData := make(map[string][]byte, len(data)) + for k, v := range data { + encodedData[k] = []byte(v.(string)) + } + + patchobj := map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + }, + "data": encodedData, + } + patch := unstructured.Unstructured{} + patch.Object = patchobj + patchbytes, err := patch.MarshalJSON() + if err != nil { + return diag.FromErr(err) + } + + // Apply the patch + _, err = conn.CoreV1().Secrets(namespace).Patch(ctx, + name, + types.ApplyPatchType, + patchbytes, + v1.PatchOptions{ + FieldManager: d.Get("field_manager").(string), + Force: ptr.To(d.Get("force").(bool)), + }, + ) + if err != nil { + if errors.IsConflict(err) { + return diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Field manager conflict", + Detail: fmt.Sprintf("Another client is managing a field Terraform tried to update. Set 'force' to true to override: %v", err), + }} + } + return diag.FromErr(err) + } + + if d.Id() == "" { + return nil + } + + return resourceKubernetesSecretV1DataRead(ctx, d, m) +} + +func resourceKubernetesSecretV1DataDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + // sets resource id to an empty. Simulating the deletion. + d.SetId("") + // Now we are calling the update function, to update the resource state + return resourceKubernetesSecretV1DataUpdate(ctx, d, m) +} diff --git a/kubernetes/resource_kubernetes_secret_v1_data_test.go b/kubernetes/resource_kubernetes_secret_v1_data_test.go new file mode 100644 index 0000000000..a5c50afc63 --- /dev/null +++ b/kubernetes/resource_kubernetes_secret_v1_data_test.go @@ -0,0 +1,246 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package kubernetes + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Handling the case for a empty secret +func TestAccKubernetesSecretV1Data_empty(t *testing.T) { + name := fmt.Sprintf("tf-acc-test-%s", acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)) + namespace := "default" + resourceName := "kubernetes_secret_v1_data.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + createEmptySecret(name, namespace) + }, + IDRefreshName: resourceName, + IDRefreshIgnore: []string{"metadata.0.resource_version"}, + ProviderFactories: testAccProviderFactories, + CheckDestroy: func(s *terraform.State) error { + return destroySecret(name, namespace) + }, + Steps: []resource.TestStep{ + { + Config: testAccKubernetesSecretV1Data_empty(name), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "metadata.0.name", name), + resource.TestCheckResourceAttr(resourceName, "data.%", "0"), + resource.TestCheckResourceAttr(resourceName, "field_manager", "tftest"), + ), + }, + }, + }) +} + +func createEmptySecret(name, namespace string) error { + conn, err := testAccProvider.Meta().(KubeClientsets).MainClientset() + if err != nil { + return err + } + ctx := context.Background() + + secret := v1.Secret{} + secret.SetName(name) + secret.SetNamespace(namespace) + secret.Data = map[string][]byte{} + _, err = conn.CoreV1().Secrets(namespace).Create(ctx, &secret, metav1.CreateOptions{ + FieldManager: "tftest", + }) + return err +} + +// Handling the case of secret creation with basic data +func TestAccKubernetesSecretV1Data_basic_data(t *testing.T) { + name := fmt.Sprintf("tf-acc-test-%s", acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)) + namespace := "default" + resourceName := "kubernetes_secret_v1_data.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + createSecretWithData(name, namespace) + }, + IDRefreshName: resourceName, + IDRefreshIgnore: []string{"metadata.0.resource_version"}, + ProviderFactories: testAccProviderFactories, + CheckDestroy: func(s *terraform.State) error { + return destroySecret(name, namespace) + }, + Steps: []resource.TestStep{ + { + Config: testAccKubernetesSecretV1Data_basic(name), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "metadata.0.name", name), + resource.TestCheckResourceAttr(resourceName, "data.%", "2"), + resource.TestCheckResourceAttr(resourceName, "data.key1", "value1"), + resource.TestCheckResourceAttr(resourceName, "data.key2", "value2"), + resource.TestCheckResourceAttr(resourceName, "field_manager", "tftest"), + ), + }, + }, + }) +} + +func createSecretWithData(name, namespace string) error { + conn, err := testAccProvider.Meta().(KubeClientsets).MainClientset() + if err != nil { + return err + } + ctx := context.Background() + + data := map[string][]byte{ + "key1": []byte("value1"), + "key2": []byte("value2"), + } + + secret := v1.Secret{} + secret.SetName(name) + secret.SetNamespace(namespace) + secret.Data = data + _, err = conn.CoreV1().Secrets(namespace).Create(ctx, &secret, metav1.CreateOptions{ + FieldManager: "tftest", + }) + return err +} + +// Handling the case for a modified secret +func TestAccKubernetesSecretV1Data_modified(t *testing.T) { + name := fmt.Sprintf("tf-acc-test-%s", acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)) + namespace := "default" + resourceName := "kubernetes_secret_v1_data.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + createModifiedSecret(name, namespace) + }, + IDRefreshName: resourceName, + IDRefreshIgnore: []string{"metadata.0.resource_version"}, + ProviderFactories: testAccProviderFactories, + CheckDestroy: func(s *terraform.State) error { + return destroySecret(name, namespace) + }, + Steps: []resource.TestStep{ + { + Config: testAccKubernetesSecretV1Data_modified(name), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "metadata.0.name", name), + resource.TestCheckResourceAttr(resourceName, "data.%", "2"), + resource.TestCheckResourceAttr(resourceName, "data.key1", "one"), + resource.TestCheckResourceAttr(resourceName, "data.key3", "three"), + resource.TestCheckResourceAttr(resourceName, "field_manager", "tftest"), + ), + }, + }, + }) +} + +func createModifiedSecret(name, namespace string) error { + conn, err := testAccProvider.Meta().(KubeClientsets).MainClientset() + if err != nil { + return err + } + ctx := context.Background() + + data := map[string][]byte{ + "key1": []byte("one"), + "key3": []byte("three"), + } + + secret := v1.Secret{} + secret.SetName(name) + secret.SetNamespace(namespace) + secret.Data = data + _, err = conn.CoreV1().Secrets(namespace).Create(ctx, &secret, metav1.CreateOptions{ + FieldManager: "tftest", + }) + return err +} + +// deletes a kubernetes secret +func destroySecret(name, namespace string) error { + conn, err := testAccProvider.Meta().(KubeClientsets).MainClientset() + if err != nil { + return err + } + ctx := context.Background() + err = conn.CoreV1().Secrets(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + return err +} + +// Handling the case where it attempts to read a secret that doesnt exist in the cluster +func TestAcctKubernetesSecretV1Data_validation(t *testing.T) { + name := fmt.Sprintf("tf-acc-test-%s", acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)) + resourceName := "kubernetes_secret_v1_data.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + IDRefreshName: resourceName, + IDRefreshIgnore: []string{"metadata.0.resource_version"}, + ProviderFactories: testAccProviderFactories, + Steps: []resource.TestStep{ + { + // Testing a non-existing secret + Config: testAccKubernetesSecretV1Data_empty(name), + ExpectError: regexp.MustCompile("The Secret .* does not exist"), + }, + }, + }) +} + +// Generate config for creating a secret with empty data +func testAccKubernetesSecretV1Data_empty(name string) string { + return fmt.Sprintf(`resource "kubernetes_secret_v1_data" "test" { + metadata { + name = %q + } + data = {} + field_manager = "tftest" + +} +`, name) +} + +// Generate some basic config, with a secret with basic data +func testAccKubernetesSecretV1Data_basic(name string) string { + return fmt.Sprintf(` +resource "kubernetes_secret_v1_data" "test" { + metadata { + name = %q + } + data = { + "key1" = "value1" + "key2" = "value2" + } + field_manager = "tftest" +} +`, name) +} + +func testAccKubernetesSecretV1Data_modified(name string) string { + return fmt.Sprintf(` +resource "kubernetes_secret_v1_data" "test" { + metadata { + name = %q + } + data = { + "key1" = "one" + "key3" = "three" + } + field_manager = "tftest" +} +`, name) +}