diff --git a/.changes/unreleased/Fixed-20260429-190243.yaml b/.changes/unreleased/Fixed-20260429-190243.yaml new file mode 100644 index 00000000..1f0e9c22 --- /dev/null +++ b/.changes/unreleased/Fixed-20260429-190243.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: Fixed `opslevel_service` dropping `preferred_api_document_source` after apply when `api_document_path` was not set, which caused a "Provider produced inconsistent result after apply" error +time: 2026-04-29T19:02:43.236755+05:30 diff --git a/opslevel/resource_opslevel_service.go b/opslevel/resource_opslevel_service.go index a242493a..b68934fb 100644 --- a/opslevel/resource_opslevel_service.go +++ b/opslevel/resource_opslevel_service.go @@ -309,19 +309,11 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest return } - if planModel.ApiDocumentPath.ValueString() != "" { - apiDocPath := planModel.ApiDocumentPath.ValueString() - if planModel.PreferredApiDocumentSource.IsNull() { - if _, err := r.client.ServiceApiDocSettingsUpdate(string(service.Id), apiDocPath, nil); err != nil { - resp.Diagnostics.AddError("opslevel client error", fmt.Sprintf("Unable to set provided 'api_document_path' %s for service. error: %s", apiDocPath, err)) - return - } - } else { - sourceEnum := opslevel.ApiDocumentSourceEnum(planModel.PreferredApiDocumentSource.ValueString()) - if _, err := r.client.ServiceApiDocSettingsUpdate(string(service.Id), apiDocPath, &sourceEnum); err != nil { - resp.Diagnostics.AddError("opslevel client error", fmt.Sprintf("Unable to set provided 'api_document_path' %s with doc source '%s' for service. error: %s", apiDocPath, sourceEnum, err)) - return - } + if !planModel.ApiDocumentPath.IsNull() || !planModel.PreferredApiDocumentSource.IsNull() { + apiDocPath, sourceEnum := serviceApiDocSettingsUpdateInput(planModel) + if _, err := r.client.ServiceApiDocSettingsUpdate(string(service.Id), apiDocPath, sourceEnum); err != nil { + resp.Diagnostics.AddError("opslevel client error", fmt.Sprintf("Unable to update API document settings for service %s. error: %s", service.Name, err)) + return } } @@ -477,33 +469,13 @@ func (r *ServiceResource) Update(ctx context.Context, req resource.UpdateRequest } } - if planModel.ApiDocumentPath.IsNull() { - if _, err := r.client.ServiceApiDocSettingsUpdate(string(service.Id), "", nil); err != nil { - resp.Diagnostics.AddError("opslevel client error", fmt.Sprintf("Unable to unset 'api_document_path' for service %s. error: %s", service.Name, err)) + if !planModel.ApiDocumentPath.Equal(stateModel.ApiDocumentPath) || + !planModel.PreferredApiDocumentSource.Equal(stateModel.PreferredApiDocumentSource) { + apiDocPath, sourceEnum := serviceApiDocSettingsUpdateInput(planModel) + if _, err := r.client.ServiceApiDocSettingsUpdate(string(service.Id), apiDocPath, sourceEnum); err != nil { + resp.Diagnostics.AddError("opslevel client error", fmt.Sprintf("Unable to update API document settings for service %s. error: %s", service.Name, err)) return } - } else { - apiDocPath := planModel.ApiDocumentPath.ValueString() - if planModel.PreferredApiDocumentSource.IsNull() { - if _, err := r.client.ServiceApiDocSettingsUpdate(string(service.Id), apiDocPath, nil); err != nil { - resp.Diagnostics.AddError("opslevel client error", - fmt.Sprintf( - "Unable to set provided 'api_document_path' %s for service. error: %s", - apiDocPath, err), - ) - return - } - } else { - sourceEnum := opslevel.ApiDocumentSourceEnum(planModel.PreferredApiDocumentSource.ValueString()) - if _, err := r.client.ServiceApiDocSettingsUpdate(string(service.Id), apiDocPath, &sourceEnum); err != nil { - resp.Diagnostics.AddError("opslevel client error", - fmt.Sprintf( - "Unable to set provided 'api_document_path' %s with doc source '%s' for service. error: %s", - apiDocPath, sourceEnum, err), - ) - return - } - } } // fetch the service again, since other mutations are performed after the create/update step @@ -561,3 +533,15 @@ func updateServiceNote(client opslevel.Client, service opslevel.Service, planMod return client.UpdateServiceNote(serviceNoteUpdateInput) } + +func serviceApiDocSettingsUpdateInput(plan ServiceResourceModel) (string, *opslevel.ApiDocumentSourceEnum) { + apiDocPath := "" + if !plan.ApiDocumentPath.IsNull() { + apiDocPath = plan.ApiDocumentPath.ValueString() + } + if plan.PreferredApiDocumentSource.IsNull() { + return apiDocPath, nil + } + sourceEnum := opslevel.ApiDocumentSourceEnum(plan.PreferredApiDocumentSource.ValueString()) + return apiDocPath, &sourceEnum +} diff --git a/opslevel/resource_opslevel_service_test.go b/opslevel/resource_opslevel_service_test.go new file mode 100644 index 00000000..ebe0ac2d --- /dev/null +++ b/opslevel/resource_opslevel_service_test.go @@ -0,0 +1,83 @@ +package opslevel + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + opslevelgo "github.com/opslevel/opslevel-go/v2026" +) + +func TestServiceApiDocSettingsUpdateInput(t *testing.T) { + testCases := []struct { + name string + plan ServiceResourceModel + expectedDocPath string + expectedDocSource *opslevelgo.ApiDocumentSourceEnum + }{ + { + name: "neither field set", + plan: ServiceResourceModel{ + ApiDocumentPath: types.StringNull(), + PreferredApiDocumentSource: types.StringNull(), + }, + expectedDocPath: "", + }, + { + name: "push source without path", + plan: ServiceResourceModel{ + ApiDocumentPath: types.StringNull(), + PreferredApiDocumentSource: types.StringValue(string(opslevelgo.ApiDocumentSourceEnumPush)), + }, + expectedDocPath: "", + expectedDocSource: &opslevelgo.ApiDocumentSourceEnumPush, + }, + { + name: "pull source without path", + plan: ServiceResourceModel{ + ApiDocumentPath: types.StringNull(), + PreferredApiDocumentSource: types.StringValue(string(opslevelgo.ApiDocumentSourceEnumPull)), + }, + expectedDocPath: "", + expectedDocSource: &opslevelgo.ApiDocumentSourceEnumPull, + }, + { + name: "path without source", + plan: ServiceResourceModel{ + ApiDocumentPath: types.StringValue("openapi.yaml"), + PreferredApiDocumentSource: types.StringNull(), + }, + expectedDocPath: "openapi.yaml", + }, + { + name: "path with source", + plan: ServiceResourceModel{ + ApiDocumentPath: types.StringValue("openapi.yaml"), + PreferredApiDocumentSource: types.StringValue(string(opslevelgo.ApiDocumentSourceEnumPush)), + }, + expectedDocPath: "openapi.yaml", + expectedDocSource: &opslevelgo.ApiDocumentSourceEnumPush, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + docPath, docSource := serviceApiDocSettingsUpdateInput(testCase.plan) + + if docPath != testCase.expectedDocPath { + t.Fatalf("expected doc path %q, got %q", testCase.expectedDocPath, docPath) + } + if testCase.expectedDocSource == nil { + if docSource != nil { + t.Fatalf("expected nil doc source, got %q", *docSource) + } + return + } + if docSource == nil { + t.Fatalf("expected doc source %q, got nil", *testCase.expectedDocSource) + } + if *docSource != *testCase.expectedDocSource { + t.Fatalf("expected doc source %q, got %q", *testCase.expectedDocSource, *docSource) + } + }) + } +} diff --git a/tests/service.tftest.hcl b/tests/service.tftest.hcl index 0ef0134c..dc58a465 100644 --- a/tests/service.tftest.hcl +++ b/tests/service.tftest.hcl @@ -238,6 +238,29 @@ run "resource_service_create_with_empty_optional_fields" { } +run "resource_service_create_with_preferred_api_document_source_without_path" { + + variables { + name = "New ${var.name} with API doc source only" + preferred_api_document_source = "PUSH" + } + + module { + source = "./opslevel_modules/modules/service" + } + + assert { + condition = opslevel_service.this.api_document_path == null + error_message = var.error_expected_null_field + } + + assert { + condition = opslevel_service.this.preferred_api_document_source == "PUSH" + error_message = "expected preferred_api_document_source to be persisted without api_document_path" + } + +} + run "resource_service_update_unset_optional_fields" { variables {