internal/services/azapi_resource.go (1,073 lines of code) (raw):
package services
import (
"context"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/terraform-provider-azapi/internal/azure"
"github.com/Azure/terraform-provider-azapi/internal/azure/identity"
"github.com/Azure/terraform-provider-azapi/internal/azure/location"
"github.com/Azure/terraform-provider-azapi/internal/azure/tags"
aztypes "github.com/Azure/terraform-provider-azapi/internal/azure/types"
"github.com/Azure/terraform-provider-azapi/internal/clients"
"github.com/Azure/terraform-provider-azapi/internal/docstrings"
"github.com/Azure/terraform-provider-azapi/internal/locks"
"github.com/Azure/terraform-provider-azapi/internal/retry"
"github.com/Azure/terraform-provider-azapi/internal/services/defaults"
"github.com/Azure/terraform-provider-azapi/internal/services/dynamic"
"github.com/Azure/terraform-provider-azapi/internal/services/migration"
"github.com/Azure/terraform-provider-azapi/internal/services/myplanmodifier"
"github.com/Azure/terraform-provider-azapi/internal/services/myplanmodifier/planmodifierdynamic"
"github.com/Azure/terraform-provider-azapi/internal/services/myvalidator"
"github.com/Azure/terraform-provider-azapi/internal/services/parse"
"github.com/Azure/terraform-provider-azapi/internal/services/preflight"
"github.com/Azure/terraform-provider-azapi/internal/skip"
"github.com/Azure/terraform-provider-azapi/internal/tf"
"github.com/Azure/terraform-provider-azapi/utils"
"github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
)
const FlagMoveState = "move_state"
type AzapiResourceModel struct {
Body types.Dynamic `tfsdk:"body"`
SensitiveBody types.Dynamic `tfsdk:"sensitive_body"`
ID types.String `tfsdk:"id"`
Identity types.List `tfsdk:"identity"`
IgnoreCasing types.Bool `tfsdk:"ignore_casing"`
IgnoreMissingProperty types.Bool `tfsdk:"ignore_missing_property"`
Location types.String `tfsdk:"location"`
Locks types.List `tfsdk:"locks"`
Name types.String `tfsdk:"name"`
Output types.Dynamic `tfsdk:"output"`
ParentID types.String `tfsdk:"parent_id"`
ReplaceTriggersExternalValues types.Dynamic `tfsdk:"replace_triggers_external_values"`
ReplaceTriggersRefs types.List `tfsdk:"replace_triggers_refs"`
ResponseExportValues types.Dynamic `tfsdk:"response_export_values"`
Retry retry.RetryValue `tfsdk:"retry" skip_on:"update"`
SchemaValidationEnabled types.Bool `tfsdk:"schema_validation_enabled"`
Tags types.Map `tfsdk:"tags"`
Timeouts timeouts.Value `tfsdk:"timeouts" skip_on:"update"`
Type types.String `tfsdk:"type"`
CreateHeaders types.Map `tfsdk:"create_headers" skip_on:"update"`
CreateQueryParameters types.Map `tfsdk:"create_query_parameters" skip_on:"update"`
UpdateHeaders types.Map `tfsdk:"update_headers"`
UpdateQueryParameters types.Map `tfsdk:"update_query_parameters"`
DeleteHeaders types.Map `tfsdk:"delete_headers" skip_on:"update"`
DeleteQueryParameters types.Map `tfsdk:"delete_query_parameters" skip_on:"update"`
ReadHeaders types.Map `tfsdk:"read_headers" skip_on:"update"`
ReadQueryParameters types.Map `tfsdk:"read_query_parameters" skip_on:"update"`
}
var _ resource.Resource = &AzapiResource{}
var _ resource.ResourceWithConfigure = &AzapiResource{}
var _ resource.ResourceWithModifyPlan = &AzapiResource{}
var _ resource.ResourceWithValidateConfig = &AzapiResource{}
var _ resource.ResourceWithImportState = &AzapiResource{}
var _ resource.ResourceWithUpgradeState = &AzapiResource{}
var _ resource.ResourceWithMoveState = &AzapiResource{}
type AzapiResource struct {
ProviderData *clients.Client
}
func (r *AzapiResource) Configure(ctx context.Context, request resource.ConfigureRequest, _ *resource.ConfigureResponse) {
if v, ok := request.ProviderData.(*clients.Client); ok {
r.ProviderData = v
}
}
func (r *AzapiResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) {
response.TypeName = request.ProviderTypeName + "_resource"
}
func (r *AzapiResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{
0: migration.AzapiResourceMigrationV0ToV2(ctx),
1: migration.AzapiResourceMigrationV1ToV2(ctx),
}
}
func (r *AzapiResource) Schema(ctx context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) {
response.Schema = schema.Schema{
MarkdownDescription: "This resource can manage any Azure Resource Manager resource.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
MarkdownDescription: docstrings.ID(),
},
"name": schema.StringAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
MarkdownDescription: "Specifies the name of the azure resource. Changing this forces a new resource to be created.",
},
"parent_id": schema.StringAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
myvalidator.StringIsResourceID(),
},
MarkdownDescription: docstrings.ParentID(),
},
"type": schema.StringAttribute{
Required: true,
Validators: []validator.String{
myvalidator.StringIsResourceType(),
},
MarkdownDescription: docstrings.Type(),
},
"location": schema.StringAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
myplanmodifier.UseStateWhen(func(a, b types.String) bool {
return location.Normalize(a.ValueString()) == location.Normalize(b.ValueString())
}),
},
MarkdownDescription: docstrings.Location(),
},
// The body attribute is a dynamic attribute that only allows users to specify the resource body as an HCL object
"body": schema.DynamicAttribute{
Optional: true,
Computed: true,
// in the previous version, the default value is string "{}", now it's a dynamic value {}
Default: defaults.DynamicDefault(types.ObjectValueMust(map[string]attr.Type{}, map[string]attr.Value{})),
PlanModifiers: []planmodifier.Dynamic{
myplanmodifier.DynamicUseStateWhen(dynamic.SemanticallyEqual),
},
MarkdownDescription: docstrings.Body(),
Validators: []validator.Dynamic{
myvalidator.DynamicIsNotStringValidator(),
},
},
"sensitive_body": schema.DynamicAttribute{
Optional: true,
WriteOnly: true,
MarkdownDescription: docstrings.SensitiveBody(),
},
"replace_triggers_external_values": schema.DynamicAttribute{
Optional: true,
MarkdownDescription: "Will trigger a replace of the resource when the value changes and is not `null`. This can be used by practitioners to force a replace of the resource when certain values change, e.g. changing the SKU of a virtual machine based on the value of variables or locals. " +
"The value is a `dynamic`, so practitioners can compose the input however they wish. For a \"break glass\" set the value to `null` to prevent the plan modifier taking effect. \n" +
"If you have `null` values that you do want to be tracked as affecting the resource replacement, include these inside an object. \n" +
"Advanced use cases are possible and resource replacement can be triggered by values external to the resource, for example when a dependent resource changes.\n\n" +
"e.g. to replace a resource when either the SKU or os_type attributes change:\n" +
"\n" +
"```hcl\n" +
"resource \"azapi_resource\" \"example\" {\n" +
" name = var.name\n" +
" type = \"Microsoft.Network/publicIPAddresses@2023-11-01\"\n" +
" parent_id = \"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example\"\n" +
" body = {\n" +
" properties = {\n" +
" sku = var.sku\n" +
" zones = var.zones\n" +
" }\n" +
" }\n" +
"\n" +
" replace_triggers_external_values = [\n" +
" var.sku,\n" +
" var.zones,\n" +
" ]\n" +
"}\n" +
"```\n",
PlanModifiers: []planmodifier.Dynamic{
planmodifierdynamic.RequiresReplaceIfNotNull(),
},
},
"replace_triggers_refs": schema.ListAttribute{
ElementType: types.StringType,
Optional: true,
MarkdownDescription: "A list of paths in the current Terraform configuration. When the values at these paths change, the resource will be replaced.",
},
"ignore_casing": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: defaults.BoolDefault(false),
MarkdownDescription: docstrings.IgnoreCasing(),
},
"ignore_missing_property": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: defaults.BoolDefault(true),
MarkdownDescription: docstrings.IgnoreMissingProperty(),
},
"response_export_values": schema.DynamicAttribute{
Optional: true,
PlanModifiers: []planmodifier.Dynamic{
myplanmodifier.DynamicUseStateWhen(dynamic.SemanticallyEqual),
},
MarkdownDescription: docstrings.ResponseExportValues(),
},
"locks": schema.ListAttribute{
ElementType: types.StringType,
Optional: true,
Validators: []validator.List{
listvalidator.ValueStringsAre(myvalidator.StringIsNotEmpty()),
},
MarkdownDescription: docstrings.Locks(),
},
"schema_validation_enabled": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: defaults.BoolDefault(true),
MarkdownDescription: docstrings.SchemaValidationEnabled(),
},
"output": schema.DynamicAttribute{
Computed: true,
MarkdownDescription: docstrings.Output("azapi_resource"),
},
"tags": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
Computed: true,
Validators: []validator.Map{
tags.Validator(),
},
MarkdownDescription: "A mapping of tags which should be assigned to the Azure resource.",
},
"retry": retry.RetrySchema(ctx),
"create_headers": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
MarkdownDescription: "A mapping of headers to be sent with the create request.",
},
"create_query_parameters": schema.MapAttribute{
ElementType: types.ListType{
ElemType: types.StringType,
},
Optional: true,
MarkdownDescription: "A mapping of query parameters to be sent with the create request.",
},
"update_headers": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
MarkdownDescription: "A mapping of headers to be sent with the update request.",
},
"update_query_parameters": schema.MapAttribute{
ElementType: types.ListType{
ElemType: types.StringType,
},
Optional: true,
MarkdownDescription: "A mapping of query parameters to be sent with the update request.",
},
"delete_headers": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
MarkdownDescription: "A mapping of headers to be sent with the delete request.",
},
"delete_query_parameters": schema.MapAttribute{
ElementType: types.ListType{
ElemType: types.StringType,
},
Optional: true,
MarkdownDescription: "A mapping of query parameters to be sent with the delete request.",
},
"read_headers": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
MarkdownDescription: "A mapping of headers to be sent with the read request.",
},
"read_query_parameters": schema.MapAttribute{
ElementType: types.ListType{
ElemType: types.StringType,
},
Optional: true,
MarkdownDescription: "A mapping of query parameters to be sent with the read request.",
},
},
Blocks: map[string]schema.Block{
"identity": schema.ListNestedBlock{
NestedObject: schema.NestedBlockObject{
Validators: []validator.Object{myvalidator.IdentityValidator()},
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Required: true,
Validators: []validator.String{stringvalidator.OneOf(
string(identity.SystemAssignedUserAssigned),
string(identity.UserAssigned),
string(identity.SystemAssigned),
string(identity.None),
)},
MarkdownDescription: docstrings.IdentityType(),
},
"identity_ids": schema.ListAttribute{
ElementType: types.StringType,
Optional: true,
Validators: []validator.List{
listvalidator.ValueStringsAre(myvalidator.StringIsUserAssignedIdentityID()),
},
MarkdownDescription: docstrings.IdentityIds(),
},
"principal_id": schema.StringAttribute{
Computed: true,
MarkdownDescription: docstrings.IdentityPrincipalID(),
},
"tenant_id": schema.StringAttribute{
Computed: true,
MarkdownDescription: docstrings.IdentityTenantID(),
},
},
},
},
"timeouts": timeouts.Block(ctx, timeouts.Opts{
Create: true,
Update: true,
Read: true,
Delete: true,
}),
},
Version: 2,
}
}
func (r *AzapiResource) ValidateConfig(ctx context.Context, request resource.ValidateConfigRequest, response *resource.ValidateConfigResponse) {
var config *AzapiResourceModel
if response.Diagnostics.Append(request.Config.Get(ctx, &config)...); response.Diagnostics.HasError() {
return
}
// destroy doesn't need to modify plan
if config == nil {
return
}
if config.Type.IsUnknown() {
return
}
resourceType := config.Type.ValueString()
// for resource group, if parent_id is not specified, set it to subscription id
if config.ParentID.IsNull() {
azureResourceType, _, _ := utils.GetAzureResourceTypeApiVersion(resourceType)
if !strings.EqualFold(azureResourceType, arm.ResourceGroupResourceType.String()) {
response.Diagnostics.AddError("Missing required argument", `The argument "parent_id" is required, but no definition was found.`)
return
}
}
if diags := validateDuplicatedDefinitions(config, config.Body); diags.HasError() {
response.Diagnostics.Append(diags...)
return
}
if config.SchemaValidationEnabled.IsNull() || config.SchemaValidationEnabled.ValueBool() {
if err := schemaValidate(config); err != nil {
response.Diagnostics.AddError("Invalid configuration", err.Error())
return
}
}
}
func (r *AzapiResource) ModifyPlan(ctx context.Context, request resource.ModifyPlanRequest, response *resource.ModifyPlanResponse) {
var config, state, plan *AzapiResourceModel
response.Diagnostics.Append(request.Config.Get(ctx, &config)...)
response.Diagnostics.Append(request.State.Get(ctx, &state)...)
response.Diagnostics.Append(request.Plan.Get(ctx, &plan)...)
if response.Diagnostics.HasError() {
return
}
// destroy doesn't need to modify plan
if config == nil {
return
}
defer func() {
response.Plan.Set(ctx, plan)
}()
// Output is a computed field, it defaults to unknown if there's any plan change
// It sets to the state if the state exists, and will set to unknown if the output needs to be updated
if state != nil {
plan.Output = state.Output
}
azureResourceType, apiVersion, err := utils.GetAzureResourceTypeApiVersion(config.Type.ValueString())
if err != nil {
response.Diagnostics.AddError("Invalid configuration", fmt.Sprintf(`The argument "type" is invalid: %s`, err.Error()))
return
}
resourceDef, _ := azure.GetResourceDefinition(azureResourceType, apiVersion)
// for resource group, if parent_id is not specified, set it to subscription id
if config.ParentID.IsNull() && strings.EqualFold(azureResourceType, arm.ResourceGroupResourceType.String()) {
plan.ParentID = types.StringValue(fmt.Sprintf("/subscriptions/%s", r.ProviderData.Account.GetSubscriptionId()))
}
if name, diags := r.nameWithDefaultNaming(config.Name); !diags.HasError() {
plan.Name = name
// replace the resource if the name is changed
if state != nil && !state.Name.Equal(plan.Name) {
response.RequiresReplace.Append(path.Root("name"))
}
} else {
response.Diagnostics.Append(diags...)
return
}
// if the config identity type and identity ids are not changed, use the state identity
if !config.Identity.IsNull() && state != nil && !state.Identity.IsNull() {
configIdentity := identity.FromList(config.Identity)
stateIdentity := identity.FromList(state.Identity)
if configIdentity.Type.Equal(stateIdentity.Type) && configIdentity.IdentityIDs.Equal(stateIdentity.IdentityIDs) {
plan.Identity = state.Identity
}
}
isNewResource := state == nil
if !dynamic.IsFullyKnown(plan.Body) || isNewResource || !plan.Identity.Equal(state.Identity) ||
!plan.Type.Equal(state.Type) ||
!plan.ResponseExportValues.Equal(state.ResponseExportValues) || !dynamic.SemanticallyEqual(plan.Body, state.Body) {
plan.Output = basetypes.NewDynamicUnknown()
}
if !dynamic.IsFullyKnown(plan.Body) {
if config.Tags.IsNull() {
plan.Tags = basetypes.NewMapUnknown(types.StringType)
}
if config.Location.IsNull() {
plan.Location = basetypes.NewStringUnknown()
}
}
// Set output as unknown to trigger a plan diff, if ephemral body has changed
diff, diags := ephemeralBodyChangeInPlan(ctx, request.Private, config.SensitiveBody)
if response.Diagnostics = append(response.Diagnostics, diags...); response.Diagnostics.HasError() {
return
}
if diff {
tflog.Info(ctx, `"sensitive_body" has changed`)
plan.Output = types.DynamicUnknown()
}
if dynamic.IsFullyKnown(plan.Body) {
plan.Tags = r.tagsWithDefaultTags(config.Tags, state, config.Body, resourceDef)
if state == nil || !state.Tags.Equal(plan.Tags) {
plan.Output = basetypes.NewDynamicUnknown()
}
// locationWithDefaultLocation will return the location in config if it's not null, otherwise it will return the default location if it supports location
plan.Location = r.locationWithDefaultLocation(config.Location, plan.Location, state, config.Body, resourceDef)
if state != nil && location.Normalize(state.Location.ValueString()) != location.Normalize(plan.Location.ValueString()) {
// if the location is changed, replace the resource
response.RequiresReplace.Append(path.Root("location"))
}
// Check if any paths in replace_triggers_refs have changed
if state != nil && plan != nil && !plan.ReplaceTriggersRefs.IsNull() {
refPaths := make(map[string]string)
for pathIndex, refPath := range AsStringList(plan.ReplaceTriggersRefs) {
refPaths[fmt.Sprintf("%d", pathIndex)] = refPath
}
// read previous values from state
stateData, err := dynamic.ToJSON(state.Body)
if err != nil {
response.Diagnostics.AddError("Invalid state body configuration", err.Error())
return
}
var stateModel interface{}
err = json.Unmarshal(stateData, &stateModel)
if err != nil {
response.Diagnostics.AddError("Invalid state body configuration", err.Error())
return
}
previousValues := flattenOutputJMES(stateModel, refPaths)
// read current values from plan
planData, err := dynamic.ToJSON(plan.Body)
if err != nil {
response.Diagnostics.AddError("Invalid plan body configuration", err.Error())
return
}
var planModel interface{}
err = json.Unmarshal(planData, &planModel)
if err != nil {
response.Diagnostics.AddError("Invalid plan body configuration", err.Error())
return
}
currentValues := flattenOutputJMES(planModel, refPaths)
// compare previous and current values
if !reflect.DeepEqual(previousValues, currentValues) {
response.RequiresReplace.Append(path.Root("body"))
}
}
}
if r.ProviderData.Features.EnablePreflight && isNewResource {
parentId := plan.ParentID.ValueString()
if parentId == "" {
placeholder, err := preflight.ParentIdPlaceholder(resourceDef, r.ProviderData.Account.GetSubscriptionId())
if err != nil {
return
}
parentId = placeholder
}
name := plan.Name.ValueString()
if name == "" {
name = preflight.NamePlaceholder()
}
err = preflight.Validate(ctx, r.ProviderData.ResourceClient, plan.Type.ValueString(), parentId, name, plan.Location.ValueString(), plan.Body, plan.Identity)
if err != nil {
response.Diagnostics.AddError("Preflight Validation: Invalid configuration", err.Error())
return
}
}
}
func (r *AzapiResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) {
r.CreateUpdate(ctx, request.Config, request.Plan, &response.State, &response.Diagnostics, response.Private)
}
func (r *AzapiResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) {
// See if we can skip the external API call (changes are to state only)
var plan, state AzapiResourceModel
if response.Diagnostics.Append(request.Plan.Get(ctx, &plan)...); response.Diagnostics.HasError() {
return
}
if response.Diagnostics.Append(request.State.Get(ctx, &state)...); response.Diagnostics.HasError() {
return
}
if skip.CanSkipExternalRequest(plan, state, "update") {
response.Diagnostics.Append(response.State.Set(ctx, plan)...)
tflog.Debug(ctx, "azapi_resource.CreateUpdate skipping external request as no unskippable changes were detected")
return
}
tflog.Debug(ctx, "azapi_resource.CreateUpdate proceeding with external request as no skippable changes were detected")
r.CreateUpdate(ctx, request.Config, request.Plan, &response.State, &response.Diagnostics, response.Private)
}
func (r *AzapiResource) CreateUpdate(ctx context.Context, requestConfig tfsdk.Config, requestPlan tfsdk.Plan, responseState *tfsdk.State, diagnostics *diag.Diagnostics, privateData PrivateData) {
var config, plan, state *AzapiResourceModel
diagnostics.Append(requestConfig.Get(ctx, &config)...)
diagnostics.Append(requestPlan.Get(ctx, &plan)...)
diagnostics.Append(responseState.Get(ctx, &state)...)
if diagnostics.HasError() {
return
}
id, err := parse.NewResourceID(plan.Name.ValueString(), plan.ParentID.ValueString(), plan.Type.ValueString())
if err != nil {
diagnostics.AddError("Invalid configuration", err.Error())
return
}
ctx = tflog.SetField(ctx, "resource_id", id.ID())
isNewResource := responseState == nil || responseState.Raw.IsNull()
ctx = tflog.SetField(ctx, "is_new_resource", isNewResource)
var timeout time.Duration
var diags diag.Diagnostics
if isNewResource {
timeout, diags = plan.Timeouts.Create(ctx, 30*time.Minute)
if diagnostics.Append(diags...); diagnostics.HasError() {
return
}
} else {
timeout, diags = plan.Timeouts.Update(ctx, 30*time.Minute)
if diagnostics.Append(diags...); diagnostics.HasError() {
return
}
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Ensure the context deadline has been set before calling ConfigureClientWithCustomRetry().
client := r.ProviderData.ResourceClient.ConfigureClientWithCustomRetry(ctx, plan.Retry, false)
if isNewResource {
// check if the resource already exists using the non-retry client to avoid issue where user specifies
// a FooResourceNotFound error as a retryable error
_, err = r.ProviderData.ResourceClient.Get(ctx, id.AzureResourceId, id.ApiVersion, clients.NewRequestOptions(AsMapOfString(plan.ReadHeaders), AsMapOfLists(plan.ReadQueryParameters)))
if err == nil {
diagnostics.AddError("Resource already exists", tf.ImportAsExistsError("azapi_resource", id.ID()).Error())
return
}
// 403 is returned if group (or child resource of group) does not exist, bug tracked at: https://github.com/Azure/azure-rest-api-specs/issues/9549
if !utils.ResponseErrorWasNotFound(err) && !(utils.ResponseWasForbidden(err) && isManagementGroupScope(id.ID())) {
diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("checking for presence of existing %s: %+v", id, err).Error())
return
}
}
// build the request body
body := make(map[string]interface{})
if err := unmarshalBody(plan.Body, &body); err != nil {
diagnostics.AddError("Invalid body", fmt.Sprintf(`The argument "body" is invalid: %s`, err.Error()))
return
}
if diagnostics.Append(expandBody(body, *plan)...); diagnostics.HasError() {
return
}
SensitiveBody := make(map[string]interface{})
if err := unmarshalBody(config.SensitiveBody, &SensitiveBody); err != nil {
diagnostics.AddError("Invalid sensitive_body", fmt.Sprintf(`The argument "sensitive_body" is invalid: %s`, err.Error()))
return
}
body = utils.MergeObject(body, SensitiveBody).(map[string]interface{})
if !isNewResource {
// handle the case that identity block was once set, now it's removed
if stateIdentity := identity.FromList(state.Identity); body["identity"] == nil && stateIdentity.Type.ValueString() != string(identity.None) {
noneIdentity := identity.Model{Type: types.StringValue(string(identity.None))}
out, _ := identity.ExpandIdentity(noneIdentity)
body["identity"] = out
}
}
// create/update the resource
lockIds := AsStringList(plan.Locks)
slices.Sort(lockIds)
for _, lockId := range lockIds {
locks.ByID(lockId)
defer locks.UnlockByID(lockId)
}
options := clients.NewRequestOptions(AsMapOfString(plan.CreateHeaders), AsMapOfLists(plan.CreateQueryParameters))
if !isNewResource {
options = clients.NewRequestOptions(AsMapOfString(plan.UpdateHeaders), AsMapOfLists(plan.UpdateQueryParameters))
}
_, err = client.CreateOrUpdate(ctx, id.AzureResourceId, id.ApiVersion, body, options)
if err != nil {
tflog.Debug(ctx, "azapi_resource.CreateUpdate client call create/update resource failed", map[string]interface{}{
"err": err,
})
if isNewResource {
if responseBody, err := client.Get(ctx, id.AzureResourceId, id.ApiVersion, clients.NewRequestOptions(AsMapOfString(plan.ReadHeaders), AsMapOfLists(plan.ReadQueryParameters))); err == nil {
// generate the computed fields
plan.ID = types.StringValue(id.ID())
var defaultOutput interface{}
if !r.ProviderData.Features.DisableDefaultOutput {
defaultOutput = id.ResourceDef.GetReadOnly(responseBody)
defaultOutput = utils.RemoveFields(defaultOutput, volatileFieldList())
}
output, err := buildOutputFromBody(responseBody, plan.ResponseExportValues, defaultOutput)
if err != nil {
diagnostics.AddError("Failed to build output", err.Error())
return
}
plan.Output = output
if bodyMap, ok := responseBody.(map[string]interface{}); ok {
if !plan.Identity.IsNull() {
planIdentity := identity.FromList(plan.Identity)
if v := identity.FlattenIdentity(bodyMap["identity"]); v != nil {
planIdentity.TenantID = v.TenantID
planIdentity.PrincipalID = v.PrincipalID
} else {
planIdentity.TenantID = types.StringNull()
planIdentity.PrincipalID = types.StringNull()
}
plan.Identity = identity.ToList(planIdentity)
}
}
diagnostics.Append(responseState.Set(ctx, plan)...)
}
}
diagnostics.AddError("Failed to create/update resource", fmt.Errorf("creating/updating %s: %+v", id, err).Error())
return
}
clientGetAfterPut := r.ProviderData.ResourceClient.ConfigureClientWithCustomRetry(ctx, plan.Retry, true)
tflog.Debug(ctx, "azapi_resource.CreateUpdate get resource after creation")
responseBody, err := clientGetAfterPut.Get(ctx, id.AzureResourceId, id.ApiVersion, clients.NewRequestOptions(AsMapOfString(plan.ReadHeaders), AsMapOfLists(plan.ReadQueryParameters)))
if err != nil {
if utils.ResponseErrorWasNotFound(err) {
tflog.Info(ctx, fmt.Sprintf("Error reading %q - removing from state", id.ID()))
responseState.RemoveResource(ctx)
return
}
diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %s: %+v", id, err).Error())
return
}
// generate the computed fields
plan.ID = types.StringValue(id.ID())
var defaultOutput interface{}
if !r.ProviderData.Features.DisableDefaultOutput {
defaultOutput = id.ResourceDef.GetReadOnly(responseBody)
defaultOutput = utils.RemoveFields(defaultOutput, volatileFieldList())
}
output, err := buildOutputFromBody(responseBody, plan.ResponseExportValues, defaultOutput)
if err != nil {
diagnostics.AddError("Failed to build output", err.Error())
return
}
plan.Output = output
if bodyMap, ok := responseBody.(map[string]interface{}); ok {
if !plan.Identity.IsNull() {
planIdentity := identity.FromList(plan.Identity)
if v := identity.FlattenIdentity(bodyMap["identity"]); v != nil {
planIdentity.TenantID = v.TenantID
planIdentity.PrincipalID = v.PrincipalID
} else {
planIdentity.TenantID = types.StringNull()
planIdentity.PrincipalID = types.StringNull()
}
plan.Identity = identity.ToList(planIdentity)
}
}
diagnostics.Append(responseState.Set(ctx, plan)...)
writeOnlyBytes, err := dynamic.ToJSON(config.SensitiveBody)
if err != nil {
diagnostics.AddError("Invalid sensitive_body", err.Error())
return
}
diagnostics.Append(ephemeralBodyPrivateMgr.Set(ctx, privateData, writeOnlyBytes)...)
}
func (r *AzapiResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) {
var model AzapiResourceModel
if response.Diagnostics.Append(request.State.Get(ctx, &model)...); response.Diagnostics.HasError() {
return
}
readTimeout, diags := model.Timeouts.Read(ctx, 5*time.Minute)
response.Diagnostics.Append(diags...)
if response.Diagnostics.HasError() {
return
}
// Ensure the context deadline has been set before calling ConfigureClientWithCustomRetry().
ctx, cancel := context.WithTimeout(ctx, readTimeout)
defer cancel()
id, err := parse.ResourceIDWithResourceType(model.ID.ValueString(), model.Type.ValueString())
if err != nil {
response.Diagnostics.AddError("Error parsing ID", err.Error())
return
}
ctx = tflog.SetField(ctx, "resource_id", id.ID())
client := r.ProviderData.ResourceClient.ConfigureClientWithCustomRetry(ctx, model.Retry, false)
responseBody, err := client.Get(ctx, id.AzureResourceId, id.ApiVersion, clients.NewRequestOptions(AsMapOfString(model.ReadHeaders), AsMapOfLists(model.ReadQueryParameters)))
if err != nil {
if utils.ResponseErrorWasNotFound(err) {
tflog.Info(ctx, fmt.Sprintf("Error reading %q - removing from state", id.ID()))
response.State.RemoveResource(ctx)
return
}
response.Diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %s: %+v", id, err).Error())
return
}
state := model
state.Name = types.StringValue(id.Name)
state.ParentID = types.StringValue(id.ParentId)
state.Type = types.StringValue(fmt.Sprintf("%s@%s", id.AzureResourceType, id.ApiVersion))
requestBody := make(map[string]interface{})
if err := unmarshalBody(model.Body, &requestBody); err != nil {
response.Diagnostics.AddError("Invalid body", fmt.Sprintf(`The argument "body" is invalid: %s`, err.Error()))
return
}
if bodyMap, ok := responseBody.(map[string]interface{}); ok {
if v, ok := bodyMap["location"]; ok && v != nil && location.Normalize(v.(string)) != location.Normalize(model.Location.ValueString()) {
state.Location = types.StringValue(v.(string))
}
if output := tags.FlattenTags(bodyMap["tags"]); len(output.Elements()) != 0 || len(state.Tags.Elements()) != 0 {
state.Tags = output
}
if requestBody["identity"] == nil {
// The following codes are used to reflect the actual changes of identity when it's not configured inside the body.
// And it suppresses the diff of nil identity and identity whose type is none.
identityFromResponse := identity.FlattenIdentity(bodyMap["identity"])
switch {
// Identity is not specified in config, and it's not in the response
case state.Identity.IsNull() && (identityFromResponse == nil || identityFromResponse.Type.ValueString() == string(identity.None)):
state.Identity = basetypes.NewListNull(identity.Model{}.ModelType())
// Identity is not specified in config, but it's in the response
case state.Identity.IsNull() && identityFromResponse != nil && identityFromResponse.Type.ValueString() != string(identity.None):
state.Identity = identity.ToList(*identityFromResponse)
// Identity is specified in config, but it's not in the response
case !state.Identity.IsNull() && identityFromResponse == nil:
stateIdentity := identity.FromList(state.Identity)
// skip when the configured identity type is none
if stateIdentity.Type.ValueString() == string(identity.None) {
// do nothing
} else {
state.Identity = basetypes.NewListNull(identity.Model{}.ModelType())
}
// Identity is specified in config, and it's in the response
case !state.Identity.IsNull() && identityFromResponse != nil:
stateIdentity := identity.FromList(state.Identity)
// suppress the diff of identity_ids = [] and identity_ids = null
if len(stateIdentity.IdentityIDs.Elements()) == 0 && len(identityFromResponse.IdentityIDs.Elements()) == 0 {
// to suppress the diff of identity_ids = [] and identity_ids = null
identityFromResponse.IdentityIDs = stateIdentity.IdentityIDs
}
state.Identity = identity.ToList(*identityFromResponse)
}
}
}
option := utils.UpdateJsonOption{
IgnoreCasing: model.IgnoreCasing.ValueBool(),
IgnoreMissingProperty: model.IgnoreMissingProperty.ValueBool(),
}
body := utils.UpdateObject(requestBody, responseBody, option)
data, err := json.Marshal(body)
if err != nil {
response.Diagnostics.AddError("Invalid body", err.Error())
return
}
var defaultOutput interface{}
if !r.ProviderData.Features.DisableDefaultOutput {
defaultOutput = id.ResourceDef.GetReadOnly(responseBody)
defaultOutput = utils.RemoveFields(defaultOutput, volatileFieldList())
}
output, err := buildOutputFromBody(responseBody, model.ResponseExportValues, defaultOutput)
if err != nil {
response.Diagnostics.AddError("Failed to build output", err.Error())
return
}
state.Output = output
if !model.Body.IsNull() {
payload, err := dynamic.FromJSON(data, model.Body.UnderlyingValue().Type(ctx))
if err != nil {
tflog.Warn(ctx, fmt.Sprintf("Failed to parse payload: %s", err.Error()))
payload, err = dynamic.FromJSONImplied(data)
if err != nil {
response.Diagnostics.AddError("Invalid payload", err.Error())
return
}
}
state.Body = payload
}
if v, _ := request.Private.GetKey(ctx, FlagMoveState); v != nil && string(v) == "true" {
payload, err := flattenBody(responseBody, id.ResourceDef)
if err != nil {
response.Diagnostics.AddError("Invalid body", err.Error())
return
}
state.Body = payload
response.Diagnostics.Append(response.Private.SetKey(ctx, FlagMoveState, []byte("false"))...)
}
response.Diagnostics.Append(response.State.Set(ctx, state)...)
}
func (r *AzapiResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) {
var model *AzapiResourceModel
if response.Diagnostics.Append(request.State.Get(ctx, &model)...); response.Diagnostics.HasError() {
return
}
id, err := parse.ResourceIDWithResourceType(model.ID.ValueString(), model.Type.ValueString())
if err != nil {
response.Diagnostics.AddError("Error parsing ID", err.Error())
return
}
ctx = tflog.SetField(ctx, "resource_id", id.ID())
deleteTimeout, diags := model.Timeouts.Delete(ctx, 30*time.Minute)
response.Diagnostics.Append(diags...)
if response.Diagnostics.HasError() {
return
}
// Ensure the context deadline has been set before calling ConfigureClientWithCustomRetry().
ctx, cancel := context.WithTimeout(ctx, deleteTimeout)
defer cancel()
client := r.ProviderData.ResourceClient.ConfigureClientWithCustomRetry(ctx, model.Retry, false)
lockIds := AsStringList(model.Locks)
slices.Sort(lockIds)
for _, lockId := range lockIds {
locks.ByID(lockId)
defer locks.UnlockByID(lockId)
}
_, err = client.Delete(ctx, id.AzureResourceId, id.ApiVersion, clients.NewRequestOptions(AsMapOfString(model.DeleteHeaders), AsMapOfLists(model.DeleteQueryParameters)))
if err != nil && !utils.ResponseErrorWasNotFound(err) {
response.Diagnostics.AddError("Failed to delete resource", fmt.Errorf("deleting %s: %+v", id, err).Error())
}
}
func (r *AzapiResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) {
tflog.Debug(ctx, fmt.Sprintf("Importing Resource - parsing %q", request.ID))
id, err := parse.ResourceID(request.ID)
if err != nil {
response.Diagnostics.AddError("Invalid Resource ID", fmt.Errorf("parsing Resource ID %q: %+v", request.ID, err).Error())
return
}
client := r.ProviderData.ResourceClient
state := r.defaultAzapiResourceModel()
state.ID = types.StringValue(id.ID())
state.Name = types.StringValue(id.Name)
state.ParentID = types.StringValue(id.ParentId)
state.Type = types.StringValue(fmt.Sprintf("%s@%s", id.AzureResourceType, id.ApiVersion))
responseBody, err := client.Get(ctx, id.AzureResourceId, id.ApiVersion, clients.NewRequestOptions(AsMapOfString(state.ReadHeaders), AsMapOfLists(state.ReadQueryParameters)))
if err != nil {
if utils.ResponseErrorWasNotFound(err) {
tflog.Info(ctx, fmt.Sprintf("[INFO] Error reading %q - removing from state", id.ID()))
response.State.RemoveResource(ctx)
return
}
response.Diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %s: %+v", id, err).Error())
return
}
tflog.Info(ctx, fmt.Sprintf("resource %q is imported", id.ID()))
payload, err := flattenBody(responseBody, id.ResourceDef)
if err != nil {
response.Diagnostics.AddError("Invalid body", err.Error())
return
}
state.Body = payload
if bodyMap, ok := responseBody.(map[string]interface{}); ok {
if v, ok := bodyMap["location"]; ok && v != nil {
state.Location = types.StringValue(location.Normalize(v.(string)))
}
if output := tags.FlattenTags(bodyMap["tags"]); len(output.Elements()) != 0 {
state.Tags = output
}
if v := identity.FlattenIdentity(bodyMap["identity"]); v != nil {
state.Identity = identity.ToList(*v)
}
}
var defaultOutput interface{}
if !r.ProviderData.Features.DisableDefaultOutput {
defaultOutput = id.ResourceDef.GetReadOnly(responseBody)
defaultOutput = utils.RemoveFields(defaultOutput, volatileFieldList())
}
output, err := buildOutputFromBody(responseBody, state.ResponseExportValues, defaultOutput)
if err != nil {
response.Diagnostics.AddError("Failed to build output", err.Error())
return
}
state.Output = output
response.Diagnostics.Append(response.State.Set(ctx, state)...)
}
func (r *AzapiResource) MoveState(ctx context.Context) []resource.StateMover {
return []resource.StateMover{
{
SourceSchema: &schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
},
},
},
StateMover: func(ctx context.Context, request resource.MoveStateRequest, response *resource.MoveStateResponse) {
if !strings.HasPrefix(request.SourceTypeName, "azurerm") {
response.Diagnostics.AddError("Invalid source type", "The `azapi_resource` resource can only be moved from an `azurerm` resource")
return
}
if request.SourceState == nil {
response.Diagnostics.AddError("Invalid source state", "The source state is nil")
return
}
requestID := ""
if response.Diagnostics.Append(request.SourceState.GetAttribute(ctx, path.Root("id"), &requestID)...); response.Diagnostics.HasError() {
return
}
if requestID == "" {
response.Diagnostics.AddError("Invalid source state", "The source state does not contain an id")
return
}
azureId, err := parse.AzurermIdToAzureId(request.SourceTypeName, requestID)
if err != nil {
response.Diagnostics.AddError("Invalid Resource ID", fmt.Errorf("parsing Resource ID %q: %+v", requestID, err).Error())
return
}
id, err := parse.ResourceID(azureId)
if err != nil {
response.Diagnostics.AddError("Invalid Resource ID", fmt.Errorf("parsing Resource ID %q: %+v", azureId, err).Error())
return
}
state := r.defaultAzapiResourceModel()
state.ID = types.StringValue(id.ID())
state.Name = types.StringValue(id.Name)
state.ParentID = types.StringValue(id.ParentId)
state.Type = types.StringValue(fmt.Sprintf("%s@%s", id.AzureResourceType, id.ApiVersion))
response.Diagnostics.Append(response.TargetPrivate.SetKey(ctx, FlagMoveState, []byte("true"))...)
response.Diagnostics.Append(response.TargetState.Set(ctx, state)...)
},
},
}
}
func (r *AzapiResource) nameWithDefaultNaming(config types.String) (types.String, diag.Diagnostics) {
if !config.IsNull() {
return config, diag.Diagnostics{}
}
if r.ProviderData.Features.DefaultNaming != "" {
return types.StringValue(r.ProviderData.Features.DefaultNaming), diag.Diagnostics{}
}
return types.StringNull(), diag.Diagnostics{
diag.NewErrorDiagnostic("Missing required argument", `The argument "name" is required, but no definition was found.`),
}
}
func (r *AzapiResource) tagsWithDefaultTags(config types.Map, state *AzapiResourceModel, body types.Dynamic, resourceDef *aztypes.ResourceType) types.Map {
// 1. use the tags in config if it's not null
if !config.IsNull() {
return config
}
// 2. use the tags in body if it's not null
if !body.IsNull() && !body.IsUnknown() && !body.IsUnderlyingValueNull() && !body.IsUnderlyingValueUnknown() {
if bodyObject, ok := body.UnderlyingValue().(types.Object); ok {
if v, ok := bodyObject.Attributes()["tags"]; ok && v != nil {
return tags.FlattenTags(v)
}
}
}
// 3. use the default tags if it's not null and the resource supports tags
if len(r.ProviderData.Features.DefaultTags) != 0 && canResourceHaveProperty(resourceDef, "tags") {
defaultTags := r.ProviderData.Features.DefaultTags
// if it's a new resource or the tags in state is null, use the default tags
if state == nil || state.Tags.IsNull() {
return tags.FlattenTags(defaultTags)
}
// if the tags in state is not null and the tags in state is not equal to the default tags, use the default tags
currentTags := tags.ExpandTags(state.Tags)
if !reflect.DeepEqual(currentTags, defaultTags) {
return tags.FlattenTags(defaultTags)
}
return state.Tags
}
// 4. To suppress the diff of config: tags = null and state: tags = {}
if state != nil && !state.Tags.IsUnknown() && len(state.Tags.Elements()) == 0 {
return state.Tags
}
// 5. return null if all the above cases are null
return types.MapNull(types.StringType)
}
func (r *AzapiResource) locationWithDefaultLocation(configLocation types.String, planLocation types.String, state *AzapiResourceModel, body types.Dynamic, resourceDef *aztypes.ResourceType) types.String {
// location field has a field level plan modifier which suppresses the diff if the location is not actually changed
config := planLocation
// For the following cases, we need to use the location in config as the specified location
// case 1. To create a new resource, the location is not specified in config, then the planned location will be unknown
// case 2. To update a resource, the location is not specified in config, then the planned location will be the state location
if config.IsUnknown() || configLocation.IsNull() {
config = configLocation
}
// 1. use the location in config if it's not null
if !config.IsNull() {
return config
}
// 2. use the location in body if it's not null
if !body.IsNull() && !body.IsUnknown() && !body.IsUnderlyingValueNull() && !body.IsUnderlyingValueUnknown() {
if bodyObject, ok := body.UnderlyingValue().(types.Object); ok {
if v, ok := bodyObject.Attributes()["location"]; ok && v != nil {
if strV, ok := v.(types.String); ok {
return strV
}
}
}
}
// 3. use the state location if it's not specified in config but returned by the API
if state != nil && state.Location.ValueString() != "" {
return state.Location
}
// 4. use the default location if it's not null and the resource supports location
if len(r.ProviderData.Features.DefaultLocation) != 0 && canResourceHaveProperty(resourceDef, "location") {
defaultLocation := r.ProviderData.Features.DefaultLocation
// if it's a new resource or the location in state is null, use the default location
if state == nil || state.Location.IsNull() {
return types.StringValue(defaultLocation)
}
// if the location in state is not null and the location in state is not equal to the default location, use the default location
currentLocation := state.Location.ValueString()
if location.Normalize(currentLocation) != location.Normalize(defaultLocation) {
return types.StringValue(defaultLocation)
}
return state.Location
}
// 5. To suppress the diff of config: location = null and state: location = ""
if state != nil && !state.Location.IsUnknown() && state.Location.ValueString() == "" {
return state.Location
}
// 6. return null if all the above cases are null
return types.StringNull()
}
func (r *AzapiResource) defaultAzapiResourceModel() AzapiResourceModel {
return AzapiResourceModel{
ID: types.StringNull(),
Name: types.StringNull(),
ParentID: types.StringNull(),
Type: types.StringNull(),
Location: types.StringNull(),
Body: types.Dynamic{},
Identity: types.ListNull(identity.Model{}.ModelType()),
IgnoreCasing: types.BoolValue(false),
IgnoreMissingProperty: types.BoolValue(true),
Locks: types.ListNull(types.StringType),
Output: types.DynamicNull(),
ReplaceTriggersExternalValues: types.DynamicNull(),
ReplaceTriggersRefs: types.ListNull(types.StringType),
ResponseExportValues: types.DynamicNull(),
Retry: retry.RetryValue{},
SchemaValidationEnabled: types.BoolValue(true),
Tags: types.MapNull(types.StringType),
Timeouts: timeouts.Value{
Object: types.ObjectNull(map[string]attr.Type{
"create": types.StringType,
"update": types.StringType,
"read": types.StringType,
"delete": types.StringType,
}),
},
CreateHeaders: types.MapNull(types.StringType),
CreateQueryParameters: types.MapNull(types.ListType{ElemType: types.StringType}),
UpdateHeaders: types.MapNull(types.StringType),
UpdateQueryParameters: types.MapNull(types.ListType{ElemType: types.StringType}),
DeleteHeaders: types.MapNull(types.StringType),
DeleteQueryParameters: types.MapNull(types.ListType{ElemType: types.StringType}),
ReadHeaders: types.MapNull(types.StringType),
ReadQueryParameters: types.MapNull(types.ListType{ElemType: types.StringType}),
}
}
func expandBody(body map[string]interface{}, model AzapiResourceModel) diag.Diagnostics {
if body == nil {
return diag.Diagnostics{}
}
if body["location"] == nil && !model.Location.IsNull() && !model.Location.IsUnknown() && len(model.Location.ValueString()) != 0 {
body["location"] = model.Location.ValueString()
}
if body["tags"] == nil && !model.Tags.IsNull() && !model.Tags.IsUnknown() && len(model.Tags.Elements()) != 0 {
body["tags"] = tags.ExpandTags(model.Tags)
}
if body["identity"] == nil && !model.Identity.IsNull() && !model.Identity.IsUnknown() {
identityModel := identity.FromList(model.Identity)
out, err := identity.ExpandIdentity(identityModel)
if err != nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("Invalid configuration", fmt.Sprintf(`The argument "identity" is invalid: value: %s, err: %+v`, model.Identity.String(), err)),
}
}
body["identity"] = out
}
return diag.Diagnostics{}
}
func validateDuplicatedDefinitions(model *AzapiResourceModel, body types.Dynamic) diag.Diagnostics {
diags := diag.Diagnostics{}
if body.IsNull() || body.IsUnknown() || body.IsUnderlyingValueNull() || body.IsUnderlyingValueUnknown() {
return diags
}
if bodyObject, ok := body.UnderlyingValue().(types.Object); ok {
if !model.Tags.IsNull() && !model.Tags.IsUnknown() && bodyObject.Attributes()["tags"] != nil {
diags.AddError("Invalid configuration", `can't specify both the argument "tags" and "tags" in the argument "body"`)
}
if !model.Location.IsNull() && !model.Location.IsUnknown() && bodyObject.Attributes()["location"] != nil {
diags.AddError("Invalid configuration", `can't specify both the argument "location" and "location" in the argument "body"`)
}
if !model.Identity.IsNull() && !model.Identity.IsUnknown() && bodyObject.Attributes()["identity"] != nil {
diags.AddError("Invalid configuration", `can't specify both the argument "identity" and "identity" in the argument "body"`)
}
}
return diags
}
func isManagementGroupScope(scope string) bool {
const managementGroupScope = "/providers/microsoft.management/managementgroups"
return strings.HasPrefix(
strings.ToLower(scope),
managementGroupScope,
)
}