diff --git a/.changes/unreleased/added-20260330-165500-unmanaged-solution.yaml b/.changes/unreleased/added-20260330-165500-unmanaged-solution.yaml new file mode 100644 index 000000000..b9a1b9494 --- /dev/null +++ b/.changes/unreleased/added-20260330-165500-unmanaged-solution.yaml @@ -0,0 +1,5 @@ +kind: added +body: Added the `powerplatform_unmanaged_solution` resource for direct Dataverse solution CRUD without ZIP import lifecycle coupling. +time: 2026-03-30T16:55:00+11:00 +custom: + Issue: 1117 diff --git a/docs/data-sources/unmanaged_solution.md b/docs/data-sources/unmanaged_solution.md new file mode 100644 index 000000000..0fa4ed2c2 --- /dev/null +++ b/docs/data-sources/unmanaged_solution.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "powerplatform_unmanaged_solution Data Source - Power Platform" +subcategory: "" +description: |- + Fetches a single unmanaged Dataverse solution by unique name. +--- + +# powerplatform_unmanaged_solution (Data Source) + +Fetches a single unmanaged Dataverse solution by unique name. + +## Example Usage + +```terraform +data "powerplatform_unmanaged_solution" "example" { + environment_id = var.environment_id + uniquename = "TerraformUnmanagedSolution" +} +``` + + +## Schema + +### Required + +- `environment_id` (String) Id of the Dataverse-enabled environment containing the unmanaged solution. +- `uniquename` (String) Unique name of the unmanaged solution. + +### Optional + +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) + +### Read-Only + +- `description` (String) Description of the unmanaged solution. +- `display_name` (String) Display name of the unmanaged solution. +- `id` (String) Unique identifier of the unmanaged solution. +- `publisher_id` (String) Existing Dataverse publisher id that owns the solution. + + +### Nested Schema for `timeouts` + +Optional: + +- `read` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Read operations occur during any refresh or planning operation when refresh is enabled. diff --git a/docs/resources/unmanaged_solution.md b/docs/resources/unmanaged_solution.md new file mode 100644 index 000000000..7ddac0759 --- /dev/null +++ b/docs/resources/unmanaged_solution.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "powerplatform_unmanaged_solution Resource - Power Platform" +subcategory: "" +description: |- + Resource for managing unmanaged Dataverse solutions as first-class solution records without coupling lifecycle to solution ZIP import operations. +--- + +# powerplatform_unmanaged_solution (Resource) + +Resource for managing unmanaged Dataverse solutions as first-class solution records without coupling lifecycle to solution ZIP import operations. + +## Example Usage + +```terraform +resource "powerplatform_unmanaged_solution" "solution" { + environment_id = var.environment_id + uniquename = "TerraformUnmanagedSolution" + display_name = "Terraform Unmanaged Solution" + publisher_id = var.publisher_id + description = "Unmanaged solution created directly through the Dataverse solutions table." +} +``` + + +## Schema + +### Required + +- `display_name` (String) Display name of the solution. +- `environment_id` (String) Id of the Dataverse-enabled environment containing the unmanaged solution. +- `publisher_id` (String) Existing Dataverse publisher id that owns the solution. +- `uniquename` (String) Unique name of the solution. This is the stable solution identity in Dataverse. + +### Optional + +- `description` (String) Optional description of the unmanaged solution. +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) + +### Read-Only + +- `id` (String) Unique identifier of the unmanaged solution. + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). +- `delete` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Setting a timeout for a Delete operation is only applicable if changes are saved into state before the destroy operation occurs. +- `read` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Read operations occur during any refresh or planning operation when refresh is enabled. +- `update` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# Unmanaged solution resources can be imported using the provider id format {environment_id}_{solution_id} +terraform import powerplatform_unmanaged_solution.solution 00000000-0000-0000-0000-000000000000_00000000-0000-0000-0000-000000000001 +``` diff --git a/examples/data-sources/powerplatform_unmanaged_solution/data-source.tf b/examples/data-sources/powerplatform_unmanaged_solution/data-source.tf new file mode 100644 index 000000000..c43e6e8ef --- /dev/null +++ b/examples/data-sources/powerplatform_unmanaged_solution/data-source.tf @@ -0,0 +1,4 @@ +data "powerplatform_unmanaged_solution" "example" { + environment_id = var.environment_id + uniquename = "TerraformUnmanagedSolution" +} diff --git a/examples/data-sources/powerplatform_unmanaged_solution/outputs.tf b/examples/data-sources/powerplatform_unmanaged_solution/outputs.tf new file mode 100644 index 000000000..cccc464bf --- /dev/null +++ b/examples/data-sources/powerplatform_unmanaged_solution/outputs.tf @@ -0,0 +1,4 @@ +output "unmanaged_solution" { + description = "Unmanaged solution details." + value = data.powerplatform_unmanaged_solution.example +} diff --git a/examples/data-sources/powerplatform_unmanaged_solution/variables.tf b/examples/data-sources/powerplatform_unmanaged_solution/variables.tf new file mode 100644 index 000000000..0240485ce --- /dev/null +++ b/examples/data-sources/powerplatform_unmanaged_solution/variables.tf @@ -0,0 +1,4 @@ +variable "environment_id" { + type = string + description = "Power Platform environment id containing the unmanaged solution." +} diff --git a/examples/resources/powerplatform_unmanaged_solution/import.sh b/examples/resources/powerplatform_unmanaged_solution/import.sh new file mode 100644 index 000000000..ea3816932 --- /dev/null +++ b/examples/resources/powerplatform_unmanaged_solution/import.sh @@ -0,0 +1,2 @@ +# Unmanaged solution resources can be imported using the provider id format {environment_id}_{solution_id} +terraform import powerplatform_unmanaged_solution.solution 00000000-0000-0000-0000-000000000000_00000000-0000-0000-0000-000000000001 diff --git a/examples/resources/powerplatform_unmanaged_solution/outputs.tf b/examples/resources/powerplatform_unmanaged_solution/outputs.tf new file mode 100644 index 000000000..59ec60d50 --- /dev/null +++ b/examples/resources/powerplatform_unmanaged_solution/outputs.tf @@ -0,0 +1,4 @@ +output "unmanaged_solution_id" { + description = "Provider id of the unmanaged solution" + value = powerplatform_unmanaged_solution.solution.id +} diff --git a/examples/resources/powerplatform_unmanaged_solution/resource.tf b/examples/resources/powerplatform_unmanaged_solution/resource.tf new file mode 100644 index 000000000..778e01946 --- /dev/null +++ b/examples/resources/powerplatform_unmanaged_solution/resource.tf @@ -0,0 +1,7 @@ +resource "powerplatform_unmanaged_solution" "solution" { + environment_id = var.environment_id + uniquename = "TerraformUnmanagedSolution" + display_name = "Terraform Unmanaged Solution" + publisher_id = var.publisher_id + description = "Unmanaged solution created directly through the Dataverse solutions table." +} diff --git a/examples/resources/powerplatform_unmanaged_solution/variables.tf b/examples/resources/powerplatform_unmanaged_solution/variables.tf new file mode 100644 index 000000000..c32a99346 --- /dev/null +++ b/examples/resources/powerplatform_unmanaged_solution/variables.tf @@ -0,0 +1,9 @@ +variable "environment_id" { + type = string + description = "Power Platform environment id containing the unmanaged solution." +} + +variable "publisher_id" { + type = string + description = "Dataverse publisher id that will own the unmanaged solution." +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 65d94ef31..5c8bef76e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -399,6 +399,7 @@ func (p *PowerPlatformProvider) Resources(ctx context.Context) []func() resource func() resource.Resource { return application.NewEnvironmentApplicationPackageInstallResource() }, func() resource.Resource { return dlp_policy.NewDataLossPreventionPolicyResource() }, func() resource.Resource { return solution.NewSolutionResource() }, + func() resource.Resource { return solution.NewUnmanagedSolutionResource() }, func() resource.Resource { return tenant_settings.NewTenantSettingsResource() }, func() resource.Resource { return managed_environment.NewManagedEnvironmentResource() }, func() resource.Resource { return licensing.NewBillingPolicyEnvironmentResource() }, @@ -431,6 +432,7 @@ func (p *PowerPlatformProvider) DataSources(ctx context.Context) []func() dataso func() datasource.DataSource { return environment.NewEnvironmentsDataSource() }, func() datasource.DataSource { return environment_templates.NewEnvironmentTemplatesDataSource() }, func() datasource.DataSource { return solution.NewSolutionsDataSource() }, + func() datasource.DataSource { return solution.NewUnmanagedSolutionDataSource() }, func() datasource.DataSource { return dlp_policy.NewDataLossPreventionPolicyDataSource() }, func() datasource.DataSource { return tenant_settings.NewTenantSettingsDataSource() }, func() datasource.DataSource { return licensing.NewBillingPoliciesDataSource() }, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 57ace85a9..669ad1afc 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -56,6 +56,7 @@ func TestUnitPowerPlatformProviderHasChildDataSources_Basic(t *testing.T) { application.NewEnvironmentApplicationPackagesDataSource(), connectors.NewConnectorsDataSource(), solution.NewSolutionsDataSource(), + solution.NewUnmanagedSolutionDataSource(), dlp_policy.NewDataLossPreventionPolicyDataSource(), tenant_settings.NewTenantSettingsDataSource(), licensing.NewBillingPoliciesDataSource(), @@ -89,6 +90,7 @@ func TestUnitPowerPlatformProviderHasChildResources_Basic(t *testing.T) { application.NewEnvironmentApplicationPackageInstallResource(), dlp_policy.NewDataLossPreventionPolicyResource(), solution.NewSolutionResource(), + solution.NewUnmanagedSolutionResource(), tenant_settings.NewTenantSettingsResource(), managed_environment.NewManagedEnvironmentResource(), licensing.NewBillingPolicyResource(), diff --git a/internal/services/solution/api_solution.go b/internal/services/solution/api_solution.go index 848940eb7..43ecf5cb8 100644 --- a/internal/services/solution/api_solution.go +++ b/internal/services/solution/api_solution.go @@ -11,6 +11,7 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strings" "github.com/microsoft/terraform-provider-power-platform/internal/api" @@ -19,6 +20,8 @@ import ( "github.com/microsoft/terraform-provider-power-platform/internal/helpers" ) +var solutionEntityHeaderIDPattern = regexp.MustCompile(`solutions\(([^)]+)\)`) + func NewSolutionClient(apiClient *api.Client) Client { return Client{ Api: apiClient, @@ -258,6 +261,115 @@ func (client *Client) CreateSolution(ctx context.Context, environmentId string, } } +func (client *Client) CreateUnmanagedSolution(ctx context.Context, environmentId, uniqueName, displayName, publisherId, description string) (*SolutionDto, error) { + environmentHost, err := client.GetEnvironmentHostById(ctx, environmentId) + if err != nil { + return nil, err + } + + requestBody := createUnmanagedSolutionDto{ + Description: description, + DisplayName: displayName, + PublisherBind: fmt.Sprintf("/publishers(%s)", publisherId), + UniqueName: uniqueName, + Version: "1.0.0.0", + } + + apiUrl := &url.URL{ + Scheme: constants.HTTPS, + Host: environmentHost, + Path: "/api/data/v9.0/solutions", + } + + createdSolution := SolutionDto{} + resp, err := client.Api.Execute(ctx, nil, "POST", apiUrl.String(), nil, requestBody, []int{http.StatusCreated, http.StatusNoContent, http.StatusForbidden, http.StatusNotFound}, &createdSolution) + if err != nil { + return nil, err + } + if err := client.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := client.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + solutionID := "" + if createdSolution.Id != "" { + solutionID = createdSolution.Id + } + if entityId := resp.GetHeader("OData-EntityId"); entityId != "" { + if parsedSolutionID, ok := tryExtractSolutionIDFromEntityHeader(entityId); ok { + solutionID = parsedSolutionID + } + } + + return client.WaitForUnmanagedSolution(ctx, environmentId, solutionID, uniqueName) +} + +func (client *Client) UpdateUnmanagedSolution(ctx context.Context, environmentId, solutionId, displayName, description string) (*SolutionDto, error) { + environmentHost, err := client.GetEnvironmentHostById(ctx, environmentId) + if err != nil { + return nil, err + } + + requestBody := updateUnmanagedSolutionDto{ + Description: description, + DisplayName: displayName, + } + + apiUrl := &url.URL{ + Scheme: constants.HTTPS, + Host: environmentHost, + Path: fmt.Sprintf("/api/data/v9.0/solutions(%s)", solutionId), + } + + resp, err := client.Api.Execute(ctx, nil, "PATCH", apiUrl.String(), nil, requestBody, []int{http.StatusNoContent, http.StatusForbidden, http.StatusNotFound}, nil) + if err != nil { + return nil, err + } + if err := client.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := client.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + return client.GetSolutionById(ctx, environmentId, solutionId) +} + +func (client *Client) WaitForUnmanagedSolution(ctx context.Context, environmentId, solutionId, uniqueName string) (*SolutionDto, error) { + var lastErr error + + for { + var ( + solution *SolutionDto + err error + ) + + if solutionId != "" { + solution, err = client.GetSolutionById(ctx, environmentId, solutionId) + } else { + solution, err = client.GetSolutionUniqueName(ctx, environmentId, uniqueName) + } + + if err == nil { + return solution, nil + } + + if !errors.Is(err, customerrors.ErrObjectNotFound) { + return nil, err + } + + lastErr = err + if err := client.Api.SleepWithContext(ctx, api.DefaultRetryAfter()); err != nil { + if lastErr != nil { + return nil, fmt.Errorf("timed out waiting for unmanaged solution '%s' to become readable: %w", uniqueName, lastErr) + } + return nil, err + } + } +} + func (client *Client) createSolutionComponentParameters(settings []byte) ([]any, error) { if len(settings) == 0 { return nil, nil @@ -384,6 +496,14 @@ func (client *Client) GetEnvironmentHostById(ctx context.Context, environmentId return envUrl.Host, nil } +func tryExtractSolutionIDFromEntityHeader(entityID string) (string, bool) { + matches := solutionEntityHeaderIDPattern.FindStringSubmatch(entityID) + if len(matches) != 2 { + return "", false + } + return matches[1], true +} + func (client *Client) getEnvironment(ctx context.Context, environmentId string) (*environmentIdDto, error) { apiUrl := &url.URL{ Scheme: constants.HTTPS, diff --git a/internal/services/solution/datasource_unmanaged_solution.go b/internal/services/solution/datasource_unmanaged_solution.go new file mode 100644 index 000000000..8e90baeaa --- /dev/null +++ b/internal/services/solution/datasource_unmanaged_solution.go @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package solution + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/microsoft/terraform-provider-power-platform/internal/api" + "github.com/microsoft/terraform-provider-power-platform/internal/customerrors" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" +) + +var ( + _ datasource.DataSource = &UnmanagedSolutionDataSource{} + _ datasource.DataSourceWithConfigure = &UnmanagedSolutionDataSource{} +) + +func NewUnmanagedSolutionDataSource() datasource.DataSource { + return &UnmanagedSolutionDataSource{ + TypeInfo: helpers.TypeInfo{ + TypeName: "unmanaged_solution", + }, + } +} + +func (d *UnmanagedSolutionDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + d.ProviderTypeName = req.ProviderTypeName + + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + resp.TypeName = d.FullTypeName() + tflog.Debug(ctx, fmt.Sprintf("METADATA: %s", resp.TypeName)) +} + +func (d *UnmanagedSolutionDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + resp.Schema = schema.Schema{ + MarkdownDescription: "Fetches a single unmanaged Dataverse solution by unique name.", + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Read: true, + }), + "id": schema.StringAttribute{ + MarkdownDescription: "Unique identifier of the unmanaged solution.", + Computed: true, + }, + "environment_id": schema.StringAttribute{ + MarkdownDescription: "Id of the Dataverse-enabled environment containing the unmanaged solution.", + Required: true, + }, + "uniquename": schema.StringAttribute{ + MarkdownDescription: "Unique name of the unmanaged solution.", + Required: true, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name of the unmanaged solution.", + Computed: true, + }, + "publisher_id": schema.StringAttribute{ + MarkdownDescription: "Existing Dataverse publisher id that owns the solution.", + Computed: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Description of the unmanaged solution.", + Computed: true, + }, + }, + } +} + +func (d *UnmanagedSolutionDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*api.ProviderClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected ProviderData Type", + fmt.Sprintf("Expected *api.ProviderClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.SolutionClient = NewSolutionClient(client.Api) +} + +func (d *UnmanagedSolutionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + var state UnmanagedSolutionDataSourceModel + resp.Diagnostics.Append(resp.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + dvExists, err := d.SolutionClient.DataverseExists(ctx, state.EnvironmentId.ValueString()) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when checking if Dataverse exists in environment '%s'", state.EnvironmentId.ValueString()), err.Error()) + return + } + + if !dvExists { + resp.Diagnostics.AddError( + fmt.Sprintf("No Dataverse exists in environment '%s'", state.EnvironmentId.ValueString()), + fmt.Sprintf("The environment '%s' does not have Dataverse enabled or provisioned. Unmanaged solutions require a Dataverse environment. Verify that the environment ID is correct and that Dataverse has been created for this environment.", state.EnvironmentId.ValueString()), + ) + return + } + + solution, err := d.SolutionClient.GetSolutionUniqueName(ctx, state.EnvironmentId.ValueString(), state.UniqueName.ValueString()) + if err != nil { + if errors.Is(err, customerrors.ErrObjectNotFound) { + resp.Diagnostics.AddError( + fmt.Sprintf("Unmanaged solution '%s' not found", state.UniqueName.ValueString()), + fmt.Sprintf("No unmanaged solution with unique name '%s' was found in environment '%s'.", state.UniqueName.ValueString(), state.EnvironmentId.ValueString()), + ) + return + } + + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading %s", d.FullTypeName()), err.Error()) + return + } + if err := validateUnmanagedSolution(solution, d.FullTypeName()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading %s", d.FullTypeName()), err.Error()) + return + } + + setUnmanagedSolutionDataSourceState(&state, solution) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func setUnmanagedSolutionDataSourceState(model *UnmanagedSolutionDataSourceModel, solution *SolutionDto) { + model.Id = types.StringValue(solution.Id) + model.UniqueName = types.StringValue(solution.Name) + model.DisplayName = types.StringValue(solution.DisplayName) + model.PublisherId = types.StringValue(solution.PublisherId) + + if solution.Description == "" { + model.Description = types.StringNull() + } else { + model.Description = types.StringValue(solution.Description) + } +} diff --git a/internal/services/solution/datasource_unmanaged_solution_test.go b/internal/services/solution/datasource_unmanaged_solution_test.go new file mode 100644 index 000000000..b8f2e7642 --- /dev/null +++ b/internal/services/solution/datasource_unmanaged_solution_test.go @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package solution_test + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/jarcoal/httpmock" + "github.com/microsoft/terraform-provider-power-platform/internal/mocks" +) + +func TestUnitUnmanagedSolutionDataSource_Validate_Read(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=uniquename+eq+%27TerraformTestSolution%27", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_solution.json").String()), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + data "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.solution", "id", unmanagedSolutionID), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.solution", "environment_id", unmanagedSolutionEnvironmentID), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.solution", "uniquename", "TerraformTestSolution"), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.solution", "display_name", "Terraform Test Solution"), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.solution", "publisher_id", unmanagedPublisherID), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.solution", "description", "Created by Terraform"), + ), + }, + }, + }) +} + +func TestUnitUnmanagedSolutionDataSource_Validate_No_Dataverse(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_No_Dataverse/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + data "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + }`, + ExpectError: regexp.MustCompile(fmt.Sprintf("No Dataverse exists in environment '%s'", unmanagedSolutionEnvironmentID)), + }, + }, + }) +} + +func TestUnitUnmanagedSolutionDataSource_Validate_Not_Found(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=uniquename+eq+%27MissingSolution%27", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + data "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "MissingSolution" + }`, + ExpectError: regexp.MustCompile("Unmanaged solution 'MissingSolution' not found"), + }, + }, + }) +} + +func TestUnitUnmanagedSolutionDataSource_Validate_Managed_Solution(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=uniquename+eq+%27ManagedSolution%27", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{ + "value": [ + { + "solutionid": "`+unmanagedSolutionID+`", + "uniquename": "ManagedSolution", + "friendlyname": "Managed Solution", + "_publisherid_value": "`+unmanagedPublisherID+`", + "ismanaged": true + } + ] + }`), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + data "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "ManagedSolution" + }`, + ExpectError: regexp.MustCompile(`solution 'ManagedSolution' is managed and cannot be used with\s+powerplatform_unmanaged_solution`), + }, + }, + }) +} diff --git a/internal/services/solution/dto.go b/internal/services/solution/dto.go index 47df9a677..3506a53ab 100644 --- a/internal/services/solution/dto.go +++ b/internal/services/solution/dto.go @@ -24,6 +24,8 @@ type SolutionDto struct { EnvironmentId string `json:"environment_id"` Name string `json:"uniquename"` DisplayName string `json:"friendlyname"` + Description string `json:"description"` + PublisherId string `json:"_publisherid_value"` IsManaged bool `json:"ismanaged"` CreatedTime string `json:"createdon"` Version string `json:"version"` @@ -112,6 +114,19 @@ type importSolutionEnvironmentVariablesDto struct { Value string `json:"value"` } +type createUnmanagedSolutionDto struct { + Description string `json:"description"` + DisplayName string `json:"friendlyname"` + PublisherBind string `json:"publisherid@odata.bind"` + UniqueName string `json:"uniquename"` + Version string `json:"version"` +} + +type updateUnmanagedSolutionDto struct { + Description string `json:"description"` + DisplayName string `json:"friendlyname"` +} + type asyncSolutionPullResponseDto struct { AsyncOperationId string `json:"AsyncOperationId"` CreatedOn string `json:"createdon"` diff --git a/internal/services/solution/models.go b/internal/services/solution/models.go index fbf1b4767..8e3689cb5 100644 --- a/internal/services/solution/models.go +++ b/internal/services/solution/models.go @@ -63,3 +63,33 @@ type ResourceModel struct { IsManaged types.Bool `tfsdk:"is_managed"` DisplayName types.String `tfsdk:"display_name"` } + +type UnmanagedSolutionResource struct { + helpers.TypeInfo + SolutionClient Client +} + +type UnmanagedSolutionResourceModel struct { + Timeouts timeouts.Value `tfsdk:"timeouts"` + Id types.String `tfsdk:"id"` + EnvironmentId types.String `tfsdk:"environment_id"` + UniqueName types.String `tfsdk:"uniquename"` + DisplayName types.String `tfsdk:"display_name"` + PublisherId types.String `tfsdk:"publisher_id"` + Description types.String `tfsdk:"description"` +} + +type UnmanagedSolutionDataSource struct { + helpers.TypeInfo + SolutionClient Client +} + +type UnmanagedSolutionDataSourceModel struct { + Timeouts timeouts.Value `tfsdk:"timeouts"` + Id types.String `tfsdk:"id"` + EnvironmentId types.String `tfsdk:"environment_id"` + UniqueName types.String `tfsdk:"uniquename"` + DisplayName types.String `tfsdk:"display_name"` + PublisherId types.String `tfsdk:"publisher_id"` + Description types.String `tfsdk:"description"` +} diff --git a/internal/services/solution/resource_unmanaged_solution.go b/internal/services/solution/resource_unmanaged_solution.go new file mode 100644 index 000000000..54db1ab76 --- /dev/null +++ b/internal/services/solution/resource_unmanaged_solution.go @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package solution + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "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/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/microsoft/terraform-provider-power-platform/internal/api" + "github.com/microsoft/terraform-provider-power-platform/internal/customerrors" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" +) + +var _ resource.Resource = &UnmanagedSolutionResource{} +var _ resource.ResourceWithImportState = &UnmanagedSolutionResource{} + +func NewUnmanagedSolutionResource() resource.Resource { + return &UnmanagedSolutionResource{ + TypeInfo: helpers.TypeInfo{ + TypeName: "unmanaged_solution", + }, + } +} + +func (r *UnmanagedSolutionResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + r.ProviderTypeName = req.ProviderTypeName + + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + resp.TypeName = r.FullTypeName() + tflog.Debug(ctx, fmt.Sprintf("METADATA: %s", resp.TypeName)) +} + +func (r *UnmanagedSolutionResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + resp.Schema = schema.Schema{ + MarkdownDescription: "Resource for managing unmanaged Dataverse solutions as first-class solution records without coupling lifecycle to solution ZIP import operations.", + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Update: true, + Delete: true, + Read: true, + }), + "id": schema.StringAttribute{ + MarkdownDescription: "Unique identifier of the unmanaged solution.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "environment_id": schema.StringAttribute{ + MarkdownDescription: "Id of the Dataverse-enabled environment containing the unmanaged solution.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "uniquename": schema.StringAttribute{ + MarkdownDescription: "Unique name of the solution. This is the stable solution identity in Dataverse.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name of the solution.", + Required: true, + }, + "publisher_id": schema.StringAttribute{ + MarkdownDescription: "Existing Dataverse publisher id that owns the solution.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Optional description of the unmanaged solution.", + Optional: true, + }, + }, + } +} + +func (r *UnmanagedSolutionResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*api.ProviderClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected ProviderData Type", + fmt.Sprintf("Expected *api.ProviderClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.SolutionClient = NewSolutionClient(client.Api) +} + +func (r *UnmanagedSolutionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan UnmanagedSolutionResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + dvExists, err := r.SolutionClient.DataverseExists(ctx, plan.EnvironmentId.ValueString()) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when checking if Dataverse exists in environment '%s'", plan.EnvironmentId.ValueString()), err.Error()) + return + } + if !dvExists { + resp.Diagnostics.AddError( + fmt.Sprintf("No Dataverse exists in environment '%s'", plan.EnvironmentId.ValueString()), + fmt.Sprintf("Unmanaged solutions can only be created in Dataverse-enabled environments. The environment '%s' does not have Dataverse provisioned, so solution creation cannot continue. Verify that the target environment is Dataverse-enabled and provision Dataverse before retrying.", plan.EnvironmentId.ValueString()), + ) + return + } + + solution, err := r.SolutionClient.CreateUnmanagedSolution( + ctx, + plan.EnvironmentId.ValueString(), + plan.UniqueName.ValueString(), + plan.DisplayName.ValueString(), + plan.PublisherId.ValueString(), + plan.Description.ValueString(), + ) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when creating %s", r.FullTypeName()), err.Error()) + return + } + if err := validateUnmanagedSolution(solution, r.FullTypeName()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when creating %s", r.FullTypeName()), err.Error()) + return + } + + setUnmanagedSolutionState(&plan, solution) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *UnmanagedSolutionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state UnmanagedSolutionResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + solution, err := r.SolutionClient.GetSolutionById(ctx, state.EnvironmentId.ValueString(), state.Id.ValueString()) + if err != nil { + if errors.Is(err, customerrors.ErrObjectNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading %s", r.FullTypeName()), err.Error()) + return + } + if err := validateUnmanagedSolution(solution, r.FullTypeName()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading %s", r.FullTypeName()), err.Error()) + return + } + + setUnmanagedSolutionState(&state, solution) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *UnmanagedSolutionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan UnmanagedSolutionResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + var state UnmanagedSolutionResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + solution, err := r.SolutionClient.UpdateUnmanagedSolution( + ctx, + state.EnvironmentId.ValueString(), + state.Id.ValueString(), + plan.DisplayName.ValueString(), + plan.Description.ValueString(), + ) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when updating %s", r.FullTypeName()), err.Error()) + return + } + if err := validateUnmanagedSolution(solution, r.FullTypeName()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when updating %s", r.FullTypeName()), err.Error()) + return + } + + plan.EnvironmentId = state.EnvironmentId + plan.UniqueName = state.UniqueName + plan.PublisherId = state.PublisherId + setUnmanagedSolutionState(&plan, solution) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *UnmanagedSolutionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state UnmanagedSolutionResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if state.EnvironmentId.IsNull() || state.Id.IsNull() { + return + } + + err := r.SolutionClient.DeleteSolution(ctx, state.EnvironmentId.ValueString(), state.Id.ValueString()) + if err != nil && !errors.Is(err, customerrors.ErrObjectNotFound) { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when deleting %s", r.FullTypeName()), err.Error()) + } +} + +func (r *UnmanagedSolutionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + idParts := splitSolutionCompositeID(req.ID) + if len(idParts) != 2 { + resp.Diagnostics.AddError( + "Invalid import ID", + fmt.Sprintf("Expected import ID in format 'environment_id_solution_id', got '%s'", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("environment_id"), idParts[0])...) +} + +func setUnmanagedSolutionState(model *UnmanagedSolutionResourceModel, solution *SolutionDto) { + existingDescription := model.Description + + model.Id = types.StringValue(solution.Id) + model.UniqueName = types.StringValue(solution.Name) + model.DisplayName = types.StringValue(solution.DisplayName) + model.PublisherId = types.StringValue(solution.PublisherId) + model.Description = normalizeNullableDescription(solution.Description, existingDescription) +} + +func validateUnmanagedSolution(solution *SolutionDto, typeName string) error { + if solution != nil && solution.IsManaged { + return fmt.Errorf("solution '%s' is managed and cannot be used with %s", solution.Name, typeName) + } + + return nil +} + +func splitSolutionCompositeID(id string) []string { + return strings.SplitN(id, "_", 2) +} + +func normalizeNullableDescription(value string, existing types.String) types.String { + if value != "" { + return types.StringValue(value) + } + + if !existing.IsNull() && !existing.IsUnknown() && existing.ValueString() == "" { + return types.StringValue("") + } + + return types.StringNull() +} diff --git a/internal/services/solution/resource_unmanaged_solution_test.go b/internal/services/solution/resource_unmanaged_solution_test.go new file mode 100644 index 000000000..0af8e63cc --- /dev/null +++ b/internal/services/solution/resource_unmanaged_solution_test.go @@ -0,0 +1,541 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package solution_test + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/jarcoal/httpmock" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" + "github.com/microsoft/terraform-provider-power-platform/internal/mocks" +) + +const ( + unmanagedSolutionEnvironmentID = "00000000-0000-0000-0000-000000000001" + unmanagedSolutionID = "86928ed8-df37-4ce2-add5-47030a833bff" + unmanagedPublisherID = "aa47dc6c-bf13-490b-a007-1da95a0d1e3f" +) + +func TestUnitUnmanagedSolutionResource_Validate_Create(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions", + func(req *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusCreated, "") + resp.Header.Set("OData-EntityId", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions("+unmanagedSolutionID+")") + return resp, nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=solutionid+eq+"+unmanagedSolutionID, + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_solution.json").String()), nil + }) + + httpmock.RegisterRegexpResponder("DELETE", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions%28`+regexp.QuoteMeta(unmanagedSolutionID)+`%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + description = "Created by Terraform" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "id", unmanagedSolutionID), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "environment_id", unmanagedSolutionEnvironmentID), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "uniquename", "TerraformTestSolution"), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "display_name", "Terraform Test Solution"), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "publisher_id", unmanagedPublisherID), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "description", "Created by Terraform"), + ), + }, + { + ResourceName: "powerplatform_unmanaged_solution.solution", + ImportState: true, + ImportStateVerify: true, + ImportStateId: unmanagedSolutionEnvironmentID + "_" + unmanagedSolutionID, + }, + }, + }) +} + +func TestUnitUnmanagedSolutionResource_Validate_Update(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + updated := false + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Update/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions", + func(req *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusCreated, "") + resp.Header.Set("OData-EntityId", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions("+unmanagedSolutionID+")") + return resp, nil + }) + + httpmock.RegisterRegexpResponder("PATCH", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/solutions%28`+regexp.QuoteMeta(unmanagedSolutionID)+`%29$`), + func(req *http.Request) (*http.Response, error) { + updated = true + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=solutionid+eq+"+unmanagedSolutionID, + func(req *http.Request) (*http.Response, error) { + fileName := "tests/resource/Validate_Unmanaged_Update/get_solution_before.json" + if updated { + fileName = "tests/resource/Validate_Unmanaged_Update/get_solution_after.json" + } + return httpmock.NewStringResponse(http.StatusOK, httpmock.File(fileName).String()), nil + }) + + httpmock.RegisterRegexpResponder("DELETE", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions%28`+regexp.QuoteMeta(unmanagedSolutionID)+`%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + description = "Created by Terraform" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "display_name", "Terraform Test Solution"), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "description", "Created by Terraform"), + ), + }, + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution Updated" + publisher_id = "` + unmanagedPublisherID + `" + description = "Updated by Terraform" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "display_name", "Terraform Test Solution Updated"), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "description", "Updated by Terraform"), + ), + }, + }, + }) +} + +func TestUnitUnmanagedSolutionResource_Validate_Create_No_Dataverse(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_No_Dataverse/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + }`, + ExpectError: regexp.MustCompile(fmt.Sprintf("No Dataverse exists in environment '%s'", unmanagedSolutionEnvironmentID)), + }, + }, + }) +} + +func TestUnitUnmanagedSolutionResource_Validate_Create_Eventual_Consistency(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + readAttempts := 0 + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=uniquename+eq+%27TerraformTestSolution%27", + func(req *http.Request) (*http.Response, error) { + readAttempts++ + if readAttempts == 1 { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + } + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_solution.json").String()), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=solutionid+eq+"+unmanagedSolutionID, + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_solution.json").String()), nil + }) + + httpmock.RegisterRegexpResponder("DELETE", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions%28`+regexp.QuoteMeta(unmanagedSolutionID)+`%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + description = "Created by Terraform" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "id", unmanagedSolutionID), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "uniquename", "TerraformTestSolution"), + ), + }, + }, + }) + + if readAttempts < 2 { + t.Fatalf("expected create flow to poll for visibility, got %d read attempt(s)", readAttempts) + } +} + +func TestUnitUnmanagedSolutionResource_Validate_Create_Uses_Response_ID(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusCreated, `{ + "solutionid":"`+unmanagedSolutionID+`", + "uniquename":"TerraformTestSolution", + "friendlyname":"Terraform Test Solution", + "description":"Created by Terraform", + "_publisherid_value":"`+unmanagedPublisherID+`" + }`), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=solutionid+eq+"+unmanagedSolutionID, + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_solution.json").String()), nil + }) + + httpmock.RegisterRegexpResponder("DELETE", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions%28`+regexp.QuoteMeta(unmanagedSolutionID)+`%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + description = "Created by Terraform" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "id", unmanagedSolutionID), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "publisher_id", unmanagedPublisherID), + ), + }, + }, + }) +} + +func TestUnitUnmanagedSolutionResource_Validate_Create_Empty_Description_No_Drift(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions", + func(req *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusCreated, "") + resp.Header.Set("OData-EntityId", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions("+unmanagedSolutionID+")") + return resp, nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=solutionid+eq+"+unmanagedSolutionID, + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{ + "value": [ + { + "solutionid": "`+unmanagedSolutionID+`", + "uniquename": "TerraformTestSolution", + "friendlyname": "Terraform Test Solution", + "description": "", + "_publisherid_value": "`+unmanagedPublisherID+`", + "ismanaged": false + } + ] + }`), nil + }) + + httpmock.RegisterRegexpResponder("DELETE", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions%28`+regexp.QuoteMeta(unmanagedSolutionID)+`%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + description = "" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "id", unmanagedSolutionID), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "description", ""), + ), + }, + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + description = "" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "description", ""), + ), + }, + }, + }) +} + +func TestUnitUnmanagedSolutionResource_Validate_Managed_Solution(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+unmanagedSolutionEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions", + func(req *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + resp.Header.Set("OData-EntityId", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/solutions("+unmanagedSolutionID+")") + return resp, nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.2/solutions?%24expand=publisherid&%24filter=solutionid+eq+"+unmanagedSolutionID, + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{ + "value": [ + { + "solutionid": "`+unmanagedSolutionID+`", + "uniquename": "TerraformTestSolution", + "friendlyname": "Terraform Test Solution", + "_publisherid_value": "`+unmanagedPublisherID+`", + "ismanaged": true + } + ] + }`), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = "` + unmanagedSolutionEnvironmentID + `" + uniquename = "TerraformTestSolution" + display_name = "Terraform Test Solution" + publisher_id = "` + unmanagedPublisherID + `" + description = "Created by Terraform" + }`, + ExpectError: regexp.MustCompile(`solution 'TerraformTestSolution' is managed and cannot be used with\s+powerplatform_unmanaged_solution`), + }, + }, + }) +} + +func TestAccUnmanagedSolutionResource_Validate_Create_Update(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: mocks.TestAccProtoV6ProviderFactories, + ExternalProviders: map[string]resource.ExternalProvider{ + "time": { + Source: "hashicorp/time", + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccUnmanagedSolutionConfig( + mocks.TestName(), + "TerraformUnmanagedSolutionAcc", + "Terraform Unmanaged Solution", + "Created by Terraform acceptance test", + true, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestMatchResourceAttr("powerplatform_unmanaged_solution.solution", "id", regexp.MustCompile(helpers.GuidRegex)), + resource.TestMatchResourceAttr("powerplatform_unmanaged_solution.solution", "environment_id", regexp.MustCompile(helpers.GuidRegex)), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "uniquename", "TerraformUnmanagedSolutionAcc"), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "display_name", "Terraform Unmanaged Solution"), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "description", "Created by Terraform acceptance test"), + resource.TestMatchResourceAttr("powerplatform_unmanaged_solution.solution", "publisher_id", regexp.MustCompile(helpers.GuidRegex)), + resource.TestMatchResourceAttr("data.powerplatform_unmanaged_solution.lookup", "id", regexp.MustCompile(helpers.GuidRegex)), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.lookup", "uniquename", "TerraformUnmanagedSolutionAcc"), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.lookup", "display_name", "Terraform Unmanaged Solution"), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.lookup", "description", "Created by Terraform acceptance test"), + resource.TestMatchResourceAttr("data.powerplatform_unmanaged_solution.lookup", "publisher_id", regexp.MustCompile(helpers.GuidRegex)), + ), + }, + { + Config: testAccUnmanagedSolutionConfig( + mocks.TestName(), + "TerraformUnmanagedSolutionAcc", + "Terraform Unmanaged Solution Updated", + "Updated by Terraform acceptance test", + true, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "display_name", "Terraform Unmanaged Solution Updated"), + resource.TestCheckResourceAttr("powerplatform_unmanaged_solution.solution", "description", "Updated by Terraform acceptance test"), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.lookup", "display_name", "Terraform Unmanaged Solution Updated"), + resource.TestCheckResourceAttr("data.powerplatform_unmanaged_solution.lookup", "description", "Updated by Terraform acceptance test"), + ), + }, + }, + }) +} + +func testAccUnmanagedSolutionConfig(environmentDisplayName, uniqueName, displayName, description string, includeLookup bool) string { + lookupBlock := "" + if includeLookup { + lookupBlock = ` + + data "powerplatform_unmanaged_solution" "lookup" { + depends_on = [powerplatform_unmanaged_solution.solution] + environment_id = powerplatform_environment.environment.id + uniquename = powerplatform_unmanaged_solution.solution.uniquename + }` + } + + return fmt.Sprintf(` + + resource "powerplatform_environment" "environment" { + display_name = "%s" + location = "unitedstates" + environment_type = "Sandbox" + dataverse = { + language_code = "1033" + currency_code = "USD" + security_group_id = "00000000-0000-0000-0000-000000000000" + } + } + + resource "time_sleep" "wait_120_seconds" { + depends_on = [powerplatform_environment.environment] + create_duration = "120s" + } + + data "powerplatform_rest_query" "default_publisher" { + depends_on = [time_sleep.wait_120_seconds] + scope = "${powerplatform_environment.environment.dataverse.url}/.default" + url = "${powerplatform_environment.environment.dataverse.url}api/data/v9.2/publishers?$select=publisherid,uniquename&$filter=startswith(uniquename,'DefaultPublisher')&$top=1" + method = "GET" + expected_http_status = [200] + } + + locals { + default_publisher = jsondecode(data.powerplatform_rest_query.default_publisher.output.body).value[0] + } + + resource "powerplatform_unmanaged_solution" "solution" { + environment_id = powerplatform_environment.environment.id + uniquename = "%s" + display_name = "%s" + publisher_id = local.default_publisher.publisherid + description = "%s" + }%s + `, environmentDisplayName, uniqueName, displayName, description, lookupBlock) +} diff --git a/internal/services/solution/tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json b/internal/services/solution/tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json new file mode 100644 index 000000000..7295f5d59 --- /dev/null +++ b/internal/services/solution/tests/resource/Validate_Unmanaged_Create/get_environment_00000000-0000-0000-0000-000000000001.json @@ -0,0 +1,9 @@ +{ + "id": "/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001", + "name": "00000000-0000-0000-0000-000000000001", + "properties": { + "linkedEnvironmentMetadata": { + "instanceUrl": "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/" + } + } +} diff --git a/internal/services/solution/tests/resource/Validate_Unmanaged_Create/get_solution.json b/internal/services/solution/tests/resource/Validate_Unmanaged_Create/get_solution.json new file mode 100644 index 000000000..7b3b99bf7 --- /dev/null +++ b/internal/services/solution/tests/resource/Validate_Unmanaged_Create/get_solution.json @@ -0,0 +1,16 @@ +{ + "value": [ + { + "solutionid": "86928ed8-df37-4ce2-add5-47030a833bff", + "uniquename": "TerraformTestSolution", + "friendlyname": "Terraform Test Solution", + "description": "Created by Terraform", + "_publisherid_value": "aa47dc6c-bf13-490b-a007-1da95a0d1e3f", + "ismanaged": false, + "createdon": "2023-10-17T11:03:41Z", + "version": "1.0.0.0", + "modifiedon": "2023-10-17T11:05:17Z", + "installedon": "2023-10-17T11:03:41Z" + } + ] +} diff --git a/internal/services/solution/tests/resource/Validate_Unmanaged_No_Dataverse/get_environment_00000000-0000-0000-0000-000000000001.json b/internal/services/solution/tests/resource/Validate_Unmanaged_No_Dataverse/get_environment_00000000-0000-0000-0000-000000000001.json new file mode 100644 index 000000000..2ca512472 --- /dev/null +++ b/internal/services/solution/tests/resource/Validate_Unmanaged_No_Dataverse/get_environment_00000000-0000-0000-0000-000000000001.json @@ -0,0 +1,7 @@ +{ + "id": "/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001", + "name": "00000000-0000-0000-0000-000000000001", + "properties": { + "linkedEnvironmentMetadata": null + } +} diff --git a/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_environment_00000000-0000-0000-0000-000000000001.json b/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_environment_00000000-0000-0000-0000-000000000001.json new file mode 100644 index 000000000..7295f5d59 --- /dev/null +++ b/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_environment_00000000-0000-0000-0000-000000000001.json @@ -0,0 +1,9 @@ +{ + "id": "/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001", + "name": "00000000-0000-0000-0000-000000000001", + "properties": { + "linkedEnvironmentMetadata": { + "instanceUrl": "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/" + } + } +} diff --git a/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_solution_after.json b/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_solution_after.json new file mode 100644 index 000000000..ef2485361 --- /dev/null +++ b/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_solution_after.json @@ -0,0 +1,16 @@ +{ + "value": [ + { + "solutionid": "86928ed8-df37-4ce2-add5-47030a833bff", + "uniquename": "TerraformTestSolution", + "friendlyname": "Terraform Test Solution Updated", + "description": "Updated by Terraform", + "_publisherid_value": "aa47dc6c-bf13-490b-a007-1da95a0d1e3f", + "ismanaged": false, + "createdon": "2023-10-17T11:03:41Z", + "version": "1.0.0.0", + "modifiedon": "2023-10-17T11:10:17Z", + "installedon": "2023-10-17T11:03:41Z" + } + ] +} diff --git a/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_solution_before.json b/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_solution_before.json new file mode 100644 index 000000000..7b3b99bf7 --- /dev/null +++ b/internal/services/solution/tests/resource/Validate_Unmanaged_Update/get_solution_before.json @@ -0,0 +1,16 @@ +{ + "value": [ + { + "solutionid": "86928ed8-df37-4ce2-add5-47030a833bff", + "uniquename": "TerraformTestSolution", + "friendlyname": "Terraform Test Solution", + "description": "Created by Terraform", + "_publisherid_value": "aa47dc6c-bf13-490b-a007-1da95a0d1e3f", + "ismanaged": false, + "createdon": "2023-10-17T11:03:41Z", + "version": "1.0.0.0", + "modifiedon": "2023-10-17T11:05:17Z", + "installedon": "2023-10-17T11:03:41Z" + } + ] +}