internal/services/azapi_update_resource.go (485 lines of code) (raw):
package services
import (
"context"
"encoding/json"
"fmt"
"slices"
"time"
"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/myvalidator"
"github.com/Azure/terraform-provider-azapi/internal/services/parse"
"github.com/Azure/terraform-provider-azapi/internal/skip"
"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/diag"
"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"
)
type AzapiUpdateResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
ParentID types.String `tfsdk:"parent_id"`
ResourceID types.String `tfsdk:"resource_id"`
Type types.String `tfsdk:"type"`
Body types.Dynamic `tfsdk:"body"`
SensitiveBody types.Dynamic `tfsdk:"sensitive_body"`
IgnoreCasing types.Bool `tfsdk:"ignore_casing"`
IgnoreMissingProperty types.Bool `tfsdk:"ignore_missing_property"`
ResponseExportValues types.Dynamic `tfsdk:"response_export_values"`
Locks types.List `tfsdk:"locks"`
Output types.Dynamic `tfsdk:"output"`
Timeouts timeouts.Value `tfsdk:"timeouts" skip_on:"update"`
Retry retry.RetryValue `tfsdk:"retry" skip_on:"update"`
UpdateHeaders types.Map `tfsdk:"update_headers"`
UpdateQueryParameters types.Map `tfsdk:"update_query_parameters"`
ReadHeaders types.Map `tfsdk:"read_headers" skip_on:"update"`
ReadQueryParameters types.Map `tfsdk:"read_query_parameters" skip_on:"update"`
}
type AzapiUpdateResource struct {
ProviderData *clients.Client
}
var _ resource.Resource = &AzapiUpdateResource{}
var _ resource.ResourceWithConfigure = &AzapiUpdateResource{}
var _ resource.ResourceWithValidateConfig = &AzapiUpdateResource{}
var _ resource.ResourceWithModifyPlan = &AzapiUpdateResource{}
var _ resource.ResourceWithUpgradeState = &AzapiUpdateResource{}
func (r *AzapiUpdateResource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) {
if v, ok := request.ProviderData.(*clients.Client); ok {
r.ProviderData = v
}
}
func (r *AzapiUpdateResource) Metadata(ctx context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) {
response.TypeName = request.ProviderTypeName + "_update_resource"
}
func (r *AzapiUpdateResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{
0: migration.AzapiUpdateResourceMigrationV0ToV2(ctx),
1: migration.AzapiUpdateResourceMigrationV1ToV2(ctx),
}
}
func (r *AzapiUpdateResource) Schema(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) {
response.Schema = schema.Schema{
MarkdownDescription: "This resource can manage a subset of any existing Azure resource manager resource's properties.\n\n" +
"-> **Note** This resource is used to add or modify properties on an existing resource. When `azapi_update_resource` is deleted, no operation will be performed, and these properties will stay unchanged. If you want to restore the modified properties to some values, you must apply the restored properties before deleting.",
Description: "This resource can manage a subset of any existing Azure resource manager resource's properties.",
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(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
myvalidator.StringIsNotEmpty(),
},
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(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
myvalidator.StringIsResourceID(),
},
MarkdownDescription: docstrings.ParentID(),
},
"resource_id": schema.StringAttribute{
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
myvalidator.StringIsResourceID(),
},
MarkdownDescription: "The ID of an existing Azure source.",
},
"type": schema.StringAttribute{
Required: true,
Validators: []validator.String{
myvalidator.StringIsResourceType(),
},
MarkdownDescription: docstrings.Type(),
},
// 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,
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(),
},
"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(),
},
"output": schema.DynamicAttribute{
Computed: true,
MarkdownDescription: docstrings.Output("azapi_update_resource"),
},
"retry": retry.RetrySchema(ctx),
"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.",
},
"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{
"timeouts": timeouts.Block(ctx, timeouts.Opts{
Create: true,
Update: true,
Read: true,
Delete: true,
}),
},
Version: 2,
}
}
func (r *AzapiUpdateResource) ValidateConfig(ctx context.Context, request resource.ValidateConfigRequest, response *resource.ValidateConfigResponse) {
var config *AzapiUpdateResourceModel
if response.Diagnostics.Append(request.Config.Get(ctx, &config)...); response.Diagnostics.HasError() {
return
}
if config == nil {
return
}
if config.Name.IsNull() && !config.ParentID.IsNull() {
response.Diagnostics.AddError("Invalid configuration", `The argument "name" is required when the argument "parent_id" is set`)
}
if !config.Name.IsNull() && config.ParentID.IsNull() {
response.Diagnostics.AddError("Invalid configuration", `The argument "parent_id" is required when the argument "name" is set`)
}
if config.Name.IsNull() && config.ResourceID.IsNull() {
response.Diagnostics.AddError("Invalid configuration", `One of the arguments "name" or "resource_id" must be set`)
}
if !config.Name.IsNull() && !config.ResourceID.IsNull() {
response.Diagnostics.AddError("Invalid configuration", `Only one of the arguments "name" or "resource_id" can be set`)
}
if response.Diagnostics.HasError() {
return
}
if name := config.Name.ValueString(); name != "" {
parentId := config.ParentID.ValueString()
resourceType := config.Type.ValueString()
// verify parent_id when it's known
if parentId != "" {
_, err := parse.NewResourceID(name, parentId, resourceType)
if err != nil {
response.Diagnostics.AddError("Invalid configuration", err.Error())
return
}
}
}
}
func (r *AzapiUpdateResource) ModifyPlan(ctx context.Context, request resource.ModifyPlanRequest, response *resource.ModifyPlanResponse) {
var config, state, plan *AzapiUpdateResourceModel
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
}
// 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
if !plan.ResponseExportValues.Equal(state.ResponseExportValues) || !dynamic.SemanticallyEqual(plan.Body, state.Body) || !plan.Type.Equal(state.Type) {
plan.Output = basetypes.NewDynamicUnknown()
}
// 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()
}
}
response.Diagnostics.Append(response.Plan.Set(ctx, plan)...)
}
func (r *AzapiUpdateResource) 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 *AzapiUpdateResource) 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 AzapiUpdateResourceModel
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") {
tflog.Debug(ctx, "azapi_resource.CreateUpdate skipping external request as no unskippable changes were detected")
response.Diagnostics.Append(response.State.Set(ctx, plan)...)
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 *AzapiUpdateResource) CreateUpdate(ctx context.Context, requestConfig tfsdk.Config, plan tfsdk.Plan, state *tfsdk.State, diagnostics *diag.Diagnostics, privateData PrivateData) {
var config, model AzapiUpdateResourceModel
diagnostics.Append(requestConfig.Get(ctx, &config)...)
if diagnostics.Append(plan.Get(ctx, &model)...); diagnostics.HasError() {
return
}
isNewResource := state == nil || state.Raw.IsNull()
var timeout time.Duration
var diags diag.Diagnostics
if isNewResource {
timeout, diags = model.Timeouts.Create(ctx, 30*time.Minute)
if diagnostics.Append(diags...); diagnostics.HasError() {
return
}
} else {
timeout, diags = model.Timeouts.Update(ctx, 30*time.Minute)
if diagnostics.Append(diags...); diagnostics.HasError() {
return
}
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var id parse.ResourceId
// We need to ensure that the ID parsed in create and update is the same to produce consistent results.
// In update, all these fields are set, using resource_id and type is able to parse the parent_id and name which are used to build it.
// But using parent_id, name and type is not able to parse the original resource_id, because the last resource type segment comes from the type instead of the resource_id.
if resourceId := model.ResourceID.ValueString(); len(resourceId) != 0 {
buildId, err := parse.ResourceIDWithResourceType(model.ResourceID.ValueString(), model.Type.ValueString())
if err != nil {
diagnostics.AddError("Invalid configuration", err.Error())
return
}
id = buildId
} else {
buildId, err := parse.NewResourceID(model.Name.ValueString(), model.ParentID.ValueString(), model.Type.ValueString())
if err != nil {
diagnostics.AddError("Invalid configuration", err.Error())
return
}
id = buildId
}
ctx = tflog.SetField(ctx, "resource_id", id.ID())
// Ensure the context deadline has been set before calling ConfigureClientWithCustomRetry().
client := r.ProviderData.ResourceClient.ConfigureClientWithCustomRetry(ctx, model.Retry, false)
existing, err := client.Get(ctx, id.AzureResourceId, id.ApiVersion, clients.NewRequestOptions(AsMapOfString(model.ReadHeaders), AsMapOfLists(model.ReadQueryParameters)))
if err != nil {
diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("checking for presence of existing %s: %+v", id, err).Error())
return
}
if utils.GetId(existing) == nil {
diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("update target does not exist %s", id).Error())
return
}
var requestBody interface{}
if err := unmarshalBody(model.Body, &requestBody); err != nil {
diagnostics.AddError("Invalid body", fmt.Sprintf(`The argument "body" is invalid: err: %+v`, err))
return
}
if requestBody != nil {
requestBody = utils.MergeObject(existing, requestBody)
} else {
requestBody = existing
}
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
}
if SensitiveBody != nil {
requestBody = utils.MergeObject(requestBody, SensitiveBody)
}
if id.ResourceDef != nil {
requestBody = (*id.ResourceDef).GetWriteOnly(utils.NormalizeObject(requestBody))
}
lockIds := AsStringList(model.Locks)
slices.Sort(lockIds)
for _, lockId := range lockIds {
locks.ByID(lockId)
defer locks.UnlockByID(lockId)
}
_, err = client.CreateOrUpdate(ctx, id.AzureResourceId, id.ApiVersion, requestBody, clients.NewRequestOptions(AsMapOfString(model.UpdateHeaders), AsMapOfLists(model.UpdateQueryParameters)))
if err != nil {
diagnostics.AddError("Failed to update resource", fmt.Errorf("updating %q: %+v", id, err).Error())
return
}
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()))
state.RemoveResource(ctx)
return
}
diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %s: %+v", id, err).Error())
return
}
model.ID = basetypes.NewStringValue(id.ID())
model.Name = basetypes.NewStringValue(id.Name)
model.ParentID = basetypes.NewStringValue(id.ParentId)
model.ResourceID = basetypes.NewStringValue(id.AzureResourceId)
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 {
diagnostics.AddError("Failed to build output", err.Error())
return
}
model.Output = output
diagnostics.Append(state.Set(ctx, model)...)
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 *AzapiUpdateResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) {
var model AzapiUpdateResourceModel
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
}
ctx, cancel := context.WithTimeout(ctx, readTimeout)
defer cancel()
id, err := parse.ResourceIDWithResourceType(model.ID.ValueString(), model.Type.ValueString())
if err != nil {
response.Diagnostics.AddError("Invalid resource id", err.Error())
return
}
ctx = tflog.SetField(ctx, "resource_id", id.ID())
// Ensure the context deadline has been set before calling ConfigureClientWithCustomRetry().
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("[INFO] Error reading %q - removing from state", id.ID()))
response.State.RemoveResource(ctx)
return
}
response.Diagnostics.AddError("Failed to retrieve resource", fmt.Errorf("reading %q: %+v", id, err).Error())
return
}
state := model
state.ID = basetypes.NewStringValue(id.ID())
state.Name = basetypes.NewStringValue(id.Name)
state.ParentID = basetypes.NewStringValue(id.ParentId)
state.ResourceID = basetypes.NewStringValue(id.AzureResourceId)
state.Type = basetypes.NewStringValue(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
}
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
}
response.Diagnostics.Append(response.State.Set(ctx, state)...)
}
func (r *AzapiUpdateResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) {
}