-
Notifications
You must be signed in to change notification settings - Fork 18
Resource: Unmanaged Solution #1118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e42fb7d
d80353e
2c6bcbe
23be3e1
ac306cc
cfd20cb
d522e39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| time: 2026-03-30T16:55:00+11:00 | ||
| custom: | ||
| Issue: 1117 | ||
| 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 | ||
|
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. | ||
| 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} | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to be consistent with the rest of the provider:
Suggested change
|
||||||
| 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." | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| 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) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this function is used only in this file?
Suggested change
|
||||||
| 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 | ||||||
| } | ||||||
|
mawasile marked this conversation as resolved.
|
||||||
|
|
||||||
| func (client *Client) getEnvironment(ctx context.Context, environmentId string) (*environmentIdDto, error) { | ||||||
| apiUrl := &url.URL{ | ||||||
| Scheme: constants.HTTPS, | ||||||
|
|
||||||
There was a problem hiding this comment.
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
_importis here. I will get it merge into main asap.