Skip to content

Commit

Permalink
fix: remove configvalidator and implement update
Browse files Browse the repository at this point in the history
  • Loading branch information
amandahla committed Dec 16, 2024
1 parent 138874f commit a94e153
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 51 deletions.
8 changes: 8 additions & 0 deletions internal/juju/offers.go
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,10 @@ func (c offersClient) GrantOffer(input *GrantRevokeOfferInput) error {
for _, user := range input.Users {
err = client.GrantOffer(user, input.Access, input.OfferURL)
if err != nil {
// ignore if user was already granted
if strings.Contains(err.Error(), "user already has") {
continue
}
return err
}
}
Expand All @@ -443,6 +447,10 @@ func (c offersClient) RevokeOffer(input *GrantRevokeOfferInput) error {
for _, user := range input.Users {
err = client.RevokeOffer(user, input.Access, input.OfferURL)
if err != nil {
// ignorer if user was already revoked
if strings.Contains(err.Error(), "not found") {
continue
}
return err
}
}
Expand Down
248 changes: 197 additions & 51 deletions internal/provider/resource_access_offer.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package provider
import (
"context"
"fmt"
"slices"

"github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
Expand All @@ -31,7 +30,6 @@ var _ resource.Resource = &accessOfferResource{}
var _ resource.ResourceWithConfigure = &accessOfferResource{}
var _ resource.ResourceWithImportState = &accessOfferResource{}
var _ resource.ResourceWithConfigValidators = &accessOfferResource{}
var _ resource.ResourceWithValidateConfig = &accessOfferResource{}

// NewAccessOfferResource returns a new instance of the Access Offer resource.
func NewAccessOfferResource() resource.Resource {
Expand Down Expand Up @@ -155,6 +153,14 @@ func (a *accessOfferResource) Create(ctx context.Context, req resource.CreateReq
}
}

// validate if there are overlaps
// validation is done here considering dynamic (juju_user resource) and static values for users
err := validateNoOverlaps(adminUsers, consumeUsers, readUsers)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create access offer resource, got error: %s", err))
return
}

// Call Offers.GrantOffer
users := make(map[permission.Access][]string)
users[permission.ConsumeAccess] = consumeUsers
Expand Down Expand Up @@ -254,7 +260,142 @@ func (a *accessOfferResource) Read(ctx context.Context, req resource.ReadRequest

// Update attempts to update the access to the offer.
func (a *accessOfferResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// todo
// Check first if the client is configured
if a.client == nil {
addClientNotConfiguredError(&resp.Diagnostics, "access offer", "update")
return
}
var plan, state accessOfferResourceOffer

// Read Terraform configuration from the request into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

// Get the users to grant admin
var adminUsers []string
if !plan.AdminUsers.IsNull() {
resp.Diagnostics.Append(plan.AdminUsers.ElementsAs(ctx, &adminUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
}

// Get the users to grant consume
var consumeUsers []string
if !plan.ConsumeUsers.IsNull() {
resp.Diagnostics.Append(plan.ConsumeUsers.ElementsAs(ctx, &consumeUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
}

// Get the users to grant read
var readUsers []string
if !plan.ReadUsers.IsNull() {
resp.Diagnostics.Append(plan.ReadUsers.ElementsAs(ctx, &readUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
}

// validate if there are overlaps
// validation is done here considering dynamic (juju_user resource) and static values for users
err := validateNoOverlaps(adminUsers, consumeUsers, readUsers)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create access offer resource, got error: %s", err))
return
}

// If bob is in the plan but not in state = grant
// if bob is in the state but not in the plan = revoke
// scenario 1 (users added/moved): bob was moved from admin to consume
// bob will be 're-granted' consume permission in further steps
// scenario 2 (users removed): bob was removed from the resource (user was in state but not in the plan anymore)
// bob's permission will be revoked (revoke read) in the last update step

// scenario 1 - check for users added or moved
var adminStateUsers []string
if !state.AdminUsers.IsNull() {
resp.Diagnostics.Append(state.AdminUsers.ElementsAs(ctx, &adminStateUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
}
err = processPermissionChanges(plan.OfferURL.ValueString(), string(permission.AdminAccess), adminUsers, adminStateUsers, a.client)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update offer access %s, got error: %s", state.ID.ValueString(), err))
return
}

var consumeStateUsers []string
if !state.ConsumeUsers.IsNull() {
resp.Diagnostics.Append(state.ConsumeUsers.ElementsAs(ctx, &consumeStateUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
}
err = processPermissionChanges(plan.OfferURL.ValueString(), string(permission.ConsumeAccess), consumeUsers, consumeStateUsers, a.client)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update offer access %s, got error: %s", state.ID.ValueString(), err))
return
}

var readStateUsers []string
if !state.ReadUsers.IsNull() {
resp.Diagnostics.Append(state.ReadUsers.ElementsAs(ctx, &readStateUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
}
err = processPermissionChanges(plan.OfferURL.ValueString(), string(permission.ReadAccess), readUsers, readStateUsers, a.client)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update offer access %s, got error: %s", state.ID.ValueString(), err))
return
}

// scenario 2 - check for users removed from the resource (they are in state but not in plan anymore)
totalStateUsers := append(adminStateUsers, consumeStateUsers...)
totalStateUsers = append(totalStateUsers, readStateUsers...)
totalPlanUsers := append(adminUsers, consumeUsers...)
totalPlanUsers = append(totalPlanUsers, readUsers...)
removeUsers := diffUsers(totalStateUsers, totalPlanUsers)
if len(removeUsers) > 0 {
err := a.client.Offers.RevokeOffer(&juju.GrantRevokeOfferInput{
Users: removeUsers,
Access: string(permission.ReadAccess),
OfferURL: plan.OfferURL.ValueString(),
})
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to destroy access offer resource, got error: %s", err))
return
}
}

// Save admin users to state
adminUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, adminUsers)
resp.Diagnostics.Append(errDiag...)
if resp.Diagnostics.HasError() {
return
}
plan.AdminUsers = adminUsersSet
// Save consume users to state
consumeUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, consumeUsers)
resp.Diagnostics.Append(errDiag...)
if resp.Diagnostics.HasError() {
return
}
plan.ConsumeUsers = consumeUsersSet
// Save read users to state
readUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, readUsers)
resp.Diagnostics.Append(errDiag...)
if resp.Diagnostics.HasError() {
return
}
plan.ReadUsers = readUsersSet
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

// Delete remove access to the offer according to the resource.
Expand Down Expand Up @@ -295,68 +436,73 @@ func (a *accessOfferResource) ConfigValidators(ctx context.Context) []resource.C
}
}

func (a *accessOfferResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
// TODO this validation does not work in case user name depends on the output of other resource
var configData accessOfferResourceOffer
// ImportState import existing resource to the state.
func (a *accessOfferResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

// Read Terraform configuration from the request into the model
resp.Diagnostics.Append(req.Config.Get(ctx, &configData)...)
if resp.Diagnostics.HasError() {
func (a *accessOfferResource) trace(msg string, additionalFields ...map[string]interface{}) {
if a.subCtx == nil {
return
}

// Get the users to grant admin
var adminUsers []string
if !configData.AdminUsers.IsNull() {
resp.Diagnostics.Append(configData.AdminUsers.ElementsAs(ctx, &adminUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
}
tflog.SubsystemTrace(a.subCtx, LogResourceAccessOffer, msg, additionalFields...)
}

// Get the users to grant consume
var consumeUsers []string
if !configData.ConsumeUsers.IsNull() {
resp.Diagnostics.Append(configData.ConsumeUsers.ElementsAs(ctx, &consumeUsers, false)...)
if resp.Diagnostics.HasError() {
return
}
func validateNoOverlaps(admin, consume, read []string) error {
sets := map[string]struct{}{}
for _, v := range consume {
sets[v] = struct{}{}
}

// Get the users to grant read
var readUsers []string
if !configData.ReadUsers.IsNull() {
resp.Diagnostics.Append(configData.ReadUsers.ElementsAs(ctx, &readUsers, false)...)
if resp.Diagnostics.HasError() {
return
for _, v := range read {
if _, exists := sets[v]; exists {
return fmt.Errorf("user '%s' appears in both 'consume' and 'read'", v)
}
sets[v] = struct{}{}
}

combinedUsers := append(append(adminUsers, consumeUsers...), readUsers...)
// Validate if there are repeated user
if slices.Contains(combinedUsers, "admin") {
resp.Diagnostics.AddAttributeError(path.Root("offer_url"), "Attribute Error", "\"admin\" user is not allowed")
}
// Validate if there are repeated user
slices.Sort(combinedUsers)
originalCount := len(combinedUsers)
compactedUsers := slices.Compact(combinedUsers)
compactedCount := len(compactedUsers)
if originalCount != compactedCount {
resp.Diagnostics.AddAttributeError(path.Root("offer_url"), "Attribute Error", "do not repeat users across different access levels")
for _, v := range admin {
if _, exists := sets[v]; exists {
return fmt.Errorf("user '%s' appears in multiple roles (e.g., 'consume', 'read', 'admin')", v)
}
}

return nil
}

// ImportState import existing resource to the state.
func (a *accessOfferResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
func processPermissionChanges(offerURL, permissionType string, planUsers, stateUsers []string, jujuClient *juju.Client) error {
toGrant := diffUsers(planUsers, stateUsers)
toRevoke := diffUsers(stateUsers, planUsers)
err := jujuClient.Offers.RevokeOffer(&juju.GrantRevokeOfferInput{
Users: toRevoke,
Access: permissionType,
OfferURL: offerURL,
})
if err != nil {
return err
}

err = jujuClient.Offers.GrantOffer(&juju.GrantRevokeOfferInput{
Users: toGrant,
Access: permissionType,
OfferURL: offerURL,
})
if err != nil {
return err
}
return nil
}

func (a *accessOfferResource) trace(msg string, additionalFields ...map[string]interface{}) {
if a.subCtx == nil {
return
func diffUsers(a, b []string) []string {
set := make(map[string]struct{})
for _, v := range b {
set[v] = struct{}{}
}

tflog.SubsystemTrace(a.subCtx, LogResourceAccessOffer, msg, additionalFields...)
var diff []string
for _, v := range a {
if _, found := set[v]; !found {
diff = append(diff, v)
}
}
return diff
}

0 comments on commit a94e153

Please sign in to comment.