Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: added
body: Added the `powerplatform_unmanaged_solution` resource for direct Dataverse solution CRUD without ZIP import lifecycle coupling.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, lets rename this resource and data source to powerplatform_solution.

The PR with rename of an exising solution into _import is here. I will get it merge into main asap.

time: 2026-03-30T16:55:00+11:00
custom:
Issue: 1117
46 changes: 46 additions & 0 deletions docs/data-sources/unmanaged_solution.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
AdamCoulterOz marked this conversation as resolved.
data "powerplatform_unmanaged_solution" "example" {
environment_id = var.environment_id
uniquename = "TerraformUnmanagedSolution"
}
```

<!-- schema generated by tfplugindocs -->
## 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.

<a id="nestedatt--timeouts"></a>
### 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.
63 changes: 63 additions & 0 deletions docs/resources/unmanaged_solution.md
Original file line number Diff line number Diff line change
@@ -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 generated by tfplugindocs -->
## 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.

<a id="nestedatt--timeouts"></a>
### 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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
data "powerplatform_unmanaged_solution" "example" {
environment_id = var.environment_id
uniquename = "TerraformUnmanagedSolution"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "unmanaged_solution" {
description = "Unmanaged solution details."
value = data.powerplatform_unmanaged_solution.example
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
variable "environment_id" {
type = string
description = "Power Platform environment id containing the unmanaged solution."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Unmanaged solution resources can be imported using the provider id format {environment_id}_{solution_id}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be consistent with the rest of the provider:

Suggested change
# Unmanaged solution resources can be imported using the provider id format {environment_id}_{solution_id}
# 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be consistent with the rest of the provider:

Suggested change
terraform import powerplatform_unmanaged_solution.solution 00000000-0000-0000-0000-000000000000_00000000-0000-0000-0000-000000000001
terraform import powerplatform_unmanaged_solution.solution 00000000-0000-0000-0000-000000000000/00000000-0000-0000-0000-000000000001

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "unmanaged_solution_id" {
description = "Provider id of the unmanaged solution"
value = powerplatform_unmanaged_solution.solution.id
}
Original file line number Diff line number Diff line change
@@ -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."
}
Original file line number Diff line number Diff line change
@@ -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."
}
2 changes: 2 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down Expand Up @@ -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() },
Expand Down
2 changes: 2 additions & 0 deletions internal/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
120 changes: 120 additions & 0 deletions internal/services/solution/api_solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"strings"

"github.com/microsoft/terraform-provider-power-platform/internal/api"
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return client.WaitForUnmanagedSolution(ctx, environmentId, solutionID, uniqueName)
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function is used only in this file?

Suggested change
func (client *Client) WaitForUnmanagedSolution(ctx context.Context, environmentId, solutionId, uniqueName string) (*SolutionDto, error) {
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
Expand Down Expand Up @@ -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
}
Comment thread
mawasile marked this conversation as resolved.

func (client *Client) getEnvironment(ctx context.Context, environmentId string) (*environmentIdDto, error) {
apiUrl := &url.URL{
Scheme: constants.HTTPS,
Expand Down
Loading