diff --git a/docs/resources/access_secret.md b/docs/resources/access_secret.md index 8258da9d..56e9678b 100644 --- a/docs/resources/access_secret.md +++ b/docs/resources/access_secret.md @@ -17,6 +17,10 @@ A resource that represents a Juju secret access. ### Required -- `applications` (List of String) The list of applications to which the secret is granted or revoked. +- `applications` (List of String) The list of applications to which the secret is granted. - `model` (String) The model in which the secret belongs. -- `secret_id` (String) The ID of the secret. +- `secret_id` (String) The ID of the secret. E.g. coj8mulh8b41e8nv6p90 + +### Read-Only + +- `id` (String) The ID of the secret. Used for terraform import. diff --git a/docs/resources/secret.md b/docs/resources/secret.md index 35772eaf..12c01ef1 100644 --- a/docs/resources/secret.md +++ b/docs/resources/secret.md @@ -13,15 +13,24 @@ A resource that represents a Juju secret. ## Example Usage ```terraform -resource "juju_secret" "this" { +resource "juju_secret" "my-secret" { model = juju_model.development.name - name = "this_secret_name" + name = "my_secret_name" value = { key1 = "value1" key2 = "value2" } info = "This is the secret" } + +resource "juju_application" "my-application" { + # + config = { + # Reference my-secret within the plan by using the secret_id + secret = juju_secret.my-secret.secret_id + } + # +} ``` @@ -39,21 +48,14 @@ resource "juju_secret" "this" { ### Read-Only -- `secret_id` (String) The ID of the secret. +- `id` (String) The ID of the secret. Used for terraform import. +- `secret_id` (String) The ID of the secret. E.g. coj8mulh8b41e8nv6p90 ## Import Import is supported using the following syntax: ```shell -# Secrets can be imported by using the URI as in the juju show-secrets output. -# Example: -# $juju show-secret secret-name -# coh2uo2ji6m0ue9a7tj0: -# revision: 1 -# owner: -# name: secret-name -# created: 2024-04-19T08:46:25Z -# updated: 2024-04-19T08:46:25Z -$ terraform import juju_secret.secret-name coh2uo2ji6m0ue9a7tj0 +# Secrets can be imported by using the model and secret names. +$ terraform import juju_secret.secret-name testmodel:secret-name ``` diff --git a/examples/resources/juju_secret/import.sh b/examples/resources/juju_secret/import.sh index c06fe998..1abb557a 100644 --- a/examples/resources/juju_secret/import.sh +++ b/examples/resources/juju_secret/import.sh @@ -1,10 +1,2 @@ -# Secrets can be imported by using the URI as in the juju show-secrets output. -# Example: -# $juju show-secret secret-name -# coh2uo2ji6m0ue9a7tj0: -# revision: 1 -# owner: -# name: secret-name -# created: 2024-04-19T08:46:25Z -# updated: 2024-04-19T08:46:25Z -$ terraform import juju_secret.secret-name coh2uo2ji6m0ue9a7tj0 \ No newline at end of file +# Secrets can be imported by using the model and secret names. +$ terraform import juju_secret.secret-name testmodel:secret-name \ No newline at end of file diff --git a/examples/resources/juju_secret/resource.tf b/examples/resources/juju_secret/resource.tf index d1b5ad48..a8cb3c0e 100644 --- a/examples/resources/juju_secret/resource.tf +++ b/examples/resources/juju_secret/resource.tf @@ -1,9 +1,18 @@ -resource "juju_secret" "this" { +resource "juju_secret" "my-secret" { model = juju_model.development.name - name = "this_secret_name" + name = "my_secret_name" value = { key1 = "value1" key2 = "value2" } info = "This is the secret" +} + +resource "juju_application" "my-application" { + # + config = { + # Reference my-secret within the plan by using the secret_id + secret = juju_secret.my-secret.secret_id + } + # } \ No newline at end of file diff --git a/examples/resources/juju_secret_access/import.sh b/examples/resources/juju_secret_access/import.sh index 72330b2c..de66d236 100644 --- a/examples/resources/juju_secret_access/import.sh +++ b/examples/resources/juju_secret_access/import.sh @@ -1,10 +1,2 @@ -# Secret access can be imported by using the URI as in the juju show-secrets output. -# Example: -# $juju show-secret secret-name -# coh2uo2ji6m0ue9a7tj0: -# revision: 1 -# owner: -# name: secret-name -# created: 2024-04-19T08:46:25Z -# updated: 2024-04-19T08:46:25Z -$ terraform import juju_access_secret.access-secret-name coh2uo2ji6m0ue9a7tj0 \ No newline at end of file +# Secret access can be imported by using the model and secret names. +$ terraform import juju_access_secret.access-secret-name modelname:secret-name \ No newline at end of file diff --git a/examples/resources/juju_secret_access/resource.tf b/examples/resources/juju_secret_access/resource.tf index 9e05c759..06fc2d7a 100644 --- a/examples/resources/juju_secret_access/resource.tf +++ b/examples/resources/juju_secret_access/resource.tf @@ -1,7 +1,18 @@ -resource "juju_access_secret" "this" { +resource "juju_secret" "my-secret" { + model = juju_model.development.name + name = "my_secret_name" + value = { + key1 = "value1" + key2 = "value2" + } + info = "This is the secret" +} + +resource "juju_access_secret" "my-secret-access" { model = juju_model.development.name applications = [ juju_application.app.name, juju_application.app2.name ] - secret_id = juju_secret.that.secret_id + # Use the secret_id from your secret resource or data source. + secret_id = juju_secret.my-secret.secret_id } \ No newline at end of file diff --git a/internal/provider/resource_access_secret.go b/internal/provider/resource_access_secret.go index 58ee734d..045211cd 100644 --- a/internal/provider/resource_access_secret.go +++ b/internal/provider/resource_access_secret.go @@ -6,9 +6,9 @@ package provider import ( "context" "fmt" + "strings" "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -43,10 +43,58 @@ type accessSecretResourceModel struct { SecretId types.String `tfsdk:"secret_id"` // Applications is the list of applications to which the secret is granted or revoked. Applications types.List `tfsdk:"applications"` + // ID is used during terraform import. + ID types.String `tfsdk:"id"` } +// ImportState reads the secret based on the model name and secret name to be +// imported into terraform. func (s *accessSecretResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "access secret", "import") + return + } + // model:name + parts := strings.Split(req.ID, ":") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: :. Got: %q", req.ID), + ) + return + } + modelName := parts[0] + secretName := parts[1] + + readSecretOutput, err := s.client.Secrets.ReadSecret(&juju.ReadSecretInput{ + ModelName: modelName, + Name: &secretName, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read secret for import, got error: %s", err)) + return + } + + // Save the secret access details into the Terraform state + state := accessSecretResourceModel{ + Model: types.StringValue(modelName), + SecretId: types.StringValue(readSecretOutput.SecretId), + ID: types.StringValue(newSecretID(modelName, readSecretOutput.SecretId)), + } + + // Save the secret details into the Terraform state + secretApplications, errDiag := types.ListValueFrom(ctx, types.StringType, readSecretOutput.Applications) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + state.Applications = secretApplications + + // Save state into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + + s.trace(fmt.Sprintf("import access secret resource %q", state.SecretId)) } func (s *accessSecretResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -66,17 +114,24 @@ func (s *accessSecretResource) Schema(_ context.Context, req resource.SchemaRequ }, }, "secret_id": schema.StringAttribute{ - Description: "The ID of the secret.", + Description: "The ID of the secret. E.g. coj8mulh8b41e8nv6p90", Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "applications": schema.ListAttribute{ - Description: "The list of applications to which the secret is granted or revoked.", + Description: "The list of applications to which the secret is granted.", Required: true, ElementType: types.StringType, }, + "id": schema.StringAttribute{ + Description: "The ID of the secret. Used for terraform import.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, }, } } @@ -98,7 +153,7 @@ func (s *accessSecretResource) Configure(ctx context.Context, req resource.Confi } s.client = client // Create the local logging subsystem here, using the TF context when creating it. - s.subCtx = tflog.NewSubsystem(ctx, LogResourceSecret) + s.subCtx = tflog.NewSubsystem(ctx, LogResourceAccessSecret) } // Create is called when the resource is being created. @@ -131,9 +186,10 @@ func (s *accessSecretResource) Create(ctx context.Context, req resource.CreateRe } // Save plan into Terraform state + plan.ID = types.StringValue(newSecretID(plan.Model.ValueString(), plan.SecretId.ValueString())) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - s.trace(fmt.Sprintf("grant secret access to %q", plan.SecretId)) + s.trace(fmt.Sprintf("grant secret access to %s", plan.SecretId)) } // Read is called when the resource is being read. @@ -169,10 +225,12 @@ func (s *accessSecretResource) Read(ctx context.Context, req resource.ReadReques } state.Applications = secretApplications + state.ID = types.StringValue(newSecretID(state.Model.ValueString(), readSecretOutput.SecretId)) + // Save state into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) - s.trace(fmt.Sprintf("read secret access %q", state.SecretId)) + s.trace(fmt.Sprintf("read secret access %s", state.SecretId)) } // Update is called when the resource is being updated. @@ -264,7 +322,7 @@ func (s *accessSecretResource) Update(ctx context.Context, req resource.UpdateRe // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) - s.trace(fmt.Sprintf("update secret access %q", state.SecretId)) + s.trace(fmt.Sprintf("update secret access %s", state.SecretId)) } // Delete is called when the resource is being deleted. @@ -310,7 +368,7 @@ func (s *accessSecretResource) Delete(ctx context.Context, req resource.DeleteRe // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) - s.trace(fmt.Sprintf("revoke secret access %q", state.SecretId)) + s.trace(fmt.Sprintf("revoke secret access %s", state.SecretId)) } func (s *accessSecretResource) trace(msg string, additionalFields ...map[string]interface{}) { diff --git a/internal/provider/resource_access_secret_test.go b/internal/provider/resource_access_secret_test.go index 30b9bbe2..606765cb 100644 --- a/internal/provider/resource_access_secret_test.go +++ b/internal/provider/resource_access_secret_test.go @@ -4,6 +4,7 @@ package provider import ( + "fmt" "os" "testing" @@ -56,6 +57,37 @@ func TestAcc_ResourceAccessSecret_GrantRevoke(t *testing.T) { }) } +func TestAcc_ResourceAccessSecret_Import(t *testing.T) { + agentVersion := os.Getenv(TestJujuAgentVersion) + if agentVersion == "" { + t.Errorf("%s is not set", TestJujuAgentVersion) + } else if internaltesting.CompareVersions(agentVersion, "3.3.0") < 0 { + t.Skipf("%s is not set or is below 3.3.0", TestJujuAgentVersion) + } + + modelName := acctest.RandomWithPrefix("tf-test-model") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecretWithAccess(modelName, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_access_secret.test_access_secret", "model", modelName), + resource.TestCheckResourceAttr("juju_access_secret.test_access_secret", "applications.0", "jul"), + ), + }, + { + ImportStateVerify: true, + ImportState: true, + ImportStateId: fmt.Sprintf("%s:test_secret_name", modelName), + ResourceName: "juju_access_secret.test_access_secret", + }, + }, + }) +} + func testAccResourceSecretWithAccess(modelName string, allApplicationAccess bool) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceSecret", diff --git a/internal/provider/resource_secret.go b/internal/provider/resource_secret.go index 5b428623..f82d2c2d 100644 --- a/internal/provider/resource_secret.go +++ b/internal/provider/resource_secret.go @@ -6,14 +6,15 @@ package provider import ( "context" "fmt" + "strings" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/juju/terraform-provider-juju/internal/juju" ) @@ -45,10 +46,62 @@ type secretResourceModel struct { SecretId types.String `tfsdk:"secret_id"` // Info is the description of the secret. This attribute is optional for all actions. Info types.String `tfsdk:"info"` + // ID is used during terraform import. + ID types.String `tfsdk:"id"` } +// ImportState reads the secret based on the model name and secret name to be +// imported into terraform. func (s *secretResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret", "import") + return + } + + // model:name + parts := strings.Split(req.ID, ":") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: :. Got: %q", req.ID), + ) + return + } + modelName := parts[0] + secretName := parts[1] + + readSecretOutput, err := s.client.Secrets.ReadSecret(&juju.ReadSecretInput{ + ModelName: modelName, + Name: &secretName, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read secret for import, got error: %s", err)) + return + } + + // Save the secret details into the Terraform state + state := secretResourceModel{ + Model: types.StringValue(modelName), + Name: types.StringValue(readSecretOutput.Name), + SecretId: types.StringValue(readSecretOutput.SecretId), + } + + if readSecretOutput.Info != "" { + state.Info = types.StringValue(readSecretOutput.Info) + } + + secretValue, errDiag := types.MapValueFrom(ctx, types.StringType, readSecretOutput.Value) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + state.Value = secretValue + + // Save state into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + + s.trace(fmt.Sprintf("import secret resource %q", state.SecretId)) } func (s *secretResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -77,7 +130,7 @@ func (s *secretResource) Schema(_ context.Context, req resource.SchemaRequest, r Sensitive: true, }, "secret_id": schema.StringAttribute{ - Description: "The ID of the secret.", + Description: "The ID of the secret. E.g. coj8mulh8b41e8nv6p90", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), @@ -87,6 +140,13 @@ func (s *secretResource) Schema(_ context.Context, req resource.SchemaRequest, r Description: "The description of the secret.", Optional: true, }, + "id": schema.StringAttribute{ + Description: "The ID of the secret. Used for terraform import.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, }, } } @@ -144,11 +204,20 @@ func (s *secretResource) Create(ctx context.Context, req resource.CreateRequest, } plan.SecretId = types.StringValue(createSecretOutput.SecretId) - + plan.ID = types.StringValue(newSecretID(plan.Model.ValueString(), plan.SecretId.ValueString())) + s.trace(fmt.Sprintf("saving secret resource %q", plan.SecretId.ValueString()), + map[string]interface{}{ + "secretID": plan.SecretId.ValueString(), + "name": plan.Name.ValueString(), + "model": plan.Model.ValueString(), + "info": plan.Info.ValueString(), + "values": plan.Value.String(), + "id": plan.ID.ValueString(), + }) // Save plan into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - s.trace(fmt.Sprintf("created secret resource %q", plan.SecretId)) + s.trace(fmt.Sprintf("created secret resource %s", plan.SecretId)) } // Read reads the details of a secret in the Juju model. @@ -172,8 +241,6 @@ func (s *secretResource) Read(ctx context.Context, req resource.ReadRequest, res readSecretOutput, err := s.client.Secrets.ReadSecret(&juju.ReadSecretInput{ SecretId: state.SecretId.ValueString(), ModelName: state.Model.ValueString(), - Name: state.Name.ValueStringPointer(), - Revision: nil, }) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read secret, got error: %s", err)) @@ -187,6 +254,7 @@ func (s *secretResource) Read(ctx context.Context, req resource.ReadRequest, res if !state.Info.IsNull() { state.Info = types.StringValue(readSecretOutput.Info) } + state.ID = types.StringValue(newSecretID(state.Model.ValueString(), readSecretOutput.SecretId)) secretValue, errDiag := types.MapValueFrom(ctx, types.StringType, readSecretOutput.Value) resp.Diagnostics.Append(errDiag...) @@ -198,7 +266,7 @@ func (s *secretResource) Read(ctx context.Context, req resource.ReadRequest, res // Save state into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) - s.trace(fmt.Sprintf("read secret resource %q", state.SecretId)) + s.trace(fmt.Sprintf("read secret resource %s", state.SecretId)) } // Update updates the details of a secret in the Juju model. @@ -270,7 +338,7 @@ func (s *secretResource) Update(ctx context.Context, req resource.UpdateRequest, // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) - s.trace(fmt.Sprintf("updated secret resource %q", state.SecretId)) + s.trace(fmt.Sprintf("updated secret resource %s", state.SecretId)) } // Delete removes a secret from the Juju model. @@ -300,7 +368,7 @@ func (s *secretResource) Delete(ctx context.Context, req resource.DeleteRequest, return } - s.trace(fmt.Sprintf("deleted secret resource %q", state.SecretId)) + s.trace(fmt.Sprintf("deleted secret resource %s", state.SecretId)) } func (s *secretResource) trace(msg string, additionalFields ...map[string]interface{}) { @@ -309,3 +377,7 @@ func (s *secretResource) trace(msg string, additionalFields ...map[string]interf } tflog.SubsystemTrace(s.subCtx, LogResourceSecret, msg, additionalFields...) } + +func newSecretID(model, secret string) string { + return fmt.Sprintf("%s:%s", model, secret) +} diff --git a/internal/provider/resource_secret_test.go b/internal/provider/resource_secret_test.go index fd082560..856ef4a2 100644 --- a/internal/provider/resource_secret_test.go +++ b/internal/provider/resource_secret_test.go @@ -4,11 +4,13 @@ package provider import ( + "fmt" "os" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + internaltesting "github.com/juju/terraform-provider-juju/internal/testing" ) @@ -74,6 +76,12 @@ func TestAcc_ResourceSecret_CreateWithInfo(t *testing.T) { resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key2", "value2"), ), }, + { + ImportStateVerify: true, + ImportState: true, + ImportStateId: fmt.Sprintf("%s:%s", modelName, secretName), + ResourceName: "juju_secret." + secretName, + }, }, }) }