Skip to content

Commit f4bdfde

Browse files
mawasilegithub-actions[bot]eduardodfmex
authored
feat: implement conflict handling and retry logic for environment operations (#767)
* feat: implement conflict handling and retry logic for environment operations * fix: add initial entry for conflict handling and retry logic in environment operations * refactor: simplify error handling in handleHttpConflict and clean up test code --------- Co-authored-by: github-actions[bot] <tfmod442916@users.noreply.github.com> Co-authored-by: Eduardo Sanchez <eduardodfmex@hotmail.com>
1 parent 92135d8 commit f4bdfde

File tree

10 files changed

+578
-4
lines changed

10 files changed

+578
-4
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: fixed
2+
body: 'feat: implement conflict handling and retry logic for environment operations'
3+
time: 2025-05-12T09:53:07.905836797Z
4+
custom:
5+
Issue: "767"

internal/services/environment/api_environment.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,10 @@ func (client *Client) DeleteEnvironment(ctx context.Context, environmentId strin
299299
}
300300

301301
if response.HttpResponse.StatusCode == http.StatusConflict {
302-
// the is another operation in progress, let's wait for it to complete, and try again
303-
tflog.Debug(ctx, "Another operation is in progress, waiting for it to complete")
304-
if err := client.Api.SleepWithContext(ctx, api.DefaultRetryAfter()); err != nil {
302+
err := client.handleHttpConflict(ctx, response)
303+
if err != nil {
305304
return err
306305
}
307-
308306
return client.DeleteEnvironment(ctx, environmentId)
309307
}
310308

@@ -451,6 +449,14 @@ func (client *Client) CreateEnvironment(ctx context.Context, environmentToCreate
451449
return nil, err
452450
}
453451

452+
if apiResponse.HttpResponse.StatusCode == http.StatusConflict {
453+
err := client.handleHttpConflict(ctx, apiResponse)
454+
if err != nil {
455+
return nil, err
456+
}
457+
return client.CreateEnvironment(ctx, environmentToCreate)
458+
}
459+
454460
if apiResponse.HttpResponse.StatusCode == http.StatusInternalServerError {
455461
return nil, customerrors.WrapIntoProviderError(nil, customerrors.ERROR_ENVIRONMENT_CREATION, string(apiResponse.BodyAsBytes))
456462
}
@@ -512,6 +518,14 @@ func (client *Client) UpdateEnvironmentAiFeatures(ctx context.Context, environme
512518
return err
513519
}
514520

521+
if apiResponse.HttpResponse.StatusCode == http.StatusConflict {
522+
err := client.handleHttpConflict(ctx, apiResponse)
523+
if err != nil {
524+
return err
525+
}
526+
return client.UpdateEnvironmentAiFeatures(ctx, environmentId, generativeAIConfig)
527+
}
528+
515529
lifecycleResponse, err := client.Api.DoWaitForLifecycleOperationStatus(ctx, apiResponse)
516530
if err != nil {
517531
return err
@@ -527,6 +541,19 @@ func (client *Client) UpdateEnvironmentAiFeatures(ctx context.Context, environme
527541
return nil
528542
}
529543

544+
func (client *Client) handleHttpConflict(ctx context.Context, apiResponse *api.Response) error {
545+
body := string(apiResponse.BodyAsBytes)
546+
if body == "" {
547+
return errors.New("environment failed with HTTP 409. No body in response")
548+
}
549+
// if 409 returns anything other than another ongoing lifecycle operation, fail the request and return the body as error to the user
550+
if !strings.Contains(body, "OperationNotStartable") {
551+
return errors.New("environment failed with HTTP 409. Body: " + body)
552+
}
553+
tflog.Debug(ctx, "Another lifecycle operation is in progress, waiting for it to complete")
554+
return client.Api.SleepWithContext(ctx, api.DefaultRetryAfter())
555+
}
556+
530557
func (client *Client) UpdateEnvironment(ctx context.Context, environmentId string, environment EnvironmentDto) (*EnvironmentDto, error) {
531558
if environment.Location != "" && environment.Properties.LinkedEnvironmentMetadata != nil && environment.Properties.LinkedEnvironmentMetadata.DomainName != "" {
532559
err := client.ValidateUpdateEnvironmentDetails(ctx, environment.Id, environment.Properties.LinkedEnvironmentMetadata.DomainName)
@@ -551,6 +578,14 @@ func (client *Client) UpdateEnvironment(ctx context.Context, environmentId strin
551578
return nil, err
552579
}
553580

581+
if apiResponse.HttpResponse.StatusCode == http.StatusConflict {
582+
err := client.handleHttpConflict(ctx, apiResponse)
583+
if err != nil {
584+
return nil, err
585+
}
586+
return client.UpdateEnvironment(ctx, environmentId, environment)
587+
}
588+
554589
// wait for the lifecycle operation to finish.
555590
lifecycleResponse, err := client.Api.DoWaitForLifecycleOperationStatus(ctx, apiResponse)
556591
if err != nil {

internal/services/environment/resource_environment_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,112 @@ import (
1919
"github.com/microsoft/terraform-provider-power-platform/internal/mocks"
2020
)
2121

22+
func TestUnitEnvironmentsResource_Validate_Do_Not_Retry_On_NoCapacity(t *testing.T) {
23+
httpmock.Activate()
24+
defer httpmock.DeactivateAndReset()
25+
26+
mocks.ActivateEnvironmentHttpMocks()
27+
28+
httpmock.RegisterResponder("POST", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2023-06-01",
29+
func(req *http.Request) (*http.Response, error) {
30+
resp := httpmock.NewStringResponse(http.StatusConflict, httpmock.File("tests/resource/Validate_Do_Not_Retry_On_NoCapacity/post_environment.json").String())
31+
return resp, nil
32+
})
33+
34+
resource.Test(t, resource.TestCase{
35+
IsUnitTest: true,
36+
ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories,
37+
Steps: []resource.TestStep{
38+
{
39+
ExpectError: regexp.MustCompile(".*InsufficientCapacity_StorageDriven.*"),
40+
Config: `
41+
resource "powerplatform_environment" "development" {
42+
display_name = "displayname"
43+
location = "europe"
44+
environment_type = "Sandbox"
45+
dataverse = {
46+
language_code = "1033"
47+
currency_code = "PLN"
48+
domain = "00000000-0000-0000-0000-000000000001"
49+
security_group_id = "00000000-0000-0000-0000-000000000000"
50+
}
51+
}`,
52+
53+
Check: resource.ComposeTestCheckFunc(),
54+
},
55+
},
56+
})
57+
}
58+
59+
func TestUnitEnvironmentsResource_Validate_Retry_On_Running_LifecycleOperation(t *testing.T) {
60+
httpmock.Activate()
61+
defer httpmock.DeactivateAndReset()
62+
63+
mocks.ActivateEnvironmentHttpMocks()
64+
65+
deleteRetryCount := 0
66+
lifecycleOperationInProgressCount := 5
67+
68+
httpmock.RegisterResponder("DELETE", `=~^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/([\d-]+)\z`,
69+
func(req *http.Request) (*http.Response, error) {
70+
resp := httpmock.NewStringResponse(http.StatusAccepted, "")
71+
resp.Header.Add("Location", "https://europe.api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/lifecycleOperations/00000000-0000-0000-0000-000000000001?api-version=2023-06-01")
72+
return resp, nil
73+
})
74+
75+
httpmock.RegisterResponder("GET", "https://europe.api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/lifecycleOperations/00000000-0000-0000-0000-000000000001?api-version=2023-06-01",
76+
func(req *http.Request) (*http.Response, error) {
77+
deleteRetryCount++
78+
return httpmock.NewStringResponse(http.StatusOK, httpmock.File(fmt.Sprintf("tests/resource/Validate_Retry_On_Running_LifecycleOperation/get_lifecycle_delete_%d.json", deleteRetryCount)).String()), nil
79+
})
80+
81+
httpmock.RegisterResponder("GET", "https://europe.api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/lifecycleOperations/b03e1e6d-73db-4367-90e1-2e378bf7e2fc?api-version=2023-06-01",
82+
func(req *http.Request) (*http.Response, error) {
83+
return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Retry_On_Running_LifecycleOperation/get_lifecycle.json").String()), nil
84+
})
85+
86+
httpmock.RegisterResponder("POST", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2023-06-01",
87+
func(req *http.Request) (*http.Response, error) {
88+
if lifecycleOperationInProgressCount > 0 {
89+
lifecycleOperationInProgressCount--
90+
resp := httpmock.NewStringResponse(http.StatusConflict, httpmock.File("tests/resource/Validate_Retry_On_Running_LifecycleOperation/post_environment_operation_in_progress.json").String())
91+
return resp, nil
92+
}
93+
resp := httpmock.NewStringResponse(http.StatusAccepted, "")
94+
resp.Header.Add("Location", "https://europe.api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/lifecycleOperations/b03e1e6d-73db-4367-90e1-2e378bf7e2fc?api-version=2023-06-01")
95+
return resp, nil
96+
})
97+
98+
httpmock.RegisterResponder("GET", `=~^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/([\d-]+)\z`,
99+
func(req *http.Request) (*http.Response, error) {
100+
id := httpmock.MustGetSubmatch(req, 1)
101+
return httpmock.NewStringResponse(http.StatusOK, httpmock.File(fmt.Sprintf("tests/resource/Validate_Retry_On_Running_LifecycleOperation/get_environment_%s.json", id)).String()), nil
102+
})
103+
104+
resource.Test(t, resource.TestCase{
105+
IsUnitTest: true,
106+
ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories,
107+
Steps: []resource.TestStep{
108+
{
109+
Config: `
110+
resource "powerplatform_environment" "development" {
111+
display_name = "displayname"
112+
location = "europe"
113+
environment_type = "Sandbox"
114+
dataverse = {
115+
language_code = "1033"
116+
currency_code = "PLN"
117+
domain = "00000000-0000-0000-0000-000000000001"
118+
security_group_id = "00000000-0000-0000-0000-000000000000"
119+
}
120+
}`,
121+
122+
Check: resource.ComposeTestCheckFunc(),
123+
},
124+
},
125+
})
126+
}
127+
22128
func TestUnitEnvironmentsResource_Validate_Retry_LifecycleOperation(t *testing.T) {
23129
httpmock.Activate()
24130
defer httpmock.DeactivateAndReset()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"error": {
3+
"code": "InsufficientCapacity_StorageDriven",
4+
"message": "This environment can't be created because your org (tenant) needs at least 1 GB of database capacity.",
5+
"detailUrlType": "NotSpecified"
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
{
2+
"id": "/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001",
3+
"type": "Microsoft.BusinessAppPlatform/scopes/environments",
4+
"location": "europe",
5+
"name": "00000000-0000-0000-0000-000000000001",
6+
"properties": {
7+
"tenantId": "123",
8+
"azureRegion": "westeurope",
9+
"displayName": "displayname",
10+
"createdTime": "2023-09-27T07:08:27.6057592Z",
11+
"createdBy": {
12+
"id": "f99f844b-ce3b-49ae-86f3-e374ecae789c",
13+
"displayName": "admin",
14+
"email": "admin",
15+
"type": "User",
16+
"tenantId": "123",
17+
"userPrincipalName": "admin"
18+
},
19+
"billingPolicy": {
20+
"id": ""
21+
},
22+
"lastModifiedTime": "2023-09-27T07:08:34.9205145Z",
23+
"provisioningState": "Succeeded",
24+
"creationType": "User",
25+
"environmentSku": "Sandbox",
26+
"isDefault": false,
27+
"capacity": [
28+
{
29+
"capacityType": "Database",
30+
"actualConsumption": 885.0391,
31+
"ratedConsumption": 1024.0,
32+
"capacityUnit": "MB",
33+
"updatedOn": "2023-10-10T03:00:35Z"
34+
},
35+
{
36+
"capacityType": "File",
37+
"actualConsumption": 1187.142,
38+
"ratedConsumption": 1187.142,
39+
"capacityUnit": "MB",
40+
"updatedOn": "2023-10-10T03:00:35Z"
41+
},
42+
{
43+
"capacityType": "Log",
44+
"actualConsumption": 0.0,
45+
"ratedConsumption": 0.0,
46+
"capacityUnit": "MB",
47+
"updatedOn": "2023-10-10T03:00:35Z"
48+
},
49+
{
50+
"capacityType": "FinOpsDatabase",
51+
"actualConsumption": 0.0,
52+
"ratedConsumption": 0.0,
53+
"capacityUnit": "MB",
54+
"updatedOn": "2023-10-10T03:00:35Z"
55+
},
56+
{
57+
"capacityType": "FinOpsFile",
58+
"actualConsumption": 0.0,
59+
"ratedConsumption": 0.0,
60+
"capacityUnit": "MB",
61+
"updatedOn": "2023-10-10T03:00:35Z"
62+
}
63+
],
64+
"addons": [],
65+
"clientUris": {
66+
"admin": "https://admin.powerplatform.microsoft.com/environments/environment/456/hub",
67+
"maker": "https://make.powerapps.com/environments/456/home"
68+
},
69+
"runtimeEndpoints": {
70+
"microsoft.BusinessAppPlatform": "https://europe.api.bap.microsoft.com",
71+
"microsoft.CommonDataModel": "https://europe.api.cds.microsoft.com",
72+
"microsoft.PowerApps": "https://europe.api.powerapps.com",
73+
"microsoft.PowerAppsAdvisor": "https://europe.api.advisor.powerapps.com",
74+
"microsoft.PowerVirtualAgents": "https://powervamg.eu-il107.gateway.prod.island.powerapps.com",
75+
"microsoft.ApiManagement": "https://management.EUROPE.azure-apihub.net",
76+
"microsoft.Flow": "https://emea.api.flow.microsoft.com"
77+
},
78+
"databaseType": "CommonDataService",
79+
"linkedEnvironmentMetadata": {
80+
"resourceId": "orgid",
81+
"friendlyName": "displayname",
82+
"uniqueName": "00000000-0000-0000-0000-000000000001",
83+
"domainName": "00000000-0000-0000-0000-000000000001",
84+
"version": "9.2.23092.00206",
85+
"instanceUrl": "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/",
86+
"instanceApiUrl": "https://00000000-0000-0000-0000-000000000001.api.crm4.dynamics.com",
87+
"baseLanguage": 1033,
88+
"instanceState": "Ready",
89+
"createdTime": "2023-09-27T07:08:28.957Z",
90+
"backgroundOperationsState": "Enabled",
91+
"scaleGroup": "EURCRMLIVESG705",
92+
"platformSku": "Standard",
93+
"schemaType": "Standard"
94+
},
95+
"trialScenarioType": "None",
96+
"notificationMetadata": {
97+
"state": "NotSpecified",
98+
"branding": "NotSpecific"
99+
},
100+
"retentionPeriod": "P7D",
101+
"states": {
102+
"management": {
103+
"id": "Ready"
104+
},
105+
"runtime": {
106+
"runtimeReasonCode": "NotSpecified",
107+
"requestedBy": {
108+
"displayName": "SYSTEM",
109+
"type": "NotSpecified"
110+
},
111+
"id": "Enabled"
112+
}
113+
},
114+
"updateCadence": {
115+
"id": "Moderate"
116+
},
117+
"retentionDetails": {
118+
"retentionPeriod": "P7D",
119+
"backupsAvailableFromDateTime": "2023-10-03T09:23:06.1717665Z"
120+
},
121+
"protectionStatus": {
122+
"keyManagedBy": "Microsoft"
123+
},
124+
"cluster": {
125+
"category": "Prod",
126+
"number": "107",
127+
"uriSuffix": "eu-il107.gateway.prod.island",
128+
"geoShortName": "EU",
129+
"environment": "Prod"
130+
},
131+
"connectedGroups": [],
132+
"lifecycleOperationsEnforcement": {
133+
"allowedOperations": [
134+
{
135+
"type": {
136+
"id": "DisableGovernanceConfiguration"
137+
},
138+
"reason": {
139+
"message": "DisableGovernanceConfiguration cannot be performed on Power Platform environment because of the governance configuration.",
140+
"type": "GovernanceConfig"
141+
}
142+
},
143+
{
144+
"type": {
145+
"id": "UpdateGovernanceConfiguration"
146+
},
147+
"reason": {
148+
"message": "UpdateGovernanceConfiguration cannot be performed on Power Platform environment because of the governance configuration.",
149+
"type": "GovernanceConfig"
150+
}
151+
}
152+
]
153+
},
154+
"governanceConfiguration": {
155+
"protectionLevel": "Basic"
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)