diff --git a/README.md b/README.md index 9ecffd2c..2a065c5c 100644 --- a/README.md +++ b/README.md @@ -82,4 +82,4 @@ grep "@module=juju.datasource" ./terraform.log To find logs specific to the juju client talking to juju itself: ```shell grep "@module=juju.client" ./terraform.log -``` +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 55cda702..e73e2e3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -72,6 +72,7 @@ Define the Juju controller credentials in the provider definition via environmen ```shell export CONTROLLER=$(juju whoami | yq .Controller) +export JUJU_AGENT_VERSION="$(juju show-controller | yq '.[$CONTROLLER]'.details.\"agent-version\"|tr -d '"')" export JUJU_CONTROLLER_ADDRESSES="$(juju show-controller | yq '.[$CONTROLLER]'.details.\"api-endpoints\" | tr -d "[]' "|tr -d '"'|tr -d '\n')" export JUJU_USERNAME="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.user|tr -d '"')" export JUJU_PASSWORD="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.password|tr -d '"')" diff --git a/docs/resources/secret.md b/docs/resources/secret.md new file mode 100644 index 00000000..35772eaf --- /dev/null +++ b/docs/resources/secret.md @@ -0,0 +1,59 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "juju_secret Resource - terraform-provider-juju" +subcategory: "" +description: |- + A resource that represents a Juju secret. +--- + +# juju_secret (Resource) + +A resource that represents a Juju secret. + +## Example Usage + +```terraform +resource "juju_secret" "this" { + model = juju_model.development.name + name = "this_secret_name" + value = { + key1 = "value1" + key2 = "value2" + } + info = "This is the secret" +} +``` + + +## Schema + +### Required + +- `model` (String) The model in which the secret belongs. +- `value` (Map of String, Sensitive) The value map of the secret. There can be more than one key-value pair. + +### Optional + +- `info` (String) The description of the secret. +- `name` (String) The name of the secret. + +### Read-Only + +- `secret_id` (String) The ID of the secret. + +## 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 +``` diff --git a/docs/resources/secret_access.md b/docs/resources/secret_access.md new file mode 100644 index 00000000..1ca5dd16 --- /dev/null +++ b/docs/resources/secret_access.md @@ -0,0 +1,49 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "juju_secret_access Resource - terraform-provider-juju" +subcategory: "" +description: |- + A resource that represents a Juju secret access. +--- + +# juju_secret_access (Resource) + +A resource that represents a Juju secret access. + +## Example Usage + +```terraform +resource "juju_secret_access" "this" { + model = juju_model.development.name + applications = [ + juju_application.app.name, juju_application.app2.name + ] + secret_id = juju_secret.that.secret_id +} +``` + + +## Schema + +### Required + +- `applications` (List of String) The list of applications to which the secret is granted or revoked. +- `model` (String) The model in which the secret belongs. +- `secret_id` (String) The ID of the secret. + +## Import + +Import is supported using the following syntax: + +```shell +# 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_secret_access.secret-access-name coh2uo2ji6m0ue9a7tj0 +``` diff --git a/examples/resources/juju_secret/import.sh b/examples/resources/juju_secret/import.sh new file mode 100644 index 00000000..c06fe998 --- /dev/null +++ b/examples/resources/juju_secret/import.sh @@ -0,0 +1,10 @@ +# 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 diff --git a/examples/resources/juju_secret/resource.tf b/examples/resources/juju_secret/resource.tf new file mode 100644 index 00000000..d1b5ad48 --- /dev/null +++ b/examples/resources/juju_secret/resource.tf @@ -0,0 +1,9 @@ +resource "juju_secret" "this" { + model = juju_model.development.name + name = "this_secret_name" + value = { + key1 = "value1" + key2 = "value2" + } + info = "This is the secret" +} \ No newline at end of file diff --git a/examples/resources/juju_secret_access/import.sh b/examples/resources/juju_secret_access/import.sh new file mode 100644 index 00000000..a4740e9f --- /dev/null +++ b/examples/resources/juju_secret_access/import.sh @@ -0,0 +1,10 @@ +# 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_secret_access.secret-access-name coh2uo2ji6m0ue9a7tj0 \ No newline at end of file diff --git a/examples/resources/juju_secret_access/resource.tf b/examples/resources/juju_secret_access/resource.tf new file mode 100644 index 00000000..432094ee --- /dev/null +++ b/examples/resources/juju_secret_access/resource.tf @@ -0,0 +1,7 @@ +resource "juju_secret_access" "this" { + model = juju_model.development.name + applications = [ + juju_application.app.name, juju_application.app2.name + ] + secret_id = juju_secret.that.secret_id +} \ No newline at end of file diff --git a/go.mod b/go.mod index b9c26fc6..e86fc8af 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/juju/cmd/v3 v3.0.14 github.com/juju/collections v1.0.4 github.com/juju/errors v1.0.0 + github.com/juju/names/v4 v4.0.0 github.com/juju/names/v5 v5.0.0 github.com/juju/retry v1.0.0 github.com/juju/utils/v3 v3.1.1 @@ -128,7 +129,6 @@ require ( github.com/juju/lumberjack/v2 v2.0.2 // indirect github.com/juju/mgo/v3 v3.0.4 // indirect github.com/juju/mutex/v2 v2.0.0 // indirect - github.com/juju/names/v4 v4.0.0-20220207005702-9c6532a52823 // indirect github.com/juju/os/v2 v2.2.3 // indirect github.com/juju/packaging/v2 v2.0.1 // indirect github.com/juju/persistent-cookiejar v1.0.0 // indirect diff --git a/go.sum b/go.sum index f57435b0..3a56337b 100644 --- a/go.sum +++ b/go.sum @@ -368,8 +368,8 @@ github.com/juju/mgo/v3 v3.0.4 h1:ek6YDy71tqikpoFSpvLkpCZ7zvYNYH+xSk/MebMkCEE= github.com/juju/mgo/v3 v3.0.4/go.mod h1:fAvhDCRbUlEbRIae6UQT8RvPUoLwKnJsBgO6OzHKNxw= github.com/juju/mutex/v2 v2.0.0 h1:rVmJdOaXGWF8rjcFHBNd4x57/1tks5CgXHx55O55SB0= github.com/juju/mutex/v2 v2.0.0/go.mod h1:jwCfBs/smYDaeZLqeaCi8CB8M+tOes4yf827HoOEoqk= -github.com/juju/names/v4 v4.0.0-20220207005702-9c6532a52823 h1:Sv0+v4107/GHA0S25ay/rgGVmLyc+5Fjp0NnTksW/IQ= -github.com/juju/names/v4 v4.0.0-20220207005702-9c6532a52823/go.mod h1:xpkrQpHbz1DGY+0Geo32ZnyognGA/2vSB++rpu/Z+Lc= +github.com/juju/names/v4 v4.0.0 h1:XeQZbwT70i98TynM+2RJr9At6EGb9X/P6l8qF56hPns= +github.com/juju/names/v4 v4.0.0/go.mod h1:xpkrQpHbz1DGY+0Geo32ZnyognGA/2vSB++rpu/Z+Lc= github.com/juju/names/v5 v5.0.0 h1:3IkRTUaniNXsgjy4lNqbJx7dVdsONlzuH6YMYT7uXss= github.com/juju/names/v5 v5.0.0/go.mod h1:PkvHbErUTniKvLu1ejJ5m/AbXOW55MFn1jsGVEbVXk8= github.com/juju/naturalsort v1.0.0 h1:kGmUUy3h8mJ5/SJYaqKOBR3f3owEd5R52Lh+Tjg/dNM= diff --git a/internal/juju/applications_test.go b/internal/juju/applications_test.go index 3ff80d2f..67b56d7c 100644 --- a/internal/juju/applications_test.go +++ b/internal/juju/applications_test.go @@ -15,7 +15,7 @@ import ( "github.com/juju/juju/core/resources" "github.com/juju/juju/environs/config" "github.com/juju/juju/rpc/params" - "github.com/juju/names/v5" + "github.com/juju/names/v4" "github.com/juju/utils/v3" "github.com/juju/version/v2" "github.com/stretchr/testify/suite" diff --git a/internal/juju/client.go b/internal/juju/client.go index af9381cf..9128ce78 100644 --- a/internal/juju/client.go +++ b/internal/juju/client.go @@ -24,6 +24,7 @@ const ( PrefixCharm = "charm-" PrefixUser = "user-" PrefixMachine = "machine-" + PrefixApplication = "application-" UnspecifiedRevision = -1 connectionTimeout = 30 * time.Second ) diff --git a/internal/juju/secrets.go b/internal/juju/secrets.go index 5e0f4269..0feead5e 100644 --- a/internal/juju/secrets.go +++ b/internal/juju/secrets.go @@ -61,10 +61,11 @@ type ReadSecretInput struct { } type ReadSecretOutput struct { - SecretId string - Name string - Value map[string]string - Info string + SecretId string + Name string + Value map[string]string + Applications []string + Info string } type UpdateSecretInput struct { @@ -176,11 +177,15 @@ func (c *secretsClient) ReadSecret(input *ReadSecretInput) (ReadSecretOutput, er return ReadSecretOutput{}, err } + // Get applications from Access info + applications := getApplicationsFromAccessInfo(results[0].Access) + return ReadSecretOutput{ - SecretId: results[0].Metadata.URI.String(), - Name: results[0].Metadata.Label, - Value: decodedValue, - Info: results[0].Metadata.Description, + SecretId: results[0].Metadata.URI.String(), + Name: results[0].Metadata.Label, + Value: decodedValue, + Applications: applications, + Info: results[0].Metadata.Description, }, nil } @@ -301,3 +306,13 @@ func (c *secretsClient) UpdateSecretAccess(input *GrantRevokeSecretAccessInput, return nil } + +// getApplicationsFromAccessInfo returns a list of applications from the access info. +func getApplicationsFromAccessInfo(accessInfo []coresecrets.AccessInfo) []string { + applications := make([]string, 0, len(accessInfo)) + for _, info := range accessInfo { + // Trim the prefix "application-" from the application name (info.Target) + applications = append(applications, strings.TrimPrefix(info.Target, PrefixApplication)) + } + return applications +} diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 71a1facb..174d999e 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -20,15 +20,16 @@ const ( LogDataSourceModel = "datasource-model" LogDataSourceOffer = "datasource-offer" - LogResourceApplication = "resource-application" - LogResourceAccessModel = "resource-assess-model" - LogResourceCredential = "resource-credential" - LogResourceMachine = "resource-machine" - LogResourceModel = "resource-model" - LogResourceOffer = "resource-offer" - LogResourceSSHKey = "resource-sshkey" - LogResourceUser = "resource-user" - LogResourceSecret = "resource-secret" + LogResourceApplication = "resource-application" + LogResourceAccessModel = "resource-assess-model" + LogResourceCredential = "resource-credential" + LogResourceMachine = "resource-machine" + LogResourceModel = "resource-model" + LogResourceOffer = "resource-offer" + LogResourceSSHKey = "resource-sshkey" + LogResourceUser = "resource-user" + LogResourceSecret = "resource-secret" + LogResourceSecretAccess = "resource-secret-access" ) const LogResourceIntegration = "resource-integration" diff --git a/internal/provider/main_test.go b/internal/provider/main_test.go index 5fe7ace1..f0410cd4 100644 --- a/internal/provider/main_test.go +++ b/internal/provider/main_test.go @@ -19,6 +19,7 @@ const TestCloudEnvKey string = "TEST_CLOUD" const TestMachineIPEnvKey string = "TEST_ADD_MACHINE_IP" const TestSSHPublicKeyFileEnvKey string = "TEST_SSH_PUB_KEY_PATH" const TestSSHPrivateKeyFileEnvKey string = "TEST_SSH_PRIV_KEY_PATH" +const TestJujuAgentVersion = "JUJU_AGENT_VERSION" // CloudTesting is a value indicating the current cloud // available for testing diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0fb3091c..af7f07f7 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -293,6 +293,7 @@ func (p *jujuProvider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return NewSSHKeyResource() }, func() resource.Resource { return NewUserResource() }, func() resource.Resource { return NewSecretResource() }, + func() resource.Resource { return NewSecretAccessResource() }, } } diff --git a/internal/provider/resource_model.go b/internal/provider/resource_model.go index 8cac55e9..442f8710 100644 --- a/internal/provider/resource_model.go +++ b/internal/provider/resource_model.go @@ -24,7 +24,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/juju/juju/core/constraints" - "github.com/juju/names/v5" + "github.com/juju/names/v4" "github.com/juju/utils/v3" "github.com/juju/terraform-provider-juju/internal/juju" diff --git a/internal/provider/resource_secret.go b/internal/provider/resource_secret.go index 710cb6cf..5b428623 100644 --- a/internal/provider/resource_secret.go +++ b/internal/provider/resource_secret.go @@ -79,6 +79,9 @@ func (s *secretResource) Schema(_ context.Context, req resource.SchemaRequest, r "secret_id": schema.StringAttribute{ Description: "The ID of the secret.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "info": schema.StringAttribute{ Description: "The description of the secret.", diff --git a/internal/provider/resource_secret_access.go b/internal/provider/resource_secret_access.go new file mode 100644 index 00000000..468480d2 --- /dev/null +++ b/internal/provider/resource_secret_access.go @@ -0,0 +1,321 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "context" + "fmt" + + "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" + "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/collections/set" + + "github.com/juju/terraform-provider-juju/internal/juju" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &secretAccessResource{} +var _ resource.ResourceWithConfigure = &secretAccessResource{} +var _ resource.ResourceWithImportState = &secretAccessResource{} + +func NewSecretAccessResource() resource.Resource { + return &secretAccessResource{} +} + +type secretAccessResource struct { + client *juju.Client + + // subCtx is the context created with the new tflog subsystem for applications. + subCtx context.Context +} + +type secretAccessResourceModel struct { + // Model to which the secret belongs. + Model types.String `tfsdk:"model"` + // SecretId is the ID of the secret to be grant or revoked. + 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"` +} + +func (s *secretAccessResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (s *secretAccessResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_secret_access" +} + +// Schema is called when the resource schema is being initialized. +func (s *secretAccessResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A resource that represents a Juju secret access.", + Attributes: map[string]schema.Attribute{ + "model": schema.StringAttribute{ + Description: "The model in which the secret belongs.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "secret_id": schema.StringAttribute{ + Description: "The ID of the secret.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "applications": schema.ListAttribute{ + Description: "The list of applications to which the secret is granted or revoked.", + Required: true, + ElementType: types.StringType, + }, + }, + } +} + +// Configure is called when the resource is being configured. +func (s *secretAccessResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*juju.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *juju.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + s.client = client + // Create the local logging subsystem here, using the TF context when creating it. + s.subCtx = tflog.NewSubsystem(ctx, LogResourceSecret) +} + +// Create is called when the resource is being created. +func (s *secretAccessResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret", "create") + return + } + + var plan secretAccessResourceModel + + // Read Terraform plan into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + applications := make([]string, len(plan.Applications.Elements())) + resp.Diagnostics.Append(plan.Applications.ElementsAs(ctx, &applications, false)...) + + err := s.client.Secrets.UpdateSecretAccess(&juju.GrantRevokeSecretAccessInput{ + ModelName: plan.Model.ValueString(), + SecretId: plan.SecretId.ValueString(), + Applications: applications, + }, juju.GrantAccess) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to grant secret access, got error: %s", err)) + return + } + + // Save plan into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + + s.trace(fmt.Sprintf("grant secret access to %q", plan.SecretId)) +} + +// Read is called when the resource is being read. +func (s *secretAccessResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret_access", "read") + return + } + + var state secretAccessResourceModel + + // Read Terraform configuration state into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + readSecretOutput, err := s.client.Secrets.ReadSecret(&juju.ReadSecretInput{ + SecretId: state.SecretId.ValueString(), + ModelName: state.Model.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read secret, got error: %s", err)) + return + } + + // 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("read secret access %q", state.SecretId)) +} + +// Update is called when the resource is being updated. +func (s *secretAccessResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret_access", "update") + return + } + + var plan, state secretAccessResourceModel + + // Read Terraform plan and state into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + var updatedSecretAccessInput juju.GrantRevokeSecretAccessInput + + updatedSecretAccessInput.ModelName = state.Model.ValueString() + updatedSecretAccessInput.SecretId = state.SecretId.ValueString() + + if plan.Applications.Equal(state.Applications) { + s.trace(fmt.Sprintf("no updates to secret access %q", state.SecretId)) + return + } + + planApplications := make([]string, len(plan.Applications.Elements())) + resp.Diagnostics.Append(plan.Applications.ElementsAs(ctx, &planApplications, false)...) + if resp.Diagnostics.HasError() { + return + } + + stateApplications := make([]string, len(state.Applications.Elements())) + resp.Diagnostics.Append(state.Applications.ElementsAs(ctx, &stateApplications, false)...) + if resp.Diagnostics.HasError() { + return + } + + planSet := set.NewStrings(planApplications...) + stateSet := set.NewStrings(stateApplications...) + + applicationsToGrant := planSet.Difference(stateSet) + applicationsToRevoke := stateSet.Difference(planSet) + + s.trace(fmt.Sprintf("applications to revoke secret: %v", applicationsToRevoke)) + s.trace(fmt.Sprintf("applications to grant secret: %v", applicationsToGrant)) + + resp.Diagnostics.Append(plan.Applications.ElementsAs(ctx, &state.Applications, false)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(plan.Applications.ElementsAs(ctx, &updatedSecretAccessInput.Applications, false)...) + if resp.Diagnostics.HasError() { + return + } + + // revoke access to applications that are in the state but not in the plan + if !applicationsToGrant.IsEmpty() { + err := s.client.Secrets.UpdateSecretAccess(&juju.GrantRevokeSecretAccessInput{ + ModelName: state.Model.ValueString(), + SecretId: state.SecretId.ValueString(), + Applications: applicationsToGrant.Values(), + }, juju.GrantAccess) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to grant secret access, got error: %s", err)) + return + } + } + + // grant access to applications that are in the plan but not in the state + if !applicationsToRevoke.IsEmpty() { + err := s.client.Secrets.UpdateSecretAccess(&juju.GrantRevokeSecretAccessInput{ + ModelName: state.Model.ValueString(), + SecretId: state.SecretId.ValueString(), + Applications: applicationsToRevoke.Values(), + }, juju.RevokeAccess) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to revoke secret access, got error: %s", err)) + return + } + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + + s.trace(fmt.Sprintf("update secret access %q", state.SecretId)) +} + +// Delete is called when the resource is being deleted. +func (s *secretAccessResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret_access", "delete") + return + } + + var state secretAccessResourceModel + + // Read Terraform configuration state into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + applications := make([]string, len(state.Applications.Elements())) + resp.Diagnostics.Append(state.Applications.ElementsAs(ctx, &applications, false)...) + if resp.Diagnostics.HasError() { + return + } + + err := s.client.Secrets.UpdateSecretAccess(&juju.GrantRevokeSecretAccessInput{ + ModelName: state.Model.ValueString(), + SecretId: state.SecretId.ValueString(), + Applications: applications, + }, juju.RevokeAccess) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to revoke secret access, got error: %s", err)) + return + } + + // Save empty list of applications into Terraform state + emptyApplicationList, errDiag := types.ListValue(types.StringType, []attr.Value{}) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + state.Applications = emptyApplicationList + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + + s.trace(fmt.Sprintf("revoke secret access %q", state.SecretId)) +} + +func (s *secretAccessResource) trace(msg string, additionalFields ...map[string]interface{}) { + if s.subCtx == nil { + return + } + tflog.SubsystemTrace(s.subCtx, LogResourceSecretAccess, msg, additionalFields...) +} diff --git a/internal/provider/resource_secret_access_test.go b/internal/provider/resource_secret_access_test.go new file mode 100644 index 00000000..7590bd51 --- /dev/null +++ b/internal/provider/resource_secret_access_test.go @@ -0,0 +1,118 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "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" +) + +// TestAcc_ResourceSecretAccess_GrantRevoke tests the creation of a secret access resource. This is a contrived test as +// the applications used don't actually require a user secret. +// TODO(anvial): Add a test that uses a secret that is actually required by the application. +func TestAcc_ResourceSecretAccess_GrantRevoke(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, false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret_access.test_secret_access", "model", modelName), + resource.TestCheckResourceAttr("juju_secret_access.test_secret_access", "applications.0", "jul"), + ), + }, + { + Config: testAccResourceSecretWithAccess(modelName, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret_access.test_secret_access", "model", modelName), + resource.TestCheckResourceAttr("juju_secret_access.test_secret_access", "applications.0", "jul"), + resource.TestCheckResourceAttr("juju_secret_access.test_secret_access", "applications.1", "jul2"), + ), + }, + { + Config: testAccResourceSecretWithAccess(modelName, false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret_access.test_secret_access", "model", modelName), + resource.TestCheckResourceAttr("juju_secret_access.test_secret_access", "applications.0", "jul"), + ), + }, + }, + }) +} + +func testAccResourceSecretWithAccess(modelName string, allApplicationAccess bool) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceSecret", + ` +resource "juju_model" "{{.ModelName}}" { + name = "{{.ModelName}}" +} + +resource "juju_application" "jul" { + name = "jul" + model = juju_model.{{.ModelName}}.name + + charm { + name = "jameinel-ubuntu-lite" + channel = "latest/stable" + } + + units = 1 +} + +resource "juju_application" "jul2" { + name = "jul2" + model = juju_model.{{.ModelName}}.name + + charm { + name = "jameinel-ubuntu-lite" + channel = "latest/stable" + } + + units = 1 +} + +resource "juju_secret" "test_secret" { + model = juju_model.{{.ModelName}}.name + name = "test_secret_name" + value = { + key1 = "value1" + key2 = "value2" + } + info = "This is my secret" +} + +resource "juju_secret_access" "test_secret_access" { + model = juju_model.{{.ModelName}}.name + {{- if .AllApplicationAccess }} + applications = [ + juju_application.jul.name, juju_application.jul2.name + ] + {{- else }} + applications = [ + juju_application.jul.name + ] + {{- end }} + secret_id = juju_secret.test_secret.secret_id +} +`, internaltesting.TemplateData{ + "ModelName": modelName, + "AllApplicationAccess": allApplicationAccess, + }) +} diff --git a/internal/provider/resource_secret_test.go b/internal/provider/resource_secret_test.go index 6b670dd0..fd082560 100644 --- a/internal/provider/resource_secret_test.go +++ b/internal/provider/resource_secret_test.go @@ -13,8 +13,11 @@ import ( ) func TestAcc_ResourceSecret_CreateWithoutName(t *testing.T) { - if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { - t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + 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") @@ -42,8 +45,11 @@ func TestAcc_ResourceSecret_CreateWithoutName(t *testing.T) { } func TestAcc_ResourceSecret_CreateWithInfo(t *testing.T) { - if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { - t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + 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") @@ -73,8 +79,11 @@ func TestAcc_ResourceSecret_CreateWithInfo(t *testing.T) { } func TestAcc_ResourceSecret_CreateWithNoInfo(t *testing.T) { - if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { - t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + 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") @@ -102,8 +111,11 @@ func TestAcc_ResourceSecret_CreateWithNoInfo(t *testing.T) { } func TestAcc_ResourceSecret_Update(t *testing.T) { - if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { - t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + 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") diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index 035734a0..5cc53a22 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -72,6 +72,7 @@ Define the Juju controller credentials in the provider definition via environmen ```shell export CONTROLLER=$(juju whoami | yq .Controller) +export JUJU_AGENT_VERSION="$(juju show-controller | yq '.[$CONTROLLER]'.details.\"agent-version\"|tr -d '"')" export JUJU_CONTROLLER_ADDRESSES="$(juju show-controller | yq '.[$CONTROLLER]'.details.\"api-endpoints\" | tr -d "[]' "|tr -d '"'|tr -d '\n')" export JUJU_USERNAME="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.user|tr -d '"')" export JUJU_PASSWORD="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.password|tr -d '"')"