Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature scaffolding - added centralized handling of resources removed from the server and state management #23

Merged
merged 6 commits into from
Jul 25, 2024
Merged
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
Original file line number Diff line number Diff line change
@@ -1,30 +1,16 @@
resource "microsoft365_graph_beta_device_and_app_management_assignment_filter" "example" {
display_name = "Example Assignment Filter"
description = "This is an example assignment filter"
platform = "android"
rule = "device.os == 'Android'"
display_name = "new filter"
description = "This is an example assignment filter"
platform = "iOS"
rule = "(device.manufacturer -eq \"thing\")"
assignment_filter_management_type = "devices"

role_scope_tags = ["tag1", "tag2"]
role_scope_tags = [8,9]

payloads {
payload_id = "payload1"
payload_type = "type1"
group_id = "group1"
assignment_filter_type = "include"
timeouts = {
create = "10s"
read = "10s"
update = "10s"
delete = "10s"
}

payloads {
payload_id = "payload2"
payload_type = "type2"
group_id = "group2"
assignment_filter_type = "exclude"
}

timeouts {
create = "30m"
read = "30m"
update = "30m"
delete = "30m"
}
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/hashicorp/terraform-plugin-docs v0.19.4
github.com/hashicorp/terraform-plugin-framework v1.10.0
github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1
github.com/hashicorp/terraform-plugin-framework-validators v0.13.0
github.com/hashicorp/terraform-plugin-log v0.9.0
github.com/microsoft/kiota-http-go v1.4.2
github.com/microsoftgraph/msgraph-beta-sdk-go v0.106.0
Expand Down Expand Up @@ -50,7 +51,6 @@ require (
github.com/hashicorp/hc-install v0.7.0 // indirect
github.com/hashicorp/terraform-exec v0.21.0 // indirect
github.com/hashicorp/terraform-json v0.22.1 // indirect
github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 // indirect
github.com/hashicorp/terraform-plugin-go v0.23.0 // indirect
github.com/hashicorp/terraform-registry-address v0.2.3 // indirect
github.com/hashicorp/terraform-svchost v0.1.1 // indirect
Expand Down
12 changes: 7 additions & 5 deletions internal/resources/common/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,24 @@ func IsNotFoundError(err error) bool {

odataErr, ok := err.(*odataerrors.ODataError)
if !ok {
return false
// If it's not an ODataError, check the error string
return strings.Contains(strings.ToLower(err.Error()), "not found")
}

mainError := odataErr.GetErrorEscaped()
if mainError != nil {
if code := mainError.GetCode(); code != nil {
switch strings.ToLower(*code) {
case "request_resourcenotfound", "resourcenotfound":
case "request_resourcenotfound", "resourcenotfound", "notfound":
return true
}
}

if message := mainError.GetMessage(); message != nil {
if strings.Contains(strings.ToLower(*message), "not found") {
return true
}
lowerMessage := strings.ToLower(*message)
return strings.Contains(lowerMessage, "not found") ||
strings.Contains(lowerMessage, "could not be found") ||
strings.Contains(lowerMessage, "does not exist")
}
}

Expand Down
45 changes: 45 additions & 0 deletions internal/resources/common/state.go
Original file line number Diff line number Diff line change
@@ -1 +1,46 @@
package common

import (
"context"
"fmt"
"strings"

"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-log/tflog"
)

// ResourceWithID is an interface that represents a resource with an ID.
type ResourceWithID interface {
GetTypeName() string
}

// StateWithID is an interface that represents a state model with an ID.
type StateWithID interface {
GetID() string
}

// HandleReadStateError handles errors during the read operation.
func HandleReadStateError(ctx context.Context, resp *resource.ReadResponse, resource ResourceWithID, state StateWithID, err error) {
if IsNotFoundError(err) || strings.Contains(err.Error(), "An error has occurred") {
tflog.Warn(ctx, fmt.Sprintf("%s with ID %s not found, removing from state", resource.GetTypeName(), state.GetID()))
resp.State.RemoveResource(ctx)
} else {
resp.Diagnostics.AddError(
"Error reading resource",
fmt.Sprintf("Could not update %s with ID %s: %s", resource.GetTypeName(), state.GetID(), err.Error()),
)
}
}

// HandleUpdateStateError handles errors during the update operation.
func HandleUpdateStateError(ctx context.Context, resp *resource.UpdateResponse, resource ResourceWithID, state StateWithID, err error) {
if IsNotFoundError(err) || strings.Contains(err.Error(), "An error has occurred") {
tflog.Warn(ctx, fmt.Sprintf("%s with ID %s not found, removing from state", resource.GetTypeName(), state.GetID()))
resp.State.RemoveResource(ctx)
} else {
resp.Diagnostics.AddError(
"Error updating resource",
fmt.Sprintf("Could not update %s with ID %s: %s", resource.GetTypeName(), state.GetID(), err.Error()),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"

"github.com/deploymenttheory/terraform-provider-microsoft365/internal/resources/common"
"github.com/microsoftgraph/msgraph-beta-sdk-go/models"
)

Expand Down Expand Up @@ -49,21 +48,16 @@ func constructResource(ctx context.Context, data *AssignmentFilterResourceModel)
}
}

if !data.RoleScopeTags.IsNull() && len(data.RoleScopeTags.Elements()) > 0 {
roleScopeTags := make([]string, len(data.RoleScopeTags.Elements()))
for i, tag := range data.RoleScopeTags.Elements() {
roleScopeTags[i] = tag.(types.String).ValueString()
roleScopeTags := make([]string, 0)
if !data.RoleScopeTags.IsNull() {
for _, tag := range data.RoleScopeTags.Elements() {
tagValue := tag.(types.String).ValueString()
if tagValue != "0" {
roleScopeTags = append(roleScopeTags, tagValue)
}
}
requestBody.SetRoleScopeTags(roleScopeTags)
}

payloads, err := convertPayloads(data.Payloads)
if err != nil {
return nil, err
}
if payloads != nil {
requestBody.SetPayloads(payloads)
}
requestBody.SetRoleScopeTags(roleScopeTags)

requestBodyJSON, err := json.MarshalIndent(map[string]interface{}{
"displayName": requestBody.GetDisplayName(),
Expand All @@ -72,7 +66,6 @@ func constructResource(ctx context.Context, data *AssignmentFilterResourceModel)
"rule": requestBody.GetRule(),
"managementType": requestBody.GetAssignmentFilterManagementType(),
"roleScopeTags": requestBody.GetRoleScopeTags(),
"payloads": payloads,
}, "", " ")
if err != nil {
return nil, fmt.Errorf("error marshalling request body to JSON: %s", err)
Expand All @@ -82,32 +75,3 @@ func constructResource(ctx context.Context, data *AssignmentFilterResourceModel)

return requestBody, nil
}

// convertPayloads
func convertPayloads(payloads types.List) ([]models.PayloadByFilterable, error) {
if payloads.IsNull() || len(payloads.Elements()) == 0 {
return nil, nil
}

result := make([]models.PayloadByFilterable, len(payloads.Elements()))
for i, elem := range payloads.Elements() {
payloadElem := elem.(types.Object)
payload := models.NewPayloadByFilter()

common.SetStringValueFromAttributes(payloadElem.Attributes(), "payload_id", payload.SetPayloadId)
if err := common.SetParsedValueFromAttributes(payloadElem.Attributes(), "payload_type", func(val *models.AssociatedAssignmentPayloadType) {
payload.SetPayloadType(val)
}, models.ParseAssociatedAssignmentPayloadType); err != nil {
return nil, fmt.Errorf("invalid payload type: %s", err)
}
common.SetStringValueFromAttributes(payloadElem.Attributes(), "group_id", payload.SetGroupId)
if err := common.SetParsedValueFromAttributes(payloadElem.Attributes(), "assignment_filter_type", func(val *models.DeviceAndAppManagementAssignmentFilterType) {
payload.SetAssignmentFilterType(val)
}, models.ParseAssociatedAssignmentPayloadType); err != nil {
return nil, fmt.Errorf("invalid assignment filter type: %s", err)
}

result[i] = payload
}
return result, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,29 +54,21 @@ func (r *AssignmentFilterResource) Create(ctx context.Context, req resource.Crea

resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)

tflog.Debug(ctx, fmt.Sprintf("Finished creation of resource: %s_%s", r.ProviderTypeName, r.TypeName))
tflog.Debug(ctx, fmt.Sprintf("Finished Create Method: %s_%s", r.ProviderTypeName, r.TypeName))
}

// Read handles the Read operation.
func (r *AssignmentFilterResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state AssignmentFilterResourceModel

tflog.Debug(ctx, "Starting Read method for assignment filter")

resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

if state.ID.IsNull() || state.ID.ValueString() == "" {
resp.Diagnostics.AddWarning(
"Unable to read assignment filter",
"Assignment filter ID is empty or null. Unable to read assignment filter.",
)
if resp.Diagnostics.HasError() {
return
}

tflog.Debug(ctx, fmt.Sprintf("Reading assignment filter with ID: %s", state.ID.ValueString()))

readTimeout, diags := state.Timeouts.Read(ctx, 30*time.Second)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
Expand All @@ -87,49 +79,35 @@ func (r *AssignmentFilterResource) Read(ctx context.Context, req resource.ReadRe

assignmentFilter, err := r.client.DeviceManagement().AssignmentFilters().ByDeviceAndAppManagementAssignmentFilterId(state.ID.ValueString()).Get(ctx, nil)
if err != nil {
if common.IsNotFoundError(err) {
resp.Diagnostics.AddWarning(
"Assignment filter not found",
fmt.Sprintf("Assignment filter with ID %s was not found. Removing from state.", state.ID.ValueString()),
)
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError(
"Error reading assignment filter",
fmt.Sprintf("Could not read assignment filter with ID %s: %s", state.ID.ValueString(), err.Error()),
)
common.HandleReadStateError(ctx, resp, r, &state, err)
return
}

mapRemoteStateToTerraform(ctx, &state, assignmentFilter)

resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)

tflog.Debug(ctx, "Finished Read method for assignment filter")
tflog.Debug(ctx, fmt.Sprintf("Finished Read Method: %s_%s", r.ProviderTypeName, r.TypeName))
}

// Update handles the Update operation.
func (r *AssignmentFilterResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data AssignmentFilterResourceModel
var plan AssignmentFilterResourceModel

tflog.Debug(ctx, fmt.Sprintf("Starting Update of resource: %s_%s", r.ProviderTypeName, r.TypeName))

resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}

updateTimeout, diags := data.Timeouts.Update(ctx, 30*time.Second)
updateTimeout, diags := plan.Timeouts.Update(ctx, 30*time.Second)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx, cancel := context.WithTimeout(ctx, updateTimeout)
defer cancel()

requestBody, err := constructResource(ctx, &data)

requestBody, err := constructResource(ctx, &plan)
if err != nil {
resp.Diagnostics.AddError(
"Error constructing assignment filter",
Expand All @@ -138,26 +116,15 @@ func (r *AssignmentFilterResource) Update(ctx context.Context, req resource.Upda
return
}

_, err = r.client.DeviceManagement().AssignmentFilters().ByDeviceAndAppManagementAssignmentFilterId(data.ID.ValueString()).Patch(ctx, requestBody, nil)
_, err = r.client.DeviceManagement().AssignmentFilters().ByDeviceAndAppManagementAssignmentFilterId(plan.ID.ValueString()).Patch(ctx, requestBody, nil)
if err != nil {
if common.IsNotFoundError(err) && !r.isCreate {
resp.Diagnostics.AddWarning(
"Resource Not Found",
fmt.Sprintf("The resource: %s_%s with ID %s was not found and will be removed from the state.", r.ProviderTypeName, r.TypeName, data.ID.ValueString()),
)
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError(
"Error reading assignment filter",
fmt.Sprintf("Could not update resource: %s_%s: %s", r.ProviderTypeName, r.TypeName, err.Error()),
)
common.HandleUpdateStateError(ctx, resp, r, &plan, err)
return
}

resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)

tflog.Debug(ctx, fmt.Sprintf("Finished Update of resource: %s_%s", r.ProviderTypeName, r.TypeName))
tflog.Debug(ctx, fmt.Sprintf("Finished Update Method: %s_%s", r.ProviderTypeName, r.TypeName))
}

// Delete handles the Delete operation.
Expand Down Expand Up @@ -185,7 +152,7 @@ func (r *AssignmentFilterResource) Delete(ctx context.Context, req resource.Dele
return
}

tflog.Debug(ctx, fmt.Sprintf("Completed deletion of resource: %s_%s", r.ProviderTypeName, r.TypeName))
tflog.Debug(ctx, fmt.Sprintf("Finished Delete Method: %s_%s", r.ProviderTypeName, r.TypeName))

resp.State.RemoveResource(ctx)
}
Loading
Loading